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
77 changes: 77 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,77 @@
/**
* @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 {ReactiveNode, SIGNAL} from '../../primitives/signals';
import {isBrowser, timeout} from '@angular/private/testing';

import {flushEffects, resetEffects, testingEffect} from './effect_util';

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

afterEach(() => {
resetEffects();
});

function setupAndReturnRef(source: WritableSignal<number>): WeakRef<object> {
const destroyEffect = testingEffect(() => source());
flushEffects();

const sourceNode = source[SIGNAL] as ReactiveNode;
let watchNode;
let link = (sourceNode as any).consumers;
while (link !== undefined) {
watchNode = link.consumer;
link = link.nextConsumer;
}
Comment on lines +31 to +35

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the spirit of making this depend less on the datastructure of links, I wonder if it would make sense to use createWatch directly so we can avoid doing source[SIGNAL]?

(I'm a fan of this new GC direction, I think it's cleaner than before and this behavior of V8 should be reasonably dependable)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is my proposed change: 2257e64

const ref = new WeakRef(watchNode!);

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

// Clearing the graph edges.
destroyEffect();

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