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 @@
+
+
+
+
+