diff --git a/docs/adr/ADR-0007-rejected-fromStore-derived.md b/docs/adr/ADR-0007-rejected-fromStore-derived.md new file mode 100644 index 00000000..a8c35acc --- /dev/null +++ b/docs/adr/ADR-0007-rejected-fromStore-derived.md @@ -0,0 +1,61 @@ +# 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`) 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: +```javascript +const state = fromStore(store); +let foo = $derived(state.current?.foo || null); +let bar = $derived(state.current?.bar || 0); +``` + +With: +```javascript +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); +``` diff --git a/frontend/src/lib/components/layout/Sidebar.svelte b/frontend/src/lib/components/layout/Sidebar.svelte index e26fa652..729a999b 100644 --- a/frontend/src/lib/components/layout/Sidebar.svelte +++ b/frontend/src/lib/components/layout/Sidebar.svelte @@ -187,12 +187,11 @@ closeMobile(); } - // Close mobile overlay on route change + // Close mobile overlay on route change — depends only on page.url.pathname, NOT isMobileOpen. + // Otherwise clicking the burger (which sets isMobileOpen=true) would immediately close it. $effect(() => { - if (isMobileOpen) { - page.url.pathname; - closeMobile(); - } + const _pathname = page.url.pathname; + closeMobile(); }); onMount(() => { diff --git a/frontend/src/lib/components/translate/TranslationRunGlobalIndicator.svelte b/frontend/src/lib/components/translate/TranslationRunGlobalIndicator.svelte index 367f55f7..4edd40b4 100644 --- a/frontend/src/lib/components/translate/TranslationRunGlobalIndicator.svelte +++ b/frontend/src/lib/components/translate/TranslationRunGlobalIndicator.svelte @@ -9,7 +9,6 @@