Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rare-ducks-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: skip derived re-evaluation inside destroyed branch effects
52 changes: 46 additions & 6 deletions packages/svelte/src/internal/client/reactivity/deriveds.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
WAS_MARKED,
DESTROYED,
CLEAN,
REACTION_RAN
INERT,
REACTION_RAN,
BRANCH_EFFECT
} from '#client/constants';
import {
active_reaction,
Expand Down Expand Up @@ -40,7 +42,7 @@ import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js';
import { batch_values, current_batch } from './batch.js';
import { batch_values, collected_effects, current_batch } from './batch.js';
import { increment_pending, unset_context } from './async.js';
import { deferred, includes, noop } from '../../shared/utils.js';
import { set_signal_status, update_derived_status } from './status.js';
Expand Down Expand Up @@ -315,9 +317,7 @@ function get_derived_parent_effect(derived) {
var parent = derived.parent;
while (parent !== null) {
if ((parent.f & DERIVED) === 0) {
// The original parent effect might've been destroyed but the derived
// is used elsewhere now - do not return the destroyed effect in that case
return (parent.f & DESTROYED) === 0 ? /** @type {Effect} */ (parent) : null;
return /** @type {Effect} */ (parent);
}
parent = parent.parent;
}
Expand All @@ -330,10 +330,24 @@ function get_derived_parent_effect(derived) {
* @returns {T}
*/
export function execute_derived(derived) {
var raw_parent = get_derived_parent_effect(derived);
var parent_effect = raw_parent !== null && (raw_parent.f & DESTROYED) !== 0 ? null : raw_parent;

// don't update deriveds inside a destroyed branch (e.g. {#if} or {#each}) —
// the branch scope is invalid and evaluating could trigger side effects
// with stale values.
if (
!is_destroying_effect &&
raw_parent !== null &&
(raw_parent.f & (DESTROYED | BRANCH_EFFECT)) === (DESTROYED | BRANCH_EFFECT)
) {
return derived.v;
}

Comment on lines +336 to +346
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

appears we never reach this code, because of the guard in update_derived

Suggested change
// don't update deriveds inside a destroyed branch (e.g. {#if} or {#each}) —
// the branch scope is invalid and evaluating could trigger side effects
// with stale values.
if (
!is_destroying_effect &&
raw_parent !== null &&
(raw_parent.f & (DESTROYED | BRANCH_EFFECT)) === (DESTROYED | BRANCH_EFFECT)
) {
return derived.v;
}

var value;
var prev_active_effect = active_effect;

set_active_effect(get_derived_parent_effect(derived));
set_active_effect(parent_effect);

if (DEV) {
let prev_eager_effects = eager_effects;
Expand Down Expand Up @@ -371,6 +385,32 @@ export function execute_derived(derived) {
* @returns {void}
*/
export function update_derived(derived) {
// Don't re-evaluate deriveds inside INERT (outroing) branches when the
// read originates from outside the branch. Re-evaluating would use stale
// dependency values (e.g. a prop that became `undefined` when the branch
// condition changed), violating the `{#if}` contract.
//
// In non-async mode, INERT branches are never walked by the scheduler,
// so any read is necessarily external — block unconditionally.
//
// In async mode, INERT branches ARE walked (to keep transitions alive),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is outdated now, might simplify some stuff

// so we only block reads during effect flushing (collected_effects === null
// and active_effect === null), which indicates the reader is an external
// effect, not the branch's own traversal.
if (!is_destroying_effect) {
var dominated_by_inert = async_mode_flag
? collected_effects === null && active_effect === null
: true;

if (dominated_by_inert) {
var parent = get_derived_parent_effect(derived);

if (parent !== null && (parent.f & INERT) !== 0 && (parent.f & DESTROYED) === 0) {
return;
}
}
}

var old_value = derived.v;
var value = execute_derived(derived);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script>
let { data } = $props();

const processed = $derived(data.toUpperCase());

export function getProcessed() {
return processed;
}
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { flushSync } from 'svelte';
import { test } from '../../test';

// Covers the INERT (outroing) counterpart to if-block-const-destroyed-external-reader.
// An external $derived reads a child component's $derived via bind:this, keeping it
// connected in the reactive graph while the branch is outroing. Without a guard,
// the inner derived re-evaluates with stale values mid-transition and crashes.
// The fix returns the cached value and keeps the derived dirty so it re-evaluates
// correctly if the branch reverses (INERT cleared) rather than being stuck with
// a stale clean value.
export default test({
ssrHtml: '<div></div><button>clear</button><p></p>',
html: '<div></div><button>clear</button><p>HELLO</p>',

async test({ assert, raf, target }) {
const [button] = target.querySelectorAll('button');

// Clearing value starts the out-transition (branch becomes INERT).
// Without the guard this crashes with a TypeError in async mode.
flushSync(() => button.click());

assert.htmlEqual(
target.innerHTML,
'<div style="opacity: 0;"></div><button>clear</button><p>HELLO</p>'
);

// Complete the transition — branch is now destroyed and div is removed.
raf.tick(100);

// Flush the bind:this teardown microtask and resulting effect updates.
await Promise.resolve();
flushSync();

assert.htmlEqual(target.innerHTML, '<button>clear</button><p></p>');
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script>
import { fade } from 'svelte/transition';
import Inner from './Inner.svelte';

let value = $state('hello');

let innerComp = $state();

// Reads Inner's derived value from outside the {#if} block, keeping it
// connected in the reactive graph even when the branch is outroing.
const externalView = $derived(innerComp?.getProcessed() ?? '');
</script>

{#if value}
<div out:fade={{ duration: 100 }}>
<Inner data={value} bind:this={innerComp} />
</div>
{/if}

<button onclick={() => (value = undefined)}>clear</button>
<p>{externalView}</p>
Loading