sveltejs / svelte · Issue No. 18546
If a render-phase effect ($effect.pre, or a block/template effect) calls flushSync(), and a later effect in that same traversal writes to state, the batch created by that write is silently dropped. It leaves CLEAN flags cleared on the effect tree with no batch owning them, and from that moment on every schedule() for anything under those branches takes the "branch is already dirty, bail" path.
The result is not a single missed update: the whole root goes permanently non-reactive. Effects never run again and the DOM never updates again, no matter what state changes — only a full page reload recovers.
Reproduced on 5.56.3 and 5.56.4.
<script>
import { flushSync } from 'svelte';
let s = $state({ restoring: false, item: 'A', shown: '-' });
// 1. a render-phase effect that flushes synchronously
// (in our real app: restoring scroll position after navigation)
$effect.pre(() => {
if (s.restoring) flushSync();
});
// 2. a *later* render-phase effect that writes state
$effect.pre(() => {
s.shown = s.item;
});
</script>
<button onclick={() => { s.restoring = true; s.item = 'B'; flushSync(); }}>
step 1 — restore + switch to B
</button>
<button onclick={() => { s.item = 'C'; }}>
step 2 — switch to C
</button>
<p>shown={s.shown}</p>
Steps: click step 1 (renders shown=B — still healthy), then click step 2.
Expected: shown=C
Actual: shown=B, forever. Every subsequent update is ignored, including plain async ones — the entire root is dead.
Note that step 2 is an ordinary update: the damage is done by step 1, and it is permanent.
Batch.prototype.schedule() (src/internal/client/reactivity/batch.js):
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) {
// branch is already dirty, bail
return;
}
e.f ^= CLEAN;
}
The early return assumes a not-CLEAN ancestor branch proves some batch has a root queued that will traverse down to this effect. But the CLEAN/DIRTY flags live on the shared effect tree, while #roots is per-Batch — so the assumption breaks as soon as a batch is dropped after its roots were populated.
Traced sequence for the repro (#3 = root, #2 = component branch, #5 = flushing pre-effect, #7 = writing pre-effect, #8 = text render effect):
s.restoring = true; s.item = 'B'; flushSync() → batch 4. schedule(#5) marks #2/#3 not-CLEAN and queues root #3.#process() → #traverse(#3) resets #3/#2 to CLEAN, then runs #5 → its flushSync() re-enters flush() on batch 4. The nested flush's finally sets current_batch = null and is_processing = false.#7, which writes s.shown. Batch.ensure() sees current_batch === null and creates batch 5. Because is_processing is now false while is_flushing_sync is true, ensure() queues no rescue microtask. schedule(#8) marks #2/#3 not-CLEAN again and pushes root #3 into batch 5's #roots.#8 anyway, so shown=B renders and everything looks fine.#process(), current_batch = null drops batch 5. Its #roots are discarded — but #2/#3 are left not-CLEAN, owned by nobody.schedule() now walks up, hits a not-CLEAN ancestor, and takes the // branch is already dirty, bail path. The new batch ends up with zero roots and traverses nothing. Dead forever.mark_reactions won't re-schedule an already-DIRTY effect and #traverse skips CLEAN branches, so nothing can ever rescue the tree.
Removing the early bail (walk the whole ancestor chain, flipping CLEAN branches, and de-duplicate the pushed root) makes the repro pass and keeps the rest of the suite green:
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) !== 0) {
e.f ^= CLEAN;
}
}
// ...
if (!this.#roots.includes(e)) {
this.#roots.push(e);
}
This restores the invariant the bail was trying to assume: a scheduled effect always sits under a not-CLEAN path from a root that is actually queued on this batch. Fixing the batch-dropping in #process()/ensure() instead would presumably be the more surgical fix; we don't have a view on which the team prefers.
We hit this in a SvelteKit app. Closing a full-screen overlay and re-opening it left the entire re-mounted subtree permanently non-reactive: the panel kept rendering the previously-selected item's data, and even its own toggle button stopped responding — until a full page reload. It took a long time to track down because all of the application state and every network response was correct; the effects simply never ran again.
Silent and unrecoverable: no error is thrown, the app just stops reacting.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.