Skip to content

Commit 8e80083

Browse files
JeanMechekirjs
authored andcommitted
refactor(core): Add custom formatters for Signals (#64000)
This commit adds a devmode only formatter for Angular signals Custom formatters must also be enabled in the browser devtools. PR Close #64000
1 parent c1a922a commit 8e80083

File tree

3 files changed

+177
-0
lines changed

3 files changed

+177
-0
lines changed

goldens/public-api/core/primitives/signals/index.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export function finalizeConsumerAfterComputation(node: ReactiveNode): void;
7070
// @public (undocumented)
7171
export function getActiveConsumer(): ReactiveNode | null;
7272

73+
// @public
74+
export function installDevToolsSignalFormatter(): void;
75+
7376
// @public (undocumented)
7477
export function isInNotificationPhase(): boolean;
7578

packages/core/primitives/signals/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import {installDevToolsSignalFormatter} from './src/formatter';
10+
911
export {ComputedNode, createComputed} from './src/computed';
1012
export {
1113
ComputationFn,
@@ -58,3 +60,14 @@ export {Watch, WatchCleanupFn, WatchCleanupRegisterFn, createWatch} from './src/
5860
export {setAlternateWeakRefImpl} from './src/weak_ref';
5961
export {untracked} from './src/untracked';
6062
export {runEffect, BASE_EFFECT_NODE, BaseEffectNode} from './src/effect';
63+
export {installDevToolsSignalFormatter} from './src/formatter';
64+
65+
// Required as the signals library is in a separate package, so we need to explicitly ensure the
66+
// global `ngDevMode` type is defined.
67+
declare const ngDevMode: boolean | undefined;
68+
69+
// We're using a top-level access to enable signal formatting whenever the signals package is loaded.
70+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
71+
// tslint:disable-next-line: no-toplevel-property-access
72+
installDevToolsSignalFormatter();
73+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {SIGNAL} from './graph';
10+
11+
// Only a subset of HTML tags are allowed in the custom formatter JsonML format.
12+
// See https://firefox-source-docs.mozilla.org/devtools-user/custom_formatters/index.html#html-template-format
13+
type AllowedTags = 'span' | 'div' | 'ol' | 'ul' | 'li' | 'table' | 'tr' | 'td';
14+
15+
type JsonMLText = string;
16+
type JsonMLAttrs = Record<string, string>;
17+
type JsonMLElement =
18+
| [tagName: AllowedTags, ...children: (JsonMLNode | JsonMLChild)[]]
19+
| [tagName: AllowedTags, attrs: JsonMLAttrs, ...children: (JsonMLNode | JsonMLChild)[]];
20+
type JsonMLNode = JsonMLText | JsonMLElement;
21+
type JsonMLChild = ['object', {object: unknown; config?: unknown}];
22+
type JsonML = JsonMLNode;
23+
24+
type FormatterConfig = unknown & {ngSkipFormatting?: boolean};
25+
26+
declare global {
27+
// We need to use `var` here to be able to declare a global variable.
28+
// `let` and `const` will be locally scoped to the file.
29+
// tslint:disable-next-line:no-unused-variable
30+
var devtoolsFormatters: any[];
31+
}
32+
33+
/**
34+
* A custom formatter which renders signals in an easy-to-read format.
35+
*
36+
* @see https://firefox-source-docs.mozilla.org/devtools-user/custom_formatters/index.html
37+
*/
38+
39+
const formatter = {
40+
/**
41+
* If the function returns `null`, the formatter is not used for this reference
42+
*/
43+
header: (sig: any, config: FormatterConfig): JsonML | null => {
44+
if (!isSignal(sig) || config?.ngSkipFormatting) return null;
45+
46+
let value: unknown;
47+
try {
48+
value = sig();
49+
} catch {
50+
// In case the signl throws, we don't want to break the formatting.
51+
return ['span', 'Signal(⚠️ Error)'];
52+
}
53+
54+
const kind = 'computation' in (sig[SIGNAL] as any) ? 'Computed' : 'Signal';
55+
56+
const isPrimitive = value === null || (!Array.isArray(value) && typeof value !== 'object');
57+
58+
return [
59+
'span',
60+
{},
61+
['span', {}, `${kind}(`],
62+
(() => {
63+
if (isSignal(value)) {
64+
// Recursively call formatter. Could return an `object` to call the formatter through DevTools,
65+
// but then recursive signals will render multiple expando arrows which is an awkward UX.
66+
return formatter.header(value, config)!;
67+
} else if (isPrimitive && value !== undefined && typeof value !== 'function') {
68+
// Use built-in rendering for primitives which applies standard syntax highlighting / theming.
69+
// Can't do this for `undefined` however, as the browser thinks we forgot to provide an object.
70+
// Also don't want to do this for functions which render nested expando arrows.
71+
return ['object', {object: value}];
72+
} else {
73+
return prettifyPreview(value as Record<string | number | symbol, unknown>);
74+
}
75+
})(),
76+
['span', {}, `)`],
77+
];
78+
},
79+
80+
hasBody: (sig: any, config: FormatterConfig) => {
81+
if (!isSignal(sig)) return false;
82+
83+
try {
84+
sig();
85+
} catch {
86+
return false;
87+
}
88+
return !config?.ngSkipFormatting;
89+
},
90+
91+
body: (sig: any, config: any): JsonML => {
92+
// We can use sys colors to fit the current DevTools theme.
93+
// Those are unfortunately only available on Chromium-based browsers.
94+
// On Firefow we fall back to the default color
95+
const color = 'var(--sys-color-primary)';
96+
97+
return [
98+
'div',
99+
{style: `background: #FFFFFF10; padding-left: 4px; padding-top: 2px; padding-bottom: 2px;`},
100+
['div', {style: `color: ${color}`}, 'Signal value: '],
101+
['div', {style: `padding-left: .5rem;`}, ['object', {object: sig(), config}]],
102+
['div', {style: `color: ${color}`}, 'Signal function: '],
103+
[
104+
'div',
105+
{style: `padding-left: .5rem;`},
106+
['object', {object: sig, config: {...config, skipFormatting: true}}],
107+
],
108+
];
109+
},
110+
};
111+
112+
function prettifyPreview(
113+
value: Record<string | number | symbol, unknown> | Array<unknown> | undefined,
114+
): string | JsonMLChild {
115+
if (value === null) return 'null';
116+
if (Array.isArray(value)) return `Array(${value.length})`;
117+
if (value instanceof Element) return `<${value.tagName.toLowerCase()}>`;
118+
if (value instanceof URL) return `URL`;
119+
120+
switch (typeof value) {
121+
case 'undefined': {
122+
return 'undefined';
123+
}
124+
case 'function': {
125+
if ('prototype' in value) {
126+
// This is what Chrome renders, can't use `object` though because it creates a nested expando arrow.
127+
return 'class';
128+
} else {
129+
return '() => {…}';
130+
}
131+
}
132+
case 'object': {
133+
if (value.constructor.name === 'Object') {
134+
return '{…}';
135+
} else {
136+
return `${value.constructor.name} {}`;
137+
}
138+
}
139+
default: {
140+
return ['object', {object: value, config: {skipFormatting: true}}];
141+
}
142+
}
143+
}
144+
145+
function isSignal(value: any): boolean {
146+
return value[SIGNAL] !== undefined;
147+
}
148+
149+
/**
150+
* Installs the custom formatter into custom formatting on Signals in the devtools.
151+
*
152+
* Supported by both Chrome and Firefox.
153+
*
154+
* @see https://firefox-source-docs.mozilla.org/devtools-user/custom_formatters/index.html
155+
*/
156+
export function installDevToolsSignalFormatter() {
157+
globalThis.devtoolsFormatters ??= [];
158+
if (!globalThis.devtoolsFormatters.some((f: any) => f === formatter)) {
159+
globalThis.devtoolsFormatters.push(formatter);
160+
}
161+
}

0 commit comments

Comments
 (0)