pull/17862/merge
Mattias Granlund 21 hours ago committed by GitHub
commit 03527cfee4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: skip derived re-evaluation inside destroyed branch effects

@ -12,7 +12,9 @@ import {
WAS_MARKED,
DESTROYED,
CLEAN,
REACTION_RAN
INERT,
REACTION_RAN,
BRANCH_EFFECT
} from '#client/constants';
import {
active_reaction,
@ -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';
@ -328,9 +330,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;
}
@ -343,10 +343,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;
}
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;
@ -384,6 +398,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),
// 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);

@ -0,0 +1,9 @@
<script>
let { data } = $props();
const processed = $derived(data.toUpperCase());
export function getProcessed() {
return processed;
}
</script>

@ -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>');
}
});

@ -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…
Cancel
Save