From 59aedbe12c48cf6efa97271b53a4f1e9e6a449ea Mon Sep 17 00:00:00 2001 From: busya Date: Mon, 18 May 2026 11:43:27 +0300 Subject: [PATCH] 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 --- .../ADR-0007-rejected-fromStore-derived.md | 61 +++++++++++++++ .../src/lib/components/layout/Sidebar.svelte | 9 +-- .../TranslationRunGlobalIndicator.svelte | 29 ++++--- frontend/src/lib/stores/translationRun.js | 10 --- .../src/routes/translate/[id]/+page.svelte | 77 ++++++++++++++++--- 5 files changed, 149 insertions(+), 37 deletions(-) create mode 100644 docs/adr/ADR-0007-rejected-fromStore-derived.md 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 @@