Files
ss-tools/docs/adr/ADR-0007-rejected-fromStore-derived.md
busya 59aedbe12c fix(translate): replace fromStore+ with +subscribe to prevent reactive loop, add runComplete flag, show error_message, collapsible run results
- 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
2026-05-18 11:43:27 +03:00

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 (flushVeynfn) throws Uncaught repeatedly.
  • CTFFznDe.js chunk 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_effect instances → no infinite reactive loop.
  • Works correctly in Svelte 5.43.x.

Negative

  • Requires explicit $state initial value and $effect subscription.
  • 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);