fix(settings): hand back the effect root disposer the store was throwing away - #624
Merged
Conversation
…ing away `SettingsStore`'s constructor calls `$effect.root()` and drops what it returns. In the app that is harmless and deliberate: `settings` is a singleton, one per webview, and it is meant to keep its ~30 write effects and its `storage` listener until the window closes. There is nothing to dispose because nothing ever stops being needed. In the suite it is a leak. Every spec file shares one jsdom, so every store a test constructs adds another live effect per persisted key and another `storage` listener to that one environment, and they outlive the test that made them. `settingsPersistence.spec.ts` already builds a store per test -- two in the multi-window one -- and nothing has bitten yet only because no file reconstructs a store and flushes often enough for an abandoned one to answer first. That is a property of how few files run under vitest today, not of the code: the pilot in #615 is spreading it to another 25, and the failure it produces is a test writing a value some earlier test's store owns, which reads as flakiness rather than as a leak. Worth knowing before the tenth file, not after. So the disposer is kept and exposed as `dispose()`, the same stop-function shape `observeFoldLayout` returns, on the instance because a constructor cannot return a second value. The app calls it nowhere and its behaviour is unchanged to the character. The `window` listener comes off with it: it is registered by an `$effect` inside the same root, and destroying a root runs its effects' teardowns, so `removeEventListener` fires without a seam of its own. Nothing in the constructor registers anything outside the root, so there is no second half left holding the environment. The one thing `dispose()` does not reach is the `initOSType()` promise, which can still land on a disposed store's fields -- inert, because with the effects gone no write follows it. The spec constructs through a `createStore()` helper paired with vitest's `onTestFinished`, so a construction site cannot forget. The new test asserts both halves separately: a disposed store's field change writes nothing over the live store's key, and a `storage` event no longer reaches its fields. Falsified by dropping the `dispose()` call: the write assertion goes red with 12 in the key the live store had just set to 30, and with that assertion also removed the listener assertion goes red with the abandoned store's font size folded to 40. Both halves fail on their own, so neither is carried by the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SettingsStore's constructor calls$effect.root(…)and throws away the disposer it returns.In the app that is fine — the store is a singleton and lives until the process ends. In tests it is not: every
new SettingsStore()leaks about 30 live effects into the file's shared environment.It has not bitten yet. But
settingsPersistence.spec.tsalready constructs several stores in one file, and the vitest migration has 25 more files to go. A file that constructs stores and flushes repeatedly would see cross-test writes.This is the limitation the #615 pilot reported itself — worth knowing before file #10, not after. So it is fixed before the rollout rather than during it.
Interface
SettingsStore.dispose(). The disposer is stashed in a private#disposeEffectsand released by a method.Precedent:
observeFoldLayout(root): () => void, whose stop function the caller holds (MarkdownViewer.svelte:1121keeps it and calls it in its effect teardown). Same contract, adapted to a class — a constructor cannot hand back a second value, so it lives on the instance.Deliberately not the
resetDiagramCache/resetRichContentCacheshape: those are module-level seams for module-level state; this state is per-instance.The app singleton is unchanged — nothing calls
dispose()outside tests.The
windowlistener is covered — confirmed by test, not by reasoningThe
storagelistener is registered by an$effectinside the root (settings.svelte.ts:404-419), and destroying a root runs its effects' teardowns, soremoveEventListenerfires. The falsification below shows the listener assertion flipping purely on the presence ofdispose().Nothing in the constructor registers anything outside the root, so no second seam is needed.
Falsified — both halves separately
a disposed store stops writing and stops listeningcarries two independent assertions, each proven to fail on its own:abandoned.dispose():a disposed store still writes to localStorage — '12' !== '30'. The abandoned store's effect re-ran on a laterflushSync()and overwrote the live store's key.a disposed store is still listening on window — 40 !== 12. The abandoned store folded astorageevent into its own field.The convention is structural, not remembered
createStore()pairs construction with vitest'sonTestFinished, and all 29 construction sites route through it — so a new test cannot forget to tear down.AGENTS.mdstates the rule and that root disposal covers listeners registered inside it.One honest caveat, not papered over
initOSType()is an async promise the constructor fires. If it resolves afterdispose(), it still assignsosTypeand possibly the three font families on the disposed instance.That is not an effect leak and cannot write to localStorage — the effects are gone — so it cannot pollute another test. But a test that disposed a store and then read
store.editorFonton a later microtask would see it change. Recorded in the commit body; an abort flag would be code for a case nothing exercises.Verification
npm test966 pass / 0 failnpm run test:vitest68 pass / 0 fail (was 67)npm run check769 files / 0 errors / 0 warnings🤖 Generated with Claude Code