Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ const getSignalGraphCallback = (messageBus: MessageBus<Events>) => (element: Ele
epoch: node.epoch,
preview: serializeValue(node.value),
debuggable: !!node.debuggableFn,
isInternal: node.isInternal ?? false,
};
});
messageBus.emit('latestSignalGraph', [{nodes, edges: graph.edges}]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,39 @@ export class SignalsTabComponent implements OnDestroy {
private svgComponent = viewChild.required<ElementRef>('component');

signalsVisualizer?: SignalsGraphVisualizer;
protected readonly showInternalSignals = signal(false);
protected readonly visibleGraph = computed(() => {
const graph = this.signalGraph.graph();
if (!graph) {
return null;
}
if (this.showInternalSignals()) {
return graph;
}
const nodesWithIndexes = graph.nodes.map((node, index) => ({node, index}));
const filteredNodes = nodesWithIndexes.filter(({node}) => !node.isInternal);
if (filteredNodes.length === graph.nodes.length) {
return graph;
}
const indexMap = new Map<number, number>();
const nodes = filteredNodes.map(({node, index}, mappedIndex) => {
indexMap.set(index, mappedIndex);
return node;
});
const edges = graph.edges
.filter((edge) => indexMap.has(edge.consumer) && indexMap.has(edge.producer))
.map((edge) => ({
consumer: indexMap.get(edge.consumer)!,
producer: indexMap.get(edge.producer)!,
}));
return {nodes, edges};
});

protected readonly preselectedNodeId = input<string | null>(null);

// selected is automatically reset to null whenever `graph` changes
private selected = linkedSignal<DebugSignalGraph | null, string | null>({
source: this.signalGraph.graph,
source: this.visibleGraph,
computation: () => this.preselectedNodeId(),
});

Expand All @@ -73,7 +100,7 @@ export class SignalsTabComponent implements OnDestroy {
readonly close = output<void>();

protected selectedNode = computed(() => {
const signalGraph = this.signalGraph.graph();
const signalGraph = this.visibleGraph();
if (!signalGraph) {
return undefined;
}
Expand Down Expand Up @@ -123,11 +150,13 @@ export class SignalsTabComponent implements OnDestroy {
);
});

protected empty = computed(() => !(this.signalGraph.graph()?.nodes.length! > 0));

protected empty = computed(() => {
const graph = this.visibleGraph();
return !(graph && graph.nodes.length > 0);
});
constructor() {
const renderGraph = () => {
const graph = this.signalGraph.graph();
const graph = this.visibleGraph();
if (graph) {
this.signalsVisualizer?.render(graph);
}
Expand Down
1 change: 1 addition & 0 deletions devtools/projects/protocol/src/lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface DebugSignalGraphNode {
label?: string;
preview: Descriptor;
debuggable: boolean;
isInternal: boolean;
}

export interface DebugSignalGraphEdge {
Expand Down
3 changes: 1 addition & 2 deletions devtools/projects/shell-browser/src/app/content-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ const handleDisconnect = (): void => {

function attemptBackendHandshake() {
if (!backendInitialized) {
// tslint:disable-next-line:no-console
console.log('Attempting handshake with backend', new Date());
console.debug('Attempting handshake with backend', new Date());

const retry = () => {
if (backendInitialized || backgroundDisconnected) {
Expand Down
1 change: 1 addition & 0 deletions devtools/src/app/demo-app/todo/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ng_project(
"//:node_modules/@angular/material",
"//:node_modules/@angular/router",
"//devtools/src/app/demo-app/todo/about",
"//devtools/src/app/demo-app/todo/forms",
"//devtools/src/app/demo-app/todo/home",
"//devtools/src/app/demo-app/todo/routes",
],
Expand Down
1 change: 1 addition & 0 deletions devtools/src/app/demo-app/todo/app-todo.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<a routerLink="/demo-app/todos/app">Todos</a>
<a routerLink="/demo-app/todos/about">About</a>
<a routerLink="/demo-app/todos/routes">Routes</a>
<a routerLink="/demo-app/todos/forms">Forms</a>
</nav>

<div class="guard-toggle-container">
Expand Down
4 changes: 4 additions & 0 deletions devtools/src/app/demo-app/todo/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export const canMatchGuard: CanActivateFn = () => {
path: 'routes',
loadChildren: () => import('./routes/routes.module').then((m) => m.RoutesModule),
},
{
path: 'forms',
loadComponent: () => import('./forms/forms.component').then((m) => m.FormsComponent),
},
{
path: '**',
redirectTo: 'app',
Expand Down
14 changes: 14 additions & 0 deletions devtools/src/app/demo-app/todo/forms/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
load("//devtools/tools:defaults.bzl", "ng_project")

package(default_visibility = ["//visibility:public"])

ng_project(
name = "forms",
srcs = [
"forms.component.ts",
],
deps = [
"//:node_modules/@angular/core",
"//:node_modules/@angular/forms",
],
)
36 changes: 36 additions & 0 deletions devtools/src/app/demo-app/todo/forms/forms.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @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 {Component} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';

@Component({
selector: 'app-forms',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="profileForm">
<label>
First Name:
<input formControlName="firstName" />
</label>

<label>
Last Name:
<input formControlName="lastName" />
</label>

<button type="submit">Submit</button>
</form>
`,
})
export class FormsComponent {
profileForm = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl(''),
});
}
7 changes: 7 additions & 0 deletions packages/core/primitives/signals/src/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ export interface ReactiveNode {
* Used in Angular DevTools to identify the kind of signal.
*/
kind: ReactiveNodeKind;

/**
* @internal
* A debug information, only present in dev mode.
* Internal reactive nodes should not be visible by default when debugging the signal graph
*/
isInternal?: boolean;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/render3/reactivity/computed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface CreateComputedOptions<T> {
* A debug name for the computed signal. Used in Angular DevTools to identify the signal.
*/
debugName?: string;

/**
* @internal
*/
isInternal?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider: Would ɵisInternal improve typing without expanding our public API contract?

Copy link
Member Author

Choose a reason for hiding this comment

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

@internal actually effectively removes the prop from the public API. (it's not present in the generated d.ts.

}

/**
Expand All @@ -35,6 +40,7 @@ export function computed<T>(computation: () => T, options?: CreateComputedOption
if (ngDevMode) {
getter.toString = () => `[Computed: ${getter()}]`;
getter[SIGNAL].debugName = options?.debugName;
(getter[SIGNAL] as any).isInternal = options?.isInternal ?? false;
}

return getter;
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/render3/reactivity/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ export interface CreateSignalOptions<T> {
* A debug name for the signal. Used in Angular DevTools to identify the signal.
*/
debugName?: string;

/**
* @internal
*/
isInternal?: boolean;
}

/**
Expand All @@ -82,6 +87,7 @@ export function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): Wr
if (ngDevMode) {
signalFn.toString = () => `[Signal: ${signalFn()}]`;
node.debugName = options?.debugName;
(node as any).isInternal = options?.isInternal ?? false;
}

return signalFn as WritableSignal<T>;
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/render3/util/signal_debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface DebugSignalGraphNode {
label?: string;
value?: unknown;
debuggableFn?: () => unknown;

isInternal?: boolean;
}

export interface DebugSignalGraphEdge {
Expand Down Expand Up @@ -115,6 +117,7 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, Reac
epoch: consumer.version,
debuggableFn: consumer.computation,
id,
isInternal: (consumer as any).isInternal ?? false,
});
} else if (isSignalNode(consumer)) {
debugSignalGraphNodes.push({
Expand All @@ -123,6 +126,7 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, Reac
kind: consumer.kind,
epoch: consumer.version,
id,
isInternal: (consumer as any).isInternal ?? false,
});
} else if (isTemplateEffectNode(consumer)) {
debugSignalGraphNodes.push({
Expand All @@ -133,13 +137,15 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, Reac
// We get the constructor so that `inspect(.constructor)` shows the component class.
debuggableFn: consumer.lView?.[CONTEXT]?.constructor as (() => unknown) | undefined,
id,
isInternal: (consumer as any).isInternal ?? false,
});
} else {
debugSignalGraphNodes.push({
label: consumer.debugName,
kind: consumer.kind,
epoch: consumer.version,
id,
isInternal: (consumer as any).isInternal ?? false,
});
}

Expand All @@ -148,7 +154,6 @@ function getNodesAndEdgesFromSignalMap(signalMap: ReadonlyMap<ReactiveNode, Reac
edges.push({consumer: consumerIndex, producer: nodes.indexOf(producer)});
}
}

return {nodes: debugSignalGraphNodes, edges};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,14 @@ export abstract class AbstractFormDirective
this._submittedReactive.set(value);
}
/** @internal */
readonly _submitted = computed(() => this._submittedReactive());
private readonly _submittedReactive = signal(false);
readonly _submitted = computed(
() => this._submittedReactive(),
...(ngDevMode ? [{isInternal: true} as any] : []),
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider: It seems easy to forget the ngDevMode bits here (or misuse it like isInternal: ngDevMode ? true : undefined), especially if we're doing this manually at each call site. We also won't have extensive tests that this is a zero-cost addition in production.

IIRC, the debug name transform wraps an explicit name in an ngDevMode conditional (might be worth double checking that). What if we boxed both debugName and isInternal into a shared debugInfo object and tree shake that value once rather than having to do each one individually?

// Definition

interface DebugInfo {
  name?: string;
  isInternal?: boolean;
}

declare function signal<T>(initial: T, opts?: {debugInfo?: DebugInfo}): WritableSignal<T>;

// Dev writes:

const foo = signal(0, {
  debugInfo: {isInternal: true},
});

// Transformed to (using our existing infra, just tweaked for `debugInfo` instead of `debugName`):

const foo = signal(0, {
  ...(ngDevMode ? {
    debugInfo: {
      name: 'foo',
      isInternal: true,
    },
  } : {})
});

This way we don't need to think about tree shaking at each call site, we just annotate which signals are internal.

I know we're just experimenting here for now, so I'm fine to come back to this later. I'm mainly calling out that there might be value in a DebugInfo abstraction which allows us to introduce new debug state in the future without having to reinvent the tree shaking solution we need for production.

);
private readonly _submittedReactive = signal(
false,
...(ngDevMode ? [{isInternal: true} as any] : []),
);

/**
* Reference to an old form group input value, which is needed to cleanup
Expand Down
30 changes: 24 additions & 6 deletions packages/forms/src/model/abstract_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,14 @@ export abstract class AbstractControl<
untracked(() => this.statusReactive.set(v));
}
/** @internal */
readonly _status = computed(() => this.statusReactive());
private readonly statusReactive = signal<FormControlStatus | undefined>(undefined);
readonly _status = computed(
() => this.statusReactive(),
...(ngDevMode ? [{isInternal: true} as any] : []),
);
private readonly statusReactive = signal<FormControlStatus | undefined>(
undefined,
...(ngDevMode ? [{isInternal: true} as any] : []),
);

/**
* A control is `valid` when its `status` is `VALID`.
Expand Down Expand Up @@ -704,8 +710,14 @@ export abstract class AbstractControl<
untracked(() => this.pristineReactive.set(v));
}
/** @internal */
readonly _pristine = computed(() => this.pristineReactive());
private readonly pristineReactive = signal(true);
readonly _pristine = computed(
() => this.pristineReactive(),
...(ngDevMode ? [{isInternal: true} as any] : []),
);
private readonly pristineReactive = signal(
true,
...(ngDevMode ? [{isInternal: true} as any] : []),
);

/**
* A control is `dirty` if the user has changed the value
Expand All @@ -731,8 +743,14 @@ export abstract class AbstractControl<
untracked(() => this.touchedReactive.set(v));
}
/** @internal */
readonly _touched = computed(() => this.touchedReactive());
private readonly touchedReactive = signal(false);
readonly _touched = computed(
() => this.touchedReactive(),
...(ngDevMode ? [{isInternal: true} as any] : []),
);
private readonly touchedReactive = signal(
false,
...(ngDevMode ? [{isInternal: true} as any] : []),
);

/**
* True if the control has not been marked as touched
Expand Down