Fixes:#16090
The Problem
A chain of derived signals fails to update if an intermediate signal in the chain is also read inside a{#snippet}
that has a parameter with a default value.
The root cause is a subtle interaction between how the snippet's fallback value is evaluated and how the reactivity system determines if a signal is "clean" or "dirty". This leads to an inconsistent state where the intermediate derived signal gets "stuck" and no longer propagates updates.
Detailed Breakdown
Compiler Generates a Fallback Reaction
The entire issue originates from the code the Svelte compiler generates for a snippet with a default parameter. A snippet like{#snippet dummy(value = 0)}
is compiled into a structure that uses a$.fallback()
call. This generated code then immediately reads the derived values within a special context.
The compiled output looks like this:
constdummy=$.wrap_snippet(_page,function($$anchor,$$arg0){$.validate_snippet_args(...arguments);// The fallback is wrapped in a derived and then immediately read by `$.get`letvalue=$.derived_safe_equal(()=>$.fallback($$arg0?.(),0));$.get(value);});
When$.get(value)
is executed, it runs in a context where askip_reaction
flag is set totrue
. This is the trigger for the entire bug.
Initial Render: An Inconsistent State is Created
When the$.get(value)
from the compiled snippet runs, it reads bothderived1
andderived2
. Becauseskip_reaction
istrue
, the following happens:
First,derived1
is evaluated. Theupdate_derived
function sees thatskip_reaction
is true and incorrectly marksderived1
asMAYBE_DIRTY
.
Code frompackages/svelte/src/internal/client/reactivity/deriveds.js
:
exportfunctionupdate_derived(derived){// ...// Because `skip_reaction` is true, and the derived is `UNOWNED`,// the status is set to `MAYBE_DIRTY` instead of `CLEAN`.varstatus=(skip_reaction||(derived.f&UNOWNED)!==0)&&derived.deps!==null ?MAYBE_DIRTY :CLEAN;set_signal_status(derived,status);}
Next,derived2
is evaluated. Thecheck_dirtiness
function is called for it. Crucially, at the end of this function,derived2
is marked asCLEAN
because it's being accessed within an active effect whereskip_reaction
does not prevent the cleaning.
Code frompackages/svelte/src/internal/client/runtime.js
:
exportfunctioncheck_dirtiness(reaction){// `reaction` is derived2// ... dependency loop runs ...if(dependency.wv>reaction.wv){// This is falsereturntrue;}// ...// This condition is met for derived2, so it gets marked CLEAN,// even though its dependency (derived1) is MAYBE_DIRTY.if(!is_unowned||(active_effect!==null&&!skip_reaction)){set_signal_status(reaction,CLEAN);}}
This creates an inconsistent state:derived1
isMAYBE_DIRTY
while its dependent,derived2
, isCLEAN
.
Update Trigger: The Reactivity Chain is Broken
Later, theoverride
state is changed. This correctly callsmark_reactions
on its dependencies, includingderived1
.
Code frompackages/svelte/src/internal/client/reactivity/sources.js
:
functionmark_reactions(signal,status){// ...for(/* ...reactions... */){var reaction=reactions[i];// This is derived1varflags=reaction.f;// `derived1` is now marked as DIRTY.set_signal_status(reaction,status);// This is where the chain breaks. Because `derived1` was MAYBE_DIRTY,// its `flags` do not contain the `CLEAN` bit. The condition fails,// and `mark_reactions` is never called on `derived2`.if((flags&(CLEAN|UNOWNED))!==0){if((flags&DERIVED)!==0){mark_reactions(/**@type {Derived} */(reaction),MAYBE_DIRTY);}// ...}}}
Becausederived1
was stuck in theMAYBE_DIRTY
state, the crucial check to continue the reaction chain fails.derived2
is never notified that it is stale.
Result: Stale UI
When Svelte flushes the effects to update the DOM, it sees thatderived2
is stillCLEAN
and therefore does not re-render it. The UI is stuck showing the old value.
The Fix
The fix is to remove theskip_reaction
check from the status calculation withinupdate_derived
. This ensures a derived signal's status is determined only by its own properties (UNOWNED
anddeps
), preventing an unrelated context flag from corrupting its state.
Code change inpackages/svelte/src/internal/client/reactivity/deriveds.js
:
// ...var status =-(skip_reaction || (derived.f & UNOWNED) !== 0) && derived.deps !== null ? MAYBE_DIRTY : CLEAN;+(derived.f & UNOWNED) !== 0 && derived.deps !== null ? MAYBE_DIRTY : CLEAN;set_signal_status(derived, status);// ...
This ensuresderived1
is correctly markedCLEAN
in the first step. As a result, when its source changes, the(flags & CLEAN) !== 0
check inmark_reactions
passes, the reactivity chain remains intact, andderived2
updates as expected.
Disclaimer
It is important to note that theskip_reaction
flag has been part of this status calculation for a significant time. Therefore, while this fix does resolve the observed bug, I am not completely certain it is the ideal solution, as I do not fully understand the original intent behind includingskip_reaction
in this specific logic. Removing the flag did not cause any existing tests to fail.
Furthermore, I was unable to create a test for this specific issue that fails without the fix and passes with it. The bug is consistently reproducible in a live browser environment, but it does not manifest in the JSDOM-based test runner.
Before submitting the PR, please make sure you do the following
Tests and linting
Uh oh!
There was an error while loading.Please reload this page.
Fixes:#16090
The Problem
A chain of derived signals fails to update if an intermediate signal in the chain is also read inside a
{#snippet}
that has a parameter with a default value.The root cause is a subtle interaction between how the snippet's fallback value is evaluated and how the reactivity system determines if a signal is "clean" or "dirty". This leads to an inconsistent state where the intermediate derived signal gets "stuck" and no longer propagates updates.
Detailed Breakdown
Compiler Generates a Fallback Reaction
The entire issue originates from the code the Svelte compiler generates for a snippet with a default parameter. A snippet like
{#snippet dummy(value = 0)}
is compiled into a structure that uses a$.fallback()
call. This generated code then immediately reads the derived values within a special context.The compiled output looks like this:
When
$.get(value)
is executed, it runs in a context where askip_reaction
flag is set totrue
. This is the trigger for the entire bug.Initial Render: An Inconsistent State is Created
When the
$.get(value)
from the compiled snippet runs, it reads bothderived1
andderived2
. Becauseskip_reaction
istrue
, the following happens:First,
derived1
is evaluated. Theupdate_derived
function sees thatskip_reaction
is true and incorrectly marksderived1
asMAYBE_DIRTY
.Code from
packages/svelte/src/internal/client/reactivity/deriveds.js
:Next,
derived2
is evaluated. Thecheck_dirtiness
function is called for it. Crucially, at the end of this function,derived2
is marked asCLEAN
because it's being accessed within an active effect whereskip_reaction
does not prevent the cleaning.Code from
packages/svelte/src/internal/client/runtime.js
:This creates an inconsistent state:
derived1
isMAYBE_DIRTY
while its dependent,derived2
, isCLEAN
.Update Trigger: The Reactivity Chain is Broken
Later, the
override
state is changed. This correctly callsmark_reactions
on its dependencies, includingderived1
.Code from
packages/svelte/src/internal/client/reactivity/sources.js
:Because
derived1
was stuck in theMAYBE_DIRTY
state, the crucial check to continue the reaction chain fails.derived2
is never notified that it is stale.Result: Stale UI
When Svelte flushes the effects to update the DOM, it sees that
derived2
is stillCLEAN
and therefore does not re-render it. The UI is stuck showing the old value.The Fix
The fix is to remove the
skip_reaction
check from the status calculation withinupdate_derived
. This ensures a derived signal's status is determined only by its own properties (UNOWNED
anddeps
), preventing an unrelated context flag from corrupting its state.Code change in
packages/svelte/src/internal/client/reactivity/deriveds.js
:This ensures
derived1
is correctly markedCLEAN
in the first step. As a result, when its source changes, the(flags & CLEAN) !== 0
check inmark_reactions
passes, the reactivity chain remains intact, andderived2
updates as expected.Disclaimer
It is important to note that the
skip_reaction
flag has been part of this status calculation for a significant time. Therefore, while this fix does resolve the observed bug, I am not completely certain it is the ideal solution, as I do not fully understand the original intent behind includingskip_reaction
in this specific logic. Removing the flag did not cause any existing tests to fail.Furthermore, I was unable to create a test for this specific issue that fails without the fix and passes with it. The bug is consistently reproducible in a live browser environment, but it does not manifest in the JSDOM-based test runner.
Before submitting the PR, please make sure you do the following
feat:
,fix:
,chore:
, ordocs:
.packages/svelte/src
, add a changeset (npx changeset
).Tests and linting
pnpm test
and lint the project withpnpm lint