Skip to content

fix: top-most derived in a chain of deriveds marked as MAYBE_DIRTY when executed from a snippet $.fallback #16111

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed

Conversation

raythurnvoid
Copy link
Contributor

@raythurnvoid raythurnvoid commented Jun 9, 2025

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

  1. 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:

    const dummy = $.wrap_snippet(_page, function ($$anchor, $$arg0) {
        $.validate_snippet_args(...arguments);
    
        // The fallback is wrapped in a derived and then immediately read by `$.get`
        let value = $.derived_safe_equal(() => $.fallback($$arg0?.(), 0));
    
        $.get(value);
    });

    When $.get(value) is executed, it runs in a context where a skip_reaction flag is set to true. This is the trigger for the entire bug.

  2. Initial Render: An Inconsistent State is Created

    When the $.get(value) from the compiled snippet runs, it reads both derived1 and derived2. Because skip_reaction is true, the following happens:

    First, derived1 is evaluated. The update_derived function sees that skip_reaction is true and incorrectly marks derived1 as MAYBE_DIRTY.

    Code from packages/svelte/src/internal/client/reactivity/deriveds.js:

    export function update_derived(derived) {
        // ...
        // Because `skip_reaction` is true, and the derived is `UNOWNED`,
        // the status is set to `MAYBE_DIRTY` instead of `CLEAN`.
        var status =
            (skip_reaction || (derived.f & UNOWNED) !== 0) && derived.deps !== null
                ? MAYBE_DIRTY
                : CLEAN;
    
        set_signal_status(derived, status);
    }

    Next, derived2 is evaluated. The check_dirtiness function is called for it. Crucially, at the end of this function, derived2 is marked as CLEAN because it's being accessed within an active effect where skip_reaction does not prevent the cleaning.

    Code from packages/svelte/src/internal/client/runtime.js:

    export function check_dirtiness(reaction) { // `reaction` is derived2
        // ... dependency loop runs ...
        if (dependency.wv > reaction.wv) { // This is false
            return true;
        }
        // ...
        
        // 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 is MAYBE_DIRTY while its dependent, derived2, is CLEAN.

  3. Update Trigger: The Reactivity Chain is Broken

    Later, the override state is changed. This correctly calls mark_reactions on its dependencies, including derived1.

    Code from packages/svelte/src/internal/client/reactivity/sources.js:

    function mark_reactions(signal, status) {
        // ...
        for (/* ...reactions... */) {
            var reaction = reactions[i]; // This is derived1
            var flags = 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);
                }
                // ...
            }
        }
    }

    Because derived1 was stuck in the MAYBE_DIRTY state, the crucial check to continue the reaction chain fails. derived2 is never notified that it is stale.

  4. Result: Stale UI

    When Svelte flushes the effects to update the DOM, it sees that derived2 is still CLEAN 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 within update_derived. This ensures a derived signal's status is determined only by its own properties (UNOWNED and deps), preventing an unrelated context flag from corrupting its state.

Code change in packages/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 ensures derived1 is correctly marked CLEAN in the first step. As a result, when its source changes, the (flags & CLEAN) !== 0 check in mark_reactions passes, the reactivity chain remains intact, and derived2 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 including skip_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

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • Prefix your PR title with feat:, fix:, chore:, or docs:.
  • 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 test and lint the project with pnpm lint

Copy link
Contributor

github-actions bot commented Jun 9, 2025

Playground

pnpm add https://pkg.pr.new/svelte@16111

@raythurnvoid
Copy link
Contributor Author

Fixed by: #16110 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Derived value not receiving updates
1 participant