diff --git a/.changeset/fifty-steaks-float.md b/.changeset/fifty-steaks-float.md new file mode 100644 index 0000000000..b100f215be --- /dev/null +++ b/.changeset/fifty-steaks-float.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: address unowned propagation signal issue diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index a67f300b21..67d1593f41 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -895,15 +895,19 @@ function mark_signal_consumers(signal, to_status, force_schedule) { for (i = 0; i < length; i++) { const consumer = consumers[i]; const flags = consumer.flags; - if ( - (flags & DIRTY) !== 0 || - (!runes && consumer === current_effect) || - (!force_schedule && consumer === current_effect) - ) { + const unowned = (flags & UNOWNED) !== 0; + const dirty = (flags & DIRTY) !== 0; + // We skip any effects that are already dirty (but not unowned). Additionally, we also + // skip if the consumer is the same as the current effect (except if we're not in runes or we + // are in force schedule mode). + if ((dirty && !unowned) || ((!force_schedule || !runes) && consumer === current_effect)) { continue; } set_signal_status(consumer, to_status); - if ((flags & CLEAN) !== 0) { + // If the signal is not clean, then skip over it – with the exception of unowned signals that + // are already dirty. Unowned signals might be dirty because they are not captured as part of an + // effect. + if ((flags & CLEAN) !== 0 || (dirty && unowned)) { if ((consumer.flags & IS_EFFECT) !== 0) { schedule_effect(/** @type {import('./types.js').EffectSignal} */ (consumer), false); } else { diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-derived-unowned/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-derived-unowned/_config.js new file mode 100644 index 0000000000..d73f3351a3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-derived-unowned/_config.js @@ -0,0 +1,45 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // The component context class instance gets shared between tests, strangely, causing hydration to fail? + skip_if_hydrate: 'permanent', + + async test({ assert, target, component }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1, 2]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1, 2, 3]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(component.log, [ + 0, + 'class trigger false', + 'local trigger false', + 1, + 2, + 3, + 4, + 'class trigger true', + 'local trigger true' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-derived-unowned/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-derived-unowned/main.svelte new file mode 100644 index 0000000000..58b96457db --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-derived-unowned/main.svelte @@ -0,0 +1,36 @@ + + + + +