Skip to content

Commit ae4af68

Browse files
authored
fix: eagerly unsubscribe when store is changed (#10727)
fixes #9346
1 parent 3fd02f1 commit ae4af68

File tree

5 files changed

+132
-21
lines changed

5 files changed

+132
-21
lines changed

.changeset/short-countries-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: eagerly unsubscribe when store is changed

packages/svelte/src/compiler/phases/3-transform/client/utils.js

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -393,28 +393,37 @@ export function serialize_set_binding(node, context, fallback, options) {
393393
return b.call(left, value);
394394
} else if (is_store) {
395395
return b.call('$.store_set', serialize_get_binding(b.id(left_name), state), value);
396-
} else if (binding.kind === 'state') {
397-
return b.call(
398-
'$.set',
399-
b.id(left_name),
400-
context.state.analysis.runes &&
401-
!options?.skip_proxy_and_freeze &&
402-
should_proxy_or_freeze(value, context.state.scope)
403-
? b.call('$.proxy', value)
404-
: value
405-
);
406-
} else if (binding.kind === 'frozen_state') {
407-
return b.call(
408-
'$.set',
409-
b.id(left_name),
410-
context.state.analysis.runes &&
411-
!options?.skip_proxy_and_freeze &&
412-
should_proxy_or_freeze(value, context.state.scope)
413-
? b.call('$.freeze', value)
414-
: value
415-
);
416396
} else {
417-
return b.call('$.set', b.id(left_name), value);
397+
let call;
398+
if (binding.kind === 'state') {
399+
call = b.call(
400+
'$.set',
401+
b.id(left_name),
402+
context.state.analysis.runes &&
403+
!options?.skip_proxy_and_freeze &&
404+
should_proxy_or_freeze(value, context.state.scope)
405+
? b.call('$.proxy', value)
406+
: value
407+
);
408+
} else if (binding.kind === 'frozen_state') {
409+
call = b.call(
410+
'$.set',
411+
b.id(left_name),
412+
context.state.analysis.runes &&
413+
!options?.skip_proxy_and_freeze &&
414+
should_proxy_or_freeze(value, context.state.scope)
415+
? b.call('$.freeze', value)
416+
: value
417+
);
418+
} else {
419+
call = b.call('$.set', b.id(left_name), value);
420+
}
421+
422+
if (state.scope.get(`$${left.name}`)?.kind === 'store_sub') {
423+
return b.call('$.store_unsub', call, b.literal(`$${left.name}`), b.id('$$subscriptions'));
424+
} else {
425+
return call;
426+
}
418427
}
419428
} else {
420429
if (is_store) {

packages/svelte/src/internal/client/reactivity/store.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@ export function store_get(store, store_name, stores) {
4747
return value === UNINITIALIZED ? entry.last_value : value;
4848
}
4949

50+
/**
51+
* Unsubscribe from a store if it's not the same as the one in the store references container.
52+
* We need this in addition to `store_get` because someone could unsubscribe from a store but
53+
* then never subscribe to the new one (if any), causing the subscription to stay open wrongfully.
54+
* @param {import('#client').Store<any> | null | undefined} store
55+
* @param {string} store_name
56+
* @param {import('#client').StoreReferencesContainer} stores
57+
*/
58+
export function store_unsub(store, store_name, stores) {
59+
/** @type {import('#client').StoreReferencesContainer[''] | undefined} */
60+
let entry = stores[store_name];
61+
62+
if (entry && entry.store !== store) {
63+
// Don't reset store yet, so that store_get above can resubscribe to new store if necessary
64+
entry.unsubscribe();
65+
entry.unsubscribe = noop;
66+
}
67+
68+
return store;
69+
}
70+
5071
/**
5172
* @template V
5273
* @param {import('#client').Store<V> | null | undefined} store
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { tick } from 'svelte';
2+
import { ok, test } from '../../test';
3+
4+
// Test that the store is unsubscribed from, even if it's not referenced once the store itself is set to null
5+
export default test({
6+
async test({ target, assert }) {
7+
assert.htmlEqual(
8+
target.innerHTML,
9+
`<input type="number"> <p>0</p> <button>add watcher</button>`
10+
);
11+
12+
target.querySelector('button')?.click();
13+
await tick();
14+
assert.htmlEqual(
15+
target.innerHTML,
16+
`<input type="number"> <p>1</p> 1 <button>remove watcher</button>`
17+
);
18+
19+
const input = target.querySelector('input');
20+
ok(input);
21+
22+
input.stepUp();
23+
input.dispatchEvent(new Event('input', { bubbles: true }));
24+
await tick();
25+
assert.htmlEqual(
26+
target.innerHTML,
27+
`<input type="number"> <p>2</p> 2 <button>remove watcher</button>`
28+
);
29+
30+
target.querySelector('button')?.click();
31+
await tick();
32+
assert.htmlEqual(
33+
target.innerHTML,
34+
`<input type="number"> <p>2</p> <button>add watcher</button>`
35+
);
36+
37+
input.stepUp();
38+
input.dispatchEvent(new Event('input', { bubbles: true }));
39+
await tick();
40+
assert.htmlEqual(
41+
target.innerHTML,
42+
`<input type="number"> <p>2</p> <button>add watcher</button>`
43+
);
44+
45+
target.querySelector('button')?.click();
46+
await tick();
47+
assert.htmlEqual(
48+
target.innerHTML,
49+
`<input type="number"> <p>3</p> 3 <button>remove watcher</button>`
50+
);
51+
}
52+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script>
2+
import { writable, derived } from "svelte/store";
3+
4+
const obj = writable({ a: 1 });
5+
let count = $state(0);
6+
let watcherA = $state();
7+
8+
function watch (prop) {
9+
return derived(obj, (o) => {
10+
count++;
11+
return o[prop];
12+
});
13+
}
14+
</script>
15+
16+
<input type="number" bind:value={$obj.a}>
17+
<p>{count}</p>
18+
19+
{#if watcherA}
20+
{$watcherA}
21+
<button on:click={() => watcherA = null}>remove watcher</button>
22+
{:else}
23+
<button on:click={() => watcherA = watch("a")}>add watcher</button>
24+
{/if}

0 commit comments

Comments
 (0)