- TranslationRunGlobalIndicator: replace fromStore + 6x with (() => store.subscribe()) to prevent accumulating render_effect instances via createSubscriber that caused infinite Svelte reactive flush - translate/[id]/+page.svelte: add runComplete flag to hide progress bar after completion without touching the store; add collapsible run detail cards with status badge, counts, and error_message; filter invalid runs - translationRun.js: remove stale guard (no longer needed) - Sidebar.svelte: fix mobile overlay close on route change - docs/adr/ADR-0007-rejected-fromStore-derived.md: document REJECTED pattern
2.3 KiB
ADR-0007: REJECTED — fromStore + multiple $derived in Svelte 5
Status
REJECTED — pattern forbidden in ss-tools frontend.
Context
fromStore(store) returns a reactive object whose .current property
reflects the underlying Svelte writable store. Using it with multiple
$derived(runState.current?.xxx) values creates persistent render_effect
instances via Svelte's createSubscriber on every reactive re-evaluation.
Observed failure (ss-tools, May 2026)
TranslationRunGlobalIndicator used 6 $derived reading runState.current.
Each $derived re-evaluation (triggered by store update) called subscribe()
which called render_effect() inside createSubscriber. The render_effect
teardown queues a microtask that decrements the subscriber count. When all
teardowns execute, subscriber count drops to zero, which triggers increment(version),
which marks all $derived as dirty again — creating an infinite reactive flush loop.
Symptoms
- After ~300 store poll cycles, the browser tab reaches 100% CPU on 2 cores.
- Svelte runtime (
flush→Ve→yn→fn) throwsUncaughtrepeatedly. CTFFznDe.jschunk enters infinite loop.- Page becomes completely unresponsive.
Decision
fromStore MUST NOT be used with $derived when the .current object is
read by more than one $derived in the same component.
Use $effect(() => store.subscribe(s => state = s)) instead — this creates
exactly ONE subscription per component lifecycle, with no accumulating
render_effect instances.
Consequences
Positive
- Store subscription is created once per component mount and cleaned up on destroy.
- No accumulating
render_effectinstances → no infinite reactive loop. - Works correctly in Svelte 5.43.x.
Negative
- Requires explicit
$stateinitial value and$effectsubscription. - Slightly more verbose than
fromStore.
Migration
Replace:
const state = fromStore(store);
let foo = $derived(state.current?.foo || null);
let bar = $derived(state.current?.bar || 0);
With:
let _state = $state(initialValue);
$effect(() => {
const unsub = store.subscribe(s => { _state = s; });
return () => unsub();
});
let foo = $derived(_state?.foo || null);
let bar = $derived(_state?.bar || 0);