diff --git a/packages/core/primitives/signals/src/graph.ts b/packages/core/primitives/signals/src/graph.ts index 144105b4c569..fd64bc226d58 100644 --- a/packages/core/primitives/signals/src/graph.ts +++ b/packages/core/primitives/signals/src/graph.ts @@ -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, }; diff --git a/packages/core/test/signals/BUILD.bazel b/packages/core/test/signals/BUILD.bazel index 93648675e3a7..d3602b0b55d1 100644 --- a/packages/core/test/signals/BUILD.bazel +++ b/packages/core/test/signals/BUILD.bazel @@ -12,6 +12,7 @@ ts_project( "//packages/core", "//packages/core/primitives/signals", "//packages/core/src/util", + "//packages/private/testing", ], ) @@ -20,6 +21,7 @@ jasmine_test( data = [ ":signals_lib", ], + node_options = ["--expose-gc"], ) ng_web_test_suite( diff --git a/packages/core/test/signals/signal_graph_leak_spec.ts b/packages/core/test/signals/signal_graph_leak_spec.ts new file mode 100644 index 000000000000..0584f5453c30 --- /dev/null +++ b/packages/core/test/signals/signal_graph_leak_spec.ts @@ -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): WeakRef { + 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[] = []; + 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(); + } + }); +});