Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: store access on component destroy #14968

Merged
merged 1 commit into from
Jan 10, 2025
Merged
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/hot-kings-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: store access on component destroy
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ export function client_component(analysis, options) {
/** @type {ESTree.VariableDeclaration[]} */
const legacy_reactive_declarations = [];

let needs_store_cleanup = false;

for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind === 'legacy_reactive') {
legacy_reactive_declarations.push(
Expand All @@ -222,7 +224,10 @@ export function client_component(analysis, options) {
}
if (binding.kind === 'store_sub') {
if (store_setup.length === 0) {
store_setup.push(b.const('$$stores', b.call('$.setup_stores')));
needs_store_cleanup = true;
store_setup.push(
b.const(b.array_pattern([b.id('$$stores'), b.id('$$cleanup')]), b.call('$.setup_stores'))
);
}

// We're creating an arrow function that gets the store value which minifies better for two or more references
Expand Down Expand Up @@ -391,14 +396,28 @@ export function client_component(analysis, options) {
analysis.reactive_statements.size > 0 ||
component_returned_object.length > 0;

// we want the cleanup function for the stores to run as the very last thing
// so that it can effectively clean up the store subscription even after the user effects runs
if (should_inject_context) {
component_block.body.unshift(b.stmt(b.call('$.push', ...push_args)));

component_block.body.push(
component_returned_object.length > 0
? b.return(b.call('$.pop', b.object(component_returned_object)))
: b.stmt(b.call('$.pop'))
);
let to_push;

if (component_returned_object.length > 0) {
let pop_call = b.call('$.pop', b.object(component_returned_object));
to_push = needs_store_cleanup ? b.var('$$pop', pop_call) : b.return(pop_call);
} else {
to_push = b.stmt(b.call('$.pop'));
}

component_block.body.push(to_push);
}

if (needs_store_cleanup) {
component_block.body.push(b.stmt(b.call('$$cleanup')));
if (component_returned_object.length > 0) {
component_block.body.push(b.return(b.id('$$pop')));
}
}

if (analysis.uses_rest_props) {
Expand Down
37 changes: 27 additions & 10 deletions packages/svelte/src/internal/client/reactivity/store.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** @import { StoreReferencesContainer } from '#client' */
/** @import { Store } from '#shared' */
import { subscribe_to_store } from '../../../store/utils.js';
import { noop } from '../../shared/utils.js';
import { get as get_store } from '../../../store/shared/index.js';
import { define_property, noop } from '../../shared/utils.js';
import { get } from '../runtime.js';
import { teardown } from './effects.js';
import { mutable_source, set } from './sources.js';
Expand All @@ -13,6 +14,8 @@ import { mutable_source, set } from './sources.js';
*/
let is_store_binding = false;

let IS_UNMOUNTED = Symbol();

/**
* Gets the current value of a store. If the store isn't subscribed to yet, it will create a proxy
* signal that will be updated when the store is. The store references container is needed to
Expand All @@ -30,7 +33,8 @@ export function store_get(store, store_name, stores) {
unsubscribe: noop
});

if (entry.store !== store) {
// if the component that setup this is already unmounted we don't want to register a subscription
if (entry.store !== store && !(IS_UNMOUNTED in stores)) {
entry.unsubscribe();
entry.store = store ?? null;

Expand All @@ -54,6 +58,13 @@ export function store_get(store, store_name, stores) {
}
}

// if the component that setup this stores is already unmounted the source will be out of sync
// so we just use the `get` for the stores, less performant but it avoids to create a memory leak
// and it will keep the value consistent
if (store && IS_UNMOUNTED in stores) {
return get_store(store);
}

return get(entry.source);
}

Expand Down Expand Up @@ -103,20 +114,26 @@ export function invalidate_store(stores, store_name) {

/**
* Unsubscribes from all auto-subscribed stores on destroy
* @returns {StoreReferencesContainer}
* @returns {[StoreReferencesContainer, ()=>void]}
*/
export function setup_stores() {
/** @type {StoreReferencesContainer} */
const stores = {};

teardown(() => {
for (var store_name in stores) {
const ref = stores[store_name];
ref.unsubscribe();
}
});
function cleanup() {
teardown(() => {
for (var store_name in stores) {
const ref = stores[store_name];
ref.unsubscribe();
}
define_property(stores, IS_UNMOUNTED, {
enumerable: false,
value: true
});
});
}

return stores;
return [stores, cleanup];
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
const { store } = $props();

$effect(() => {
$store;
return () => {
console.log($store);
$store++;
console.log($store);
};
});
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { flushSync } from 'svelte';
import { test } from '../../test';

export default test({
async test({ assert, target, logs }) {
const input = target.querySelector('input');
flushSync(() => {
input?.click();
});
assert.deepEqual(logs, [0, 1]);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
import { writable } from 'svelte/store';

import Test from './Test.svelte';

let store = writable(0);

let checked = $state(true);
</script>

<input type="checkbox" bind:checked />

{#if checked}
<Test {store} />
{/if}
Loading