mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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.
This commit is contained in:
committed by
Leon Senft
parent
168a324cce
commit
d45bd2f53d
@@ -61,6 +61,9 @@ export function createWatch(fn: (onCleanup: WatchCleanupRegisterFn) => void, sch
|
||||
// @public
|
||||
export function defaultEquals<T>(a: T, b: T): boolean;
|
||||
|
||||
// @public
|
||||
export const ERRORED: any;
|
||||
|
||||
// @public
|
||||
export function finalizeConsumerAfterComputation(node: ReactiveNode): void;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface DebugSignalGraphNode {
|
||||
label?: string;
|
||||
value?: unknown;
|
||||
debuggableFn?: () => unknown;
|
||||
watched?: boolean;
|
||||
}
|
||||
|
||||
export interface DebugSignalGraphEdge {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import {installDevToolsSignalFormatter} from './src/formatter';
|
||||
|
||||
export {ComputedNode, createComputed} from './src/computed';
|
||||
export {ComputedNode, createComputed, ERRORED} from './src/computed';
|
||||
export {
|
||||
ComputationFn,
|
||||
LinkedSignalNode,
|
||||
|
||||
@@ -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<K extends keyof ExternalCoreGlobalUtils>(
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,18 @@ import type {
|
||||
} from '../../../primitives/devtools';
|
||||
import {
|
||||
ComputedNode,
|
||||
ERRORED,
|
||||
LinkedSignalNode,
|
||||
REACTIVE_NODE,
|
||||
ReactiveNode,
|
||||
SIGNAL,
|
||||
SignalNode,
|
||||
consumerAfterComputation,
|
||||
consumerBeforeComputation,
|
||||
consumerDestroy,
|
||||
consumerPollProducersForChange,
|
||||
producerAccessed,
|
||||
producerUpdateValueVersion,
|
||||
} from '../../../primitives/signals';
|
||||
import {Injector} from '../../di/injector';
|
||||
import {R3Injector} from '../../di/r3_injector';
|
||||
@@ -70,9 +78,46 @@ function getTemplateConsumer(injector: NodeInjector): ReactiveLViewConsumer | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a `ReactiveNode` to its generated unique string ID for DevTools.
|
||||
*/
|
||||
const signalDebugMap = new WeakMap<ReactiveNode, string>();
|
||||
|
||||
interface DebugWatchNode extends ReactiveNode {
|
||||
targetNode: WeakRef<ReactiveNode>;
|
||||
destroyed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores signal debug metadata by string ID, holding a `WeakRef` to the `ReactiveNode`
|
||||
* and a `WeakRef` to any active `DebugWatchNode` so signals and watchers can form isolated
|
||||
* cycles that are eligible for garbage collection when app references are dropped.
|
||||
*/
|
||||
const signalDebugNodeMap = new Map<
|
||||
string,
|
||||
{
|
||||
node: WeakRef<SignalNode<unknown> | ComputedNode<unknown> | LinkedSignalNode<unknown, unknown>>;
|
||||
watch?: WeakRef<DebugWatchNode>;
|
||||
}
|
||||
>();
|
||||
|
||||
/**
|
||||
* Finalization registry that destroys and cleans up a `DebugWatchNode` automatically if the target
|
||||
* `ReactiveNode` is garbage-collected while being watched.
|
||||
*/
|
||||
const watchCleanupRegistry = new FinalizationRegistry<{id: string}>(({id}) => {
|
||||
unwatchSignal(id);
|
||||
signalDebugNodeMap.delete(id);
|
||||
});
|
||||
let counter = 0;
|
||||
|
||||
function isWatched(id: string): boolean {
|
||||
const watchRef = signalDebugNodeMap.get(id)?.watch;
|
||||
if (!watchRef) return false;
|
||||
const watchNode = watchRef.deref();
|
||||
return watchNode !== undefined && !watchNode.destroyed;
|
||||
}
|
||||
|
||||
function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, ReactiveNode[]>): {
|
||||
nodes: DebugSignalGraphNode[];
|
||||
edges: DebugSignalGraphEdge[];
|
||||
@@ -91,31 +136,31 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, Reac
|
||||
signalDebugMap.set(consumer, id);
|
||||
}
|
||||
|
||||
// collect node
|
||||
if (isComputedNode(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,
|
||||
id,
|
||||
});
|
||||
} else if (isLinkedSignalNode(consumer)) {
|
||||
debugSignalGraphNodes.push({
|
||||
label: consumer.debugName,
|
||||
value: consumer.value,
|
||||
kind: consumer.kind,
|
||||
epoch: consumer.version,
|
||||
debuggableFn: consumer.computation as () => unknown,
|
||||
watched: isWatched(id),
|
||||
id,
|
||||
});
|
||||
} else if (isSignalNode(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,
|
||||
watched: isWatched(id),
|
||||
id,
|
||||
});
|
||||
} else if (isTemplateEffectNode(consumer)) {
|
||||
@@ -126,6 +171,21 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, Reac
|
||||
// The `lView[CONTEXT]` is a reference to an instance of the component's class.
|
||||
// We get the constructor so that `inspect(.constructor)` shows the component class.
|
||||
debuggableFn: consumer.lView?.[CONTEXT]?.constructor as (() => 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: isWatched(id),
|
||||
id,
|
||||
});
|
||||
} else if (isEffectNode(consumer)) {
|
||||
@@ -149,6 +209,7 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, Reac
|
||||
label: consumer.debugName,
|
||||
kind: consumer.kind,
|
||||
epoch: consumer.version,
|
||||
watched: false,
|
||||
id,
|
||||
});
|
||||
}
|
||||
@@ -234,3 +295,118 @@ export function getSignalGraph(injector: Injector): DebugSignalGraph {
|
||||
|
||||
return getNodesAndEdgesFromSignalMap(signalDependenciesMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles debug watching for a signal node by its ID.
|
||||
*
|
||||
* - If the signal is currently watched, disposes the watcher.
|
||||
* - If not watched, creates a reactive `DebugWatchNode` that logs debugging information
|
||||
* whenever the signal updates or is invalidated.
|
||||
*
|
||||
* Uses `WeakRef` and `FinalizationRegistry` so watching a signal does not prevent it
|
||||
* (or its enclosing context) from being garbage collected.
|
||||
*
|
||||
* @param id The unique string ID of the signal node to watch or unwatch.
|
||||
*/
|
||||
export function toggleWatchSignal(id: string): void {
|
||||
const entry = signalDebugNodeMap.get(id);
|
||||
if (!entry) {
|
||||
console.warn(
|
||||
`Could not find signal with ID "${id}". The ID may be wrong, or it could have been garbage collected.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If already watching this signal, dispose the watcher and stop watching.
|
||||
if (entry.watch) {
|
||||
const activeWatch = entry.watch.deref();
|
||||
if (activeWatch && !activeWatch.destroyed) {
|
||||
unwatchSignal(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve the target ReactiveNode from weak reference mapping.
|
||||
const node = entry.node.deref();
|
||||
if (!node) {
|
||||
unwatchSignal(id);
|
||||
signalDebugNodeMap.delete(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const watchNode = createDebugWatchNode(node);
|
||||
entry.watch = new WeakRef(watchNode);
|
||||
runDebugWatch(watchNode);
|
||||
}
|
||||
|
||||
function createDebugWatchNode(targetNode: ReactiveNode): DebugWatchNode {
|
||||
const node: DebugWatchNode = {
|
||||
...REACTIVE_NODE,
|
||||
consumerIsAlwaysLive: true,
|
||||
consumerAllowSignalWrites: true,
|
||||
dirty: true,
|
||||
kind: 'effect',
|
||||
targetNode: new WeakRef(targetNode),
|
||||
destroyed: false,
|
||||
consumerMarkedDirty: () => {
|
||||
if (node.destroyed) return;
|
||||
// Schedule watch re-execution asynchronously in the next microtask after
|
||||
// the signal update cycle completes, ceding the main thread back to the
|
||||
// framework and avoiding synchronous re-entrancy during value computation.
|
||||
queueMicrotask(() => runDebugWatch(node));
|
||||
},
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
function runDebugWatch(node: DebugWatchNode): void {
|
||||
if (node.destroyed) return;
|
||||
|
||||
const targetNode = node.targetNode.deref();
|
||||
if (!targetNode) {
|
||||
node.destroyed = true;
|
||||
consumerDestroy(node);
|
||||
return;
|
||||
}
|
||||
|
||||
node.dirty = false;
|
||||
if (node.version > 0 && !consumerPollProducersForChange(node)) {
|
||||
return;
|
||||
}
|
||||
node.version++;
|
||||
|
||||
const prevConsumer = consumerBeforeComputation(node);
|
||||
try {
|
||||
producerUpdateValueVersion(targetNode);
|
||||
producerAccessed(targetNode);
|
||||
const name = targetNode.debugName ? targetNode.debugName : 'DevTools signal watch';
|
||||
if (
|
||||
(isComputedNode(targetNode) || isLinkedSignalNode(targetNode)) &&
|
||||
targetNode.value === ERRORED
|
||||
) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.error(`[${name} (error)]:`, targetNode.error);
|
||||
return;
|
||||
}
|
||||
const value =
|
||||
isSignalNode(targetNode) || isComputedNode(targetNode) || isLinkedSignalNode(targetNode)
|
||||
? targetNode.value
|
||||
: undefined;
|
||||
// tslint:disable-next-line:no-console
|
||||
console.log(`[${name}]:`, value);
|
||||
} finally {
|
||||
consumerAfterComputation(node, prevConsumer);
|
||||
}
|
||||
}
|
||||
|
||||
function unwatchSignal(id: string) {
|
||||
const entry = signalDebugNodeMap.get(id);
|
||||
if (entry?.watch) {
|
||||
const watchNode = entry.watch.deref();
|
||||
if (watchNode && !watchNode.destroyed) {
|
||||
watchNode.destroyed = true;
|
||||
consumerDestroy(watchNode);
|
||||
}
|
||||
entry.watch = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,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 {TestBed} from '../../testing';
|
||||
|
||||
describe('getSignalGraph', () => {
|
||||
@@ -574,3 +574,184 @@ describe('getSignalGraph', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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', async () => {
|
||||
@Component({selector: 'component-with-watched-signal', template: `{{ mySignal() }}`})
|
||||
class WithWatchedSignal {
|
||||
mySignal = signal(100, {debugName: 'mySignal'});
|
||||
}
|
||||
TestBed.configureTestingModule({imports: [WithWatchedSignal]});
|
||||
const fixture = TestBed.createComponent(WithWatchedSignal);
|
||||
|
||||
await fixture.whenStable();
|
||||
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);
|
||||
await fixture.whenStable();
|
||||
|
||||
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);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dispose the watch when toggled off', async () => {
|
||||
@Component({selector: 'component-with-disposed-watch', template: `{{ mySignal() }}`})
|
||||
class App {
|
||||
mySignal = signal('initial');
|
||||
}
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
|
||||
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');
|
||||
await fixture.whenStable();
|
||||
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');
|
||||
await fixture.whenStable();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dispose watch and clean up tracking maps if node is dereferenced as undefined', async () => {
|
||||
@Component({selector: 'component-for-deref-test', template: `{{ mySignal() }}`})
|
||||
class App {
|
||||
mySignal = signal('hello');
|
||||
}
|
||||
TestBed.configureTestingModule({imports: [App]});
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
|
||||
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');
|
||||
await fixture.whenStable();
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
it('should log the caught error when watching an errored computed signal', async () => {
|
||||
const computationError = new Error('Computation failed');
|
||||
@Component({
|
||||
selector: 'component-with-errored-computed',
|
||||
template: `{{ display() }}`,
|
||||
})
|
||||
class WithErroredComputed {
|
||||
source = signal(false);
|
||||
computedVal = computed(
|
||||
() => {
|
||||
if (this.source()) {
|
||||
throw computationError;
|
||||
}
|
||||
return 42;
|
||||
},
|
||||
{debugName: 'computedVal'},
|
||||
);
|
||||
display = computed(() => {
|
||||
try {
|
||||
return this.computedVal();
|
||||
} catch {
|
||||
return 'errored';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({imports: [WithErroredComputed]});
|
||||
const fixture = TestBed.createComponent(WithErroredComputed);
|
||||
await fixture.whenStable();
|
||||
|
||||
const {nodes} = getSignalGraph(fixture.componentRef.injector);
|
||||
const computedNode = nodes.find((n) => n.label === 'computedVal')!;
|
||||
expect(computedNode).toBeDefined();
|
||||
|
||||
const spyLog = spyOn(console, 'log');
|
||||
const spyError = spyOn(console, 'error');
|
||||
|
||||
// Start watching when valid
|
||||
toggleWatchSignal(computedNode.id);
|
||||
expect(spyLog).toHaveBeenCalledWith('[computedVal]:', 42);
|
||||
expect(spyError).not.toHaveBeenCalled();
|
||||
spyLog.calls.reset();
|
||||
|
||||
// Trigger update that causes computation to throw
|
||||
fixture.componentInstance.source.set(true);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(spyError).toHaveBeenCalledWith('[computedVal (error)]:', computationError);
|
||||
expect(spyLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user