Skip to content

fix(settings): hand back the effect root disposer the store was throwing away - #624

Merged
PathGao merged 1 commit into
masterfrom
fix/settings-store-disposer
Aug 12, 2026
Merged

fix(settings): hand back the effect root disposer the store was throwing away#624
PathGao merged 1 commit into
masterfrom
fix/settings-store-disposer

Conversation

@PathGao

@PathGao PathGao commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

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.ts already 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 #disposeEffects and released by a method.

Precedent: observeFoldLayout(root): () => void, whose stop function the caller holds (MarkdownViewer.svelte:1121 keeps 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 / resetRichContentCache shape: 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 window listener is covered — confirmed by test, not by reasoning

The storage listener is registered by an $effect inside the root (settings.svelte.ts:404-419), and destroying a root runs its effects' teardowns, so removeEventListener fires. The falsification below shows the listener assertion flipping purely on the presence of dispose().

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 listening carries two independent assertions, each proven to fail on its own:

  1. Write half — remove abandoned.dispose(): a disposed store still writes to localStorage — '12' !== '30'. The abandoned store's effect re-ran on a later flushSync() and overwrote the live store's key.
  2. Listener half — with assertion 1 also removed (it sits first and short-circuits): a disposed store is still listening on window — 40 !== 12. The abandoned store folded a storage event into its own field.

The convention is structural, not remembered

createStore() pairs construction with vitest's onTestFinished, and all 29 construction sites route through it — so a new test cannot forget to tear down. AGENTS.md states 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 after dispose(), it still assigns osType and 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.editorFont on 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 test 966 pass / 0 fail
  • npm run test:vitest 68 pass / 0 fail (was 67)
  • npm run check 769 files / 0 errors / 0 warnings

🤖 Generated with Claude Code

…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>
@PathGao
PathGao merged commit 819301c into master Aug 12, 2026
4 checks passed
@PathGao
PathGao deleted the fix/settings-store-disposer branch August 12, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant