From 716fb386b373cd1a513edeb1e5275501f308b97e Mon Sep 17 00:00:00 2001 From: Cameron Smick Date: Mon, 20 Jul 2026 13:30:22 -0700 Subject: [PATCH 1/2] refactor(core): implement toggleWatchSignal for DevTools signal debugging Implement \`toggleWatchSignal(id)\` in \`signal_debug.ts\` to enable toggling reactive watch listeners on individual signal graph nodes. - Create reactive \`Watch\` instances using \`createWatch\` to log signal value/state changes to the console when active. - Use \`WeakRef\` mapping and \`FinalizationRegistry\` (\`watchCleanupRegistry\`) so active watches do not retain strong references to signal nodes or prevent garbage collection. - Expose \`watched\` status on \`DebugSignalGraphNode\` and publish \`toggleWatchSignal\` onto \`window.ng\` in development mode. - Add acceptance tests covering watch activation, signal mutation logging, manual disposal, GC cleanup, and safe invalid ID handling. --- .../devtools/src/debug_signal_graph.ts | 1 + .../core/src/render3/util/global_utils.ts | 23 ++- .../core/src/render3/util/signal_debug.ts | 141 +++++++++++++++++- .../core/test/acceptance/signal_debug_spec.ts | 136 ++++++++++++++++- 4 files changed, 290 insertions(+), 11 deletions(-) diff --git a/packages/core/primitives/devtools/src/debug_signal_graph.ts b/packages/core/primitives/devtools/src/debug_signal_graph.ts index 48a5a8856e93..3411b34ccab6 100644 --- a/packages/core/primitives/devtools/src/debug_signal_graph.ts +++ b/packages/core/primitives/devtools/src/debug_signal_graph.ts @@ -15,6 +15,7 @@ export interface DebugSignalGraphNode { label?: string; value?: unknown; debuggableFn?: () => unknown; + watched?: boolean; } export interface DebugSignalGraphEdge { diff --git a/packages/core/src/render3/util/global_utils.ts b/packages/core/src/render3/util/global_utils.ts index e9b3e1fdf5bb..61d6f3bdc627 100644 --- a/packages/core/src/render3/util/global_utils.ts +++ b/packages/core/src/render3/util/global_utils.ts @@ -17,8 +17,6 @@ import {Signal, isSignal} from '../reactivity/api'; import {applyChanges} from './change_detection_utils'; import {getControlFlowBlocks} from './control_flow'; import { - AngularComponentDebugMetadata, - AngularDirectiveDebugMetadata, DirectiveDebugMetadata, Listener, getComponent, @@ -37,7 +35,7 @@ import { getInjectorProviders, getInjectorResolutionPath, } from './injector_discovery_utils'; -import {getSignalGraph} from './signal_debug'; +import {getSignalGraph, toggleWatchSignal} from './signal_debug'; import { enableProfiling, @@ -94,7 +92,9 @@ interface NonCoreGlobalUtils { * Angular. This allows fast iteration on new global utils and only applies Angular's long-lived * versioning constraint when we are ready to accept it. */ -interface InternalCoreGlobalUtils {} +interface InternalCoreGlobalUtils { + toggleWatchSignal(id: string): void; +} /** * The set of external (meaning outside google3) global utils implemented by `@angular/core`. @@ -135,6 +135,10 @@ export interface ExternalCoreGlobalUtils { enableProfiling(): void; } +const internalCoreGlobalUtils: InternalCoreGlobalUtils = { + toggleWatchSignal, +}; + const externalCoreGlobalUtils: ExternalCoreGlobalUtils = { /** * Warning: functions that start with `ɵ` are considered *INTERNAL* and should not be relied upon @@ -185,6 +189,10 @@ export function publishDefaultGlobalUtils() { for (const [methodName, method] of Object.entries(externalCoreGlobalUtils)) { publishGlobalUtil(methodName as keyof ExternalCoreGlobalUtils, method); } + + for (const [methodName, method] of Object.entries(internalCoreGlobalUtils)) { + publishGlobalUtil(methodName as keyof InternalCoreGlobalUtils, method); + } } } @@ -192,10 +200,9 @@ export function publishDefaultGlobalUtils() { * Publishes the given function to `window.ng` so that it can be * used from the browser console when an application is not in production. */ -export function publishGlobalUtil( - name: K, - fn: ExternalCoreGlobalUtils[K], -): void { +export function publishGlobalUtil< + K extends keyof InternalCoreGlobalUtils | keyof ExternalCoreGlobalUtils, +>(name: K, fn: (InternalCoreGlobalUtils & ExternalCoreGlobalUtils)[K]): void { publishUtil(name, fn); } diff --git a/packages/core/src/render3/util/signal_debug.ts b/packages/core/src/render3/util/signal_debug.ts index ffab39a19402..7090cbbfdd38 100644 --- a/packages/core/src/render3/util/signal_debug.ts +++ b/packages/core/src/render3/util/signal_debug.ts @@ -14,7 +14,17 @@ import {EffectNode, EffectRefImpl} from '../reactivity/effect'; import {Injector} from '../../di/injector'; import {R3Injector} from '../../di/r3_injector'; import {throwError} from '../../util/assert'; -import {ComputedNode, ReactiveNode, SIGNAL, SignalNode} from '../../../primitives/signals'; +import { + ComputedNode, + LinkedSignalNode, + ReactiveNode, + SIGNAL, + SignalNode, + Watch, + createWatch, + producerAccessed, + producerUpdateValueVersion, +} from '../../../primitives/signals'; import {isLView} from '../interfaces/type_checks'; import type { DebugSignalGraph, @@ -38,6 +48,10 @@ function isSignalNode(node: ReactiveNode): node is SignalNode { return node.kind === 'signal'; } +function isLinkedSignalNode(node: ReactiveNode): node is LinkedSignalNode { + return node.kind === 'linkedSignal'; +} + /** * * @param injector @@ -55,7 +69,34 @@ function getTemplateConsumer(injector: NodeInjector): ReactiveLViewConsumer | nu return null; } +/** + * Maps a `ReactiveNode` to its generated unique string ID for DevTools. + */ const signalDebugMap = new WeakMap(); + +/** + * Stores signal debug metadata by string ID, holding a `WeakRef` to the `ReactiveNode` + * (to allow lookup without preventing garbage collection) and an optional active `Watch`. + */ +const signalDebugNodeMap = new Map< + string, + { + node: WeakRef | ComputedNode | LinkedSignalNode>; + watch?: Watch; + } +>(); + +/** + * Finalization registry that destroys and cleans up a `Watch` automatically if the target + * `ReactiveNode` is garbage-collected while being watched. + */ +const watchCleanupRegistry = new FinalizationRegistry<{id: string}>(({id}) => { + const entry = signalDebugNodeMap.get(id); + if (entry?.watch) { + entry.watch.destroy(); + } + signalDebugNodeMap.delete(id); +}); let counter = 0; function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap): { @@ -76,22 +117,31 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap unknown) | undefined, + watched: false, + id, + }); + } else if (isLinkedSignalNode(consumer)) { + if (!signalDebugNodeMap.has(id)) { + signalDebugNodeMap.set(id, {node: new WeakRef(consumer)}); + watchCleanupRegistry.register(consumer, {id}); + } + debugSignalGraphNodes.push({ + label: consumer.debugName, + value: consumer.value, + kind: consumer.kind, + epoch: consumer.version, + debuggableFn: consumer.computation as (() => unknown) | undefined, + watched: signalDebugNodeMap.get(id)?.watch !== undefined, id, }); } else { @@ -109,6 +174,7 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap { + const targetNode = weakNode.deref(); + if (!targetNode) { + // Target signal node was garbage collected; clean up watch state. + unwatchSignal(id); + signalDebugNodeMap.delete(id); + return; + } + producerUpdateValueVersion(targetNode); + producerAccessed(targetNode); + const name = targetNode.debugName ? targetNode.debugName : 'DevTools signal watch'; + // tslint:disable-next-line:no-console + console.log(`[${name}]`, ': ', targetNode.value); + }, + (watch) => { + queueMicrotask(() => watch.run()); + }, + false, + ); + + entry.watch = watch; + watch.run(); +} + +function unwatchSignal(id: string) { + const entry = signalDebugNodeMap.get(id); + if (entry?.watch) { + entry.watch.destroy(); + entry.watch = undefined; + } +} diff --git a/packages/core/test/acceptance/signal_debug_spec.ts b/packages/core/test/acceptance/signal_debug_spec.ts index b310cf0365fe..c88553bfc2ce 100644 --- a/packages/core/test/acceptance/signal_debug_spec.ts +++ b/packages/core/test/acceptance/signal_debug_spec.ts @@ -26,7 +26,7 @@ import { } from '../../src/render3/debug/framework_injector_profiler'; import {setInjectorProfiler} from '../../src/render3/debug/injector_profiler'; import type {DebugSignalGraphEdge, DebugSignalGraphNode} from '../../primitives/devtools'; -import {getSignalGraph} from '../../src/render3/util/signal_debug'; +import {getSignalGraph, toggleWatchSignal} from '../../src/render3/util/signal_debug'; import {fakeAsync, TestBed, tick} from '../../testing'; describe('getSignalGraph', () => { @@ -479,3 +479,137 @@ describe('getSignalGraph', () => { expect(getFrameworkDIDebugData().resolverToEffects.get(injector)?.length).toBe(0); }); }); + +describe('toggleWatchSignal', () => { + beforeEach(() => { + setInjectorProfiler(null); + setupFrameworkInjectorProfiler(); + }); + + afterEach(() => { + getFrameworkDIDebugData().reset(); + setInjectorProfiler(null); + TestBed.resetTestingModule(); + }); + + it('should toggle watching a signal, printing debugging information when active, and stopping when disposed', fakeAsync(() => { + @Component({selector: 'component-with-watched-signal', template: `{{ mySignal() }}`}) + class WithWatchedSignal { + mySignal = signal(100, {debugName: 'mySignal'}); + } + TestBed.configureTestingModule({imports: [WithWatchedSignal]}); + const fixture = TestBed.createComponent(WithWatchedSignal); + + tick(); + fixture.detectChanges(); + const injector = fixture.componentRef.injector; + + const initialGraph = getSignalGraph(injector); + const signalNode = initialGraph.nodes.find((node) => node.label === 'mySignal')!; + expect(signalNode).toBeDefined(); + expect(signalNode.watched).toBe(false); + + const spy = spyOn(console, 'log'); + + toggleWatchSignal(signalNode.id); + + expect(spy).toHaveBeenCalledWith('[mySignal]', ': ', 100); + spy.calls.reset(); + + const activeGraph = getSignalGraph(injector); + const activeSignalNode = activeGraph.nodes.find((node) => node.label === 'mySignal')!; + expect(activeSignalNode.watched).toBe(true); + + fixture.componentInstance.mySignal.set(200); + tick(); + + expect(spy).toHaveBeenCalledWith('[mySignal]', ': ', 200); + spy.calls.reset(); + + toggleWatchSignal(signalNode.id); + + const disposedGraph = getSignalGraph(injector); + const disposedSignalNode = disposedGraph.nodes.find((node) => node.label === 'mySignal')!; + expect(disposedSignalNode.watched).toBe(false); + + fixture.componentInstance.mySignal.set(300); + tick(); + + expect(spy).not.toHaveBeenCalled(); + })); + + it('should dispose the watch when toggled off', fakeAsync(() => { + @Component({selector: 'component-with-disposed-watch', template: `{{ mySignal() }}`}) + class App { + mySignal = signal('initial'); + } + TestBed.configureTestingModule({imports: [App]}); + const fixture = TestBed.createComponent(App); + tick(); + fixture.detectChanges(); + + const signalGraph = getSignalGraph(fixture.componentRef.injector); + const signalNode = signalGraph.nodes.find((node) => node.kind === 'signal')!; + + const spy = spyOn(console, 'log'); + + // Start watching (triggers initial log execution) + toggleWatchSignal(signalNode.id); + expect(spy).toHaveBeenCalledTimes(1); + spy.calls.reset(); + + // Signal update should trigger log execution while watched + fixture.componentInstance.mySignal.set('watched update'); + tick(); + expect(spy).toHaveBeenCalledTimes(1); + spy.calls.reset(); + + // Stop watching (disposes watch) + toggleWatchSignal(signalNode.id); + + // Further signal updates should not trigger logging + fixture.componentInstance.mySignal.set('unwatched update'); + tick(); + expect(spy).not.toHaveBeenCalled(); + })); + + it('should dispose watch and clean up tracking maps if node is dereferenced as undefined', fakeAsync(() => { + @Component({selector: 'component-for-deref-test', template: `{{ mySignal() }}`}) + class App { + mySignal = signal('hello'); + } + TestBed.configureTestingModule({imports: [App]}); + const fixture = TestBed.createComponent(App); + tick(); + fixture.detectChanges(); + + const signalGraph = getSignalGraph(fixture.componentRef.injector); + const signalNode = signalGraph.nodes.find((node) => node.kind === 'signal')!; + + // Start watching + toggleWatchSignal(signalNode.id); + + // Simulate garbage collection across WeakRef instances + spyOn(WeakRef.prototype, 'deref').and.returnValue(undefined); + + const spy = spyOn(console, 'log'); + + // Triggering signal update causes watch callback to run, detecting node is gone + fixture.componentInstance.mySignal.set('world'); + tick(); + + // Watch should destroy itself and avoid logging + expect(spy).not.toHaveBeenCalled(); + + // Calling toggleWatchSignal on the dead node ID should run safely without errors + expect(() => toggleWatchSignal(signalNode.id)).not.toThrow(); + })); + + it('should handle non-existent node IDs safely', () => { + const spyLog = spyOn(console, 'log'); + const spyWarn = spyOn(console, 'warn'); + expect(() => toggleWatchSignal('non-existent-id-99999')).not.toThrow(); + expect(spyLog).not.toHaveBeenCalled(); + expect(spyWarn).toHaveBeenCalledTimes(1); + }); +}); From a4792db52993ebf4d0779dec09f4738d763199f8 Mon Sep 17 00:00:00 2001 From: Cameron Smick Date: Mon, 20 Jul 2026 13:31:45 -0700 Subject: [PATCH 2/2] feat(devtools): add "Watch signal" button to signal-details component Add a "Watch signal" button to the signal-details component to allow users to "watch" changes to the associated signal. --- .../src/lib/client-event-subscribers.ts | 8 ++++ .../signal-details.component.html | 14 ++++++ .../signal-details.component.ts | 20 +++++++- .../devtools-signal-graph.spec.ts | 46 +++++++++++++++++++ .../src/lib/shared/signal-graph/utils.spec.ts | 1 + .../projects/protocol/src/lib/messages.ts | 3 ++ 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts b/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts index 2b25a74ee9ba..856055c12fb0 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts @@ -131,6 +131,8 @@ export const subscribeToClientEvents = ( messageBus.on('getSignalGraph', getSignalGraphCallback(messageBus)); + messageBus.on('toggleWatchSignal', toggleWatchSignal(messageBus)); + if (appIsAngularInDevMode() && appIsSupportedAngularVersion() && appIsAngularIvy()) { inspector.ref = setupInspector(messageBus); @@ -660,12 +662,18 @@ const getSignalGraphCallback = (messageBus: MessageBus) => (element: Ele epoch: node.epoch, preview: serializeValue(node.value), debuggable: !!node.debuggableFn, + watched: node.watched ?? false, }; }); messageBus.emit('latestSignalGraph', [{nodes, edges: graph.edges}]); } }; +const toggleWatchSignal = (messageBus: MessageBus) => (id: string) => { + const ng = ngDebugClient(); + ng.toggleWatchSignal?.(id); +}; + // Route data needs to be serializable to be sent over the message bus. export function sanitizeRouteData(route: Route): Route { if (route.data) { diff --git a/devtools/projects/ng-devtools/src/lib/shared/signal-details/signal-details.component.html b/devtools/projects/ng-devtools/src/lib/shared/signal-details/signal-details.component.html index fb6e55b44e7e..94fef4fca39a 100644 --- a/devtools/projects/ng-devtools/src/lib/shared/signal-details/signal-details.component.html +++ b/devtools/projects/ng-devtools/src/lib/shared/signal-details/signal-details.component.html @@ -1,6 +1,7 @@ @let node = this.node(); @let isSignalNode = this.isSignalNode(node); @let isClusterNode = this.isClusterNode(node); +@let isWatchable = this.isWatchable(node);
@@ -48,6 +49,19 @@ > + @if (isWatchable) { + + }