Skip to content

Commit 36b270e

Browse files
authored
fix: address derived memory leak on disconnection from reactive graph (#11819)
Fixes #11817. It turns out that we weren't fully handling the disconnection of derived signals from the reactivity graph – resulting in cases where they could leak memory because they were not being removed from their dependency reactions array. However, removing ourselves from this array meant that any future changes might mean we need to reconnect the derived to the graph again – so we have to do some additional bookkeeping to make this work. Thankfully, we already have versioning because of unowned deriveds, we can use the versions to understand if the owned derived signal is dirty after being connected to the reactivity graph again – something we couldn't do in early permutations of the signal architecture – and likely why we hadn't addressed this in the same sense.
1 parent c71e29c commit 36b270e

File tree

5 files changed

+74
-15
lines changed

5 files changed

+74
-15
lines changed

.changeset/afraid-worms-drum.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: address derived memory leak on disconnection from reactive graph

packages/svelte/src/internal/client/constants.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ export const BLOCK_EFFECT = 1 << 4;
55
export const BRANCH_EFFECT = 1 << 5;
66
export const ROOT_EFFECT = 1 << 6;
77
export const UNOWNED = 1 << 7;
8-
export const CLEAN = 1 << 8;
9-
export const DIRTY = 1 << 9;
10-
export const MAYBE_DIRTY = 1 << 10;
11-
export const INERT = 1 << 11;
12-
export const DESTROYED = 1 << 12;
13-
export const EFFECT_RAN = 1 << 13;
8+
export const DISCONNECTED = 1 << 8;
9+
export const CLEAN = 1 << 9;
10+
export const DIRTY = 1 << 10;
11+
export const MAYBE_DIRTY = 1 << 11;
12+
export const INERT = 1 << 12;
13+
export const DESTROYED = 1 << 13;
14+
export const EFFECT_RAN = 1 << 14;
1415
/** 'Transparent' effects do not create a transition boundary */
15-
export const EFFECT_TRANSPARENT = 1 << 14;
16+
export const EFFECT_TRANSPARENT = 1 << 15;
1617
/** Svelte 4 legacy mode props need to be handled with deriveds and be recognized elsewhere, hence the dedicated flag */
17-
export const LEGACY_DERIVED_PROP = 1 << 15;
18+
export const LEGACY_DERIVED_PROP = 1 << 16;
1819

1920
export const STATE_SYMBOL = Symbol('$state');
2021
export const LOADING_ATTR_SYMBOL = Symbol('');

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ export function prop(props, key, flags, fallback) {
323323
from_child = true;
324324
set(inner_current_value, value);
325325
get(current_value); // force a synchronisation immediately
326+
// Increment the value so that unowned/disconnected nodes can validate dirtiness again.
327+
current_value.version++;
326328
}
327329

328330
return value;

packages/svelte/src/internal/client/runtime.js

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
STATE_SYMBOL,
1717
BLOCK_EFFECT,
1818
ROOT_EFFECT,
19-
LEGACY_DERIVED_PROP
19+
LEGACY_DERIVED_PROP,
20+
DISCONNECTED
2021
} from './constants.js';
2122
import { flush_tasks } from './dom/task.js';
2223
import { add_owner } from './dev/ownership.js';
@@ -198,26 +199,29 @@ export function check_dirtiness(reaction) {
198199
return true;
199200
}
200201

202+
var is_disconnected = (flags & DISCONNECTED) !== 0;
203+
201204
if ((flags & MAYBE_DIRTY) !== 0 || (is_dirty && is_unowned)) {
202205
var dependencies = reaction.deps;
203206

204207
if (dependencies !== null) {
205208
var length = dependencies.length;
206209
var is_equal;
210+
var reactions;
207211

208212
for (var i = 0; i < length; i++) {
209213
var dependency = dependencies[i];
210214

211215
if (!is_dirty && check_dirtiness(/** @type {import('#client').Derived} */ (dependency))) {
212216
is_equal = update_derived(/** @type {import('#client').Derived} **/ (dependency), true);
213217
}
218+
var version = dependency.version;
214219

215220
if (is_unowned) {
216221
// If we're working with an unowned derived signal, then we need to check
217222
// if our dependency write version is higher. If it is then we can assume
218223
// that state has changed to a newer version and thus this unowned signal
219224
// is also dirty.
220-
var version = dependency.version;
221225

222226
if (version > /** @type {import('#client').Derived} */ (reaction).version) {
223227
/** @type {import('#client').Derived} */ (reaction).version = version;
@@ -228,7 +232,7 @@ export function check_dirtiness(reaction) {
228232
// If we are working with an unowned signal as part of an effect (due to !current_skip_reaction)
229233
// and the version hasn't changed, we still need to check that this reaction
230234
// if linked to the dependency source – otherwise future updates will not be caught.
231-
var reactions = dependency.reactions;
235+
reactions = dependency.reactions;
232236
if (reactions === null) {
233237
dependency.reactions = [reaction];
234238
} else {
@@ -238,6 +242,20 @@ export function check_dirtiness(reaction) {
238242
} else if ((reaction.f & DIRTY) !== 0) {
239243
// `signal` might now be dirty, as a result of calling `check_dirtiness` and/or `update_derived`
240244
return true;
245+
} else if (is_disconnected) {
246+
// It might be that the derived was was dereferenced from its dependencies but has now come alive again.
247+
// In thise case, we need to re-attach it to the graph and mark it dirty if any of its dependencies have
248+
// changed since.
249+
if (version > /** @type {import('#client').Derived} */ (reaction).version) {
250+
/** @type {import('#client').Derived} */ (reaction).version = version;
251+
is_dirty = true;
252+
}
253+
reactions = dependency.reactions;
254+
if (reactions === null) {
255+
dependency.reactions = [reaction];
256+
} else if (!reactions.includes(reaction)) {
257+
reactions.push(reaction);
258+
}
241259
}
242260
}
243261
}
@@ -246,6 +264,9 @@ export function check_dirtiness(reaction) {
246264
if (!is_unowned) {
247265
set_signal_status(reaction, CLEAN);
248266
}
267+
if (is_disconnected) {
268+
reaction.f ^= DISCONNECTED;
269+
}
249270
}
250271

251272
return is_dirty;
@@ -422,9 +443,15 @@ function remove_reaction(signal, dependency) {
422443
}
423444
}
424445
}
425-
if (reactions_length === 0 && (dependency.f & UNOWNED) !== 0) {
426-
// If the signal is unowned then we need to make sure to change it to maybe dirty.
446+
// If the derived has no reactions, then we can disconnect it from the graph,
447+
// allowing it to either reconnect in the future, or be GC'd by the VM.
448+
if (reactions_length === 0 && (dependency.f & DERIVED) !== 0) {
427449
set_signal_status(dependency, MAYBE_DIRTY);
450+
// If we are working with a derived that is owned by an effect, then mark it as being
451+
// disconnected.
452+
if ((dependency.f & (UNOWNED | DISCONNECTED)) === 0) {
453+
dependency.f ^= DISCONNECTED;
454+
}
428455
remove_reactions(/** @type {import('#client').Derived} **/ (dependency), 0);
429456
}
430457
}
@@ -815,7 +842,7 @@ export function get(signal) {
815842
) {
816843
if (current_dependencies === null) {
817844
current_dependencies = [signal];
818-
} else {
845+
} else if (current_dependencies[current_dependencies.length - 1] !== signal) {
819846
current_dependencies.push(signal);
820847
}
821848
}

packages/svelte/tests/signals/test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { describe, assert, it } from 'vitest';
22
import { flushSync } from '../../src/index-client';
33
import * as $ from '../../src/internal/client/runtime';
44
import {
5+
destroy_effect,
56
effect,
67
effect_root,
78
render_effect,
89
user_effect
910
} from '../../src/internal/client/reactivity/effects';
1011
import { source, set } from '../../src/internal/client/reactivity/sources';
11-
import type { Derived, Value } from '../../src/internal/client/types';
12+
import type { Derived, Effect, Value } from '../../src/internal/client/types';
1213
import { proxy } from '../../src/internal/client/proxy';
1314
import { derived } from '../../src/internal/client/reactivity/deriveds';
1415

@@ -423,4 +424,27 @@ describe('signals', () => {
423424
destroy();
424425
};
425426
});
427+
428+
test('owned deriveds correctly cleanup when no longer connected to graph', () => {
429+
let a: Derived<unknown>;
430+
let state = source(0);
431+
432+
const destroy = effect_root(() => {
433+
render_effect(() => {
434+
a = derived(() => {
435+
$.get(state);
436+
});
437+
$.get(a);
438+
});
439+
});
440+
441+
return () => {
442+
flushSync();
443+
assert.equal(a?.deps?.length, 1);
444+
assert.equal(state?.reactions?.length, 1);
445+
destroy();
446+
assert.equal(a?.deps?.length, 1);
447+
assert.equal(state?.reactions, null);
448+
};
449+
});
426450
});

0 commit comments

Comments
 (0)