Skip to content
Open
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
6 changes: 5 additions & 1 deletion packages/core/primitives/signals/src/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,11 @@ export function producerAccessed(node: ReactiveNode): void {
// instead of eagerly destroying the previous link, we delay until we've finished recomputing
// the producers list, so that we can destroy all of the old links at once.
nextProducer: nextProducerLink,
prevConsumer: prevConsumerLink,
// Don't set prevConsumer here — it's only meaningful when the link is part of
// the producer's consumer list. producerAddLiveConsumer sets it correctly when
// the link is actually inserted. Setting it eagerly would create a dangling
// reference into the consumer list that prevents GC of removed entries.
prevConsumer: undefined,
lastReadVersion: node.version,
nextConsumer: undefined,
};
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/signals/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ ts_project(
"//packages/core",
"//packages/core/primitives/signals",
"//packages/core/src/util",
"//packages/private/testing",
],
)

Expand All @@ -20,6 +21,7 @@ jasmine_test(
data = [
":signals_lib",
],
node_options = ["--expose-gc"],
)

ng_web_test_suite(
Expand Down
68 changes: 68 additions & 0 deletions packages/core/test/signals/signal_graph_leak_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {computed, signal, WritableSignal} from '../../src/core';
import {createWatch, SIGNAL} from '../../primitives/signals';
import {isBrowser, timeout} from '@angular/private/testing';

describe('signal graph: destroyed consumers should be GC-eligible', () => {
if (isBrowser) {
it('should pass', () => {});
return;
}

function setupAndReturnRef(source: WritableSignal<number>): WeakRef<object> {
const watch = createWatch(
() => source(),
() => {},
true,
);
watch.run();

const ref = new WeakRef(watch[SIGNAL]);

// Non-live computed that also reads source.
const derived = computed(() => source() + 1);
derived();

// Clearing the graph edges.
watch.destroy();

return ref;
}

it('should GC a destroyed effect when a non-live computed reads the same producer', async () => {
const source = signal(0);
const ref = setupAndReturnRef(source);

(globalThis as any).gc();
await timeout();
(globalThis as any).gc();

expect(ref.deref()).toBeUndefined();
});

it('should GC destroyed effects across repeated create/destroy cycles', async () => {
const source = signal(0);
const derived = computed(() => source() + 1);
derived();

const refs: WeakRef<object>[] = [];
for (let i = 0; i < 5; i++) {
refs.push(setupAndReturnRef(source));
}

(globalThis as any).gc();
await timeout();
(globalThis as any).gc();

for (let i = 0; i < refs.length; i++) {
expect(refs[i].deref()).withContext(`cycle ${i}`).toBeUndefined();
}
});
});
Loading