$state mutation inside getter read during flush deadlocks scheduler (regression in 5.53.8)
#17891
Development PRs
Description
Fixes #17891
When a getter lazily writes $state on first read, and that getter is read during the flush/commit phase (e.g., in a style: binding), the scheduler deadlocks silently in 5.53.8+.
The bug
In Batch.schedule(), when walking up the effect tree, if a branch is already "dirty" (CLEAN bit is 0), the method bails out early:
if ((flags & CLEAN) === 0) {
// branch is already dirty, bail
return;
}
The assumption is: if the branch is dirty, the root must already be scheduled. But this breaks when a new batch is created during flush — the branches are dirty from the previous batch's traversal, but the root isn't scheduled in the new batch.
The fix
Don't bail when branch is dirty — just skip marking it (since it's already dirty) and continue to the root. Add deduplication check to prevent pushing the same root multiple times:
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) !== 0) {
e.f ^= CLEAN;
}
// Don't bail — continue to root
}
// ...
if (!includes.call(this.#roots, e)) {
this.#roots.push(e);
}
Test
Added state-write-in-getter-during-flush runtime test that reproduces the exact pattern from the bug report.
Before submitting the PR, please make sure you do the following
- It's really useful if your PR references an issue where it is discussed ahead of time.
- Prefix your PR title with
feat:,fix:,chore:, ordocs:. - This message body should clearly illustrate what problems it solves.
- Ideally, include a test that fails without this PR but passes with it.
- If this PR changes code within
packages/svelte/src, add a changeset (npx changeset).
Tests and linting
- Run the tests with
pnpm testand lint the project withpnpm lint
(Unable to run locally — CI will validate)
In some cases a new branch might create effects which via reading/writing reschedule an effect, causing this.#roots to become populated again. In this case we need to re-process the batch. Most of the time this will just result in a cleanup of the dirtied branches since other work is already handled via running the effects etc. - it's still crucial, else the reactive graph becomes frozen since no new root effects are scheduled.
Fixes #17891
Issue
Describe the bug
A store getter that lazily writes $state on first read deadlocks the scheduler when read inside a template binding during the flush/commit phase. The UI freezes permanently — no errors, no stack overflow, no crash.
Works in: 5.53.7
Broken in: 5.53.8, 5.53.9
Likely cause: #17805 "simplify scheduling logic"
Reproduction
https://github.com/vdg/svelte-5.53.8-scheduler-deadlock
git clone https://github.com/vdg/svelte-5.53.8-scheduler-deadlock
cd svelte-5.53.8-scheduler-deadlock
npm install
npm run dev
- Click "Open panel"
- Click "Counter" — counter does not update, UI is frozen
- No console errors
Change to [email protected] → works fine.
The pattern
A module store with a getter that writes $state on first read:
let panelWidth = $state(null)
export default {
get panelWidth () {
if (panelWidth === null) panelWidth = DEFAULT_WIDTH // writes $state
return panelWidth
}
}
Read by a template binding:
<div style:width={store.panelWidth ? `${store.panelWidth}px` : null}>
What happens
- User action triggers a flush
- During commit, the
style:widthbinding readsstore.panelWidthfor the first time - The getter writes
$state(lazy init) - In 5.53.8+, this stalls the scheduler — no further updates are processed
Workaround
Avoid writing $state inside getters:
get panelWidth () {
return panelWidth ?? DEFAULT_WIDTH // no state write
}
Severity
Silent — no errors or warnings. The page appears frozen but JS is still running. Very hard to diagnose without knowing to look for this specific pattern.
Info
Pro tip: You can prefix GitHub URLs of issues, PRs or discussions with svcl.dev/ to view them on this page! Also try it on a GitHub release ;)