refactor(devtools): Add support for signals. (#53269)

The devtools now support signals.
Writable signals of primitives are editable.
Object Signal and other non-writable signals (like computed) are not editable.

Co-authored-by: Tomasz Ducin <tomasz.ducin@gmail.com>

PR Close #53269
This commit is contained in:
Matthieu Riegler
2023-12-06 17:02:03 +01:00
committed by Dylan Hunn
parent 025f7af694
commit 9ff4893bc0
21 changed files with 337 additions and 164 deletions
@@ -2,3 +2,10 @@
<app-zippy [title]="getTitle()">This is my content</app-zippy>
<app-heavy></app-heavy>
<div #elementReference>HTMLElement</div>
<hr/>
<h2>Signals</h2>
<div>Object Signal: {{objectSignal()|json}}</div>
<div>Object Computed: {{objectComputed()|json}}</div>
<div>Editable Signal: {{primitiveSignal()}}</div>
<div>Primitive computed: {{primitiveComputed()}}</div>
@@ -6,7 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, EventEmitter, inject, Injector, Input, Output, ViewChild, ViewEncapsulation} from '@angular/core';
import {JsonPipe} from '@angular/common';
import {Component, computed, CUSTOM_ELEMENTS_SCHEMA, ElementRef, EventEmitter, inject, Injector, Input, Output, signal, ViewChild, ViewEncapsulation} from '@angular/core';
import {createCustomElement} from '@angular/elements';
import {RouterOutlet} from '@angular/router';
import {initializeMessageBus} from 'ng-devtools-backend';
@@ -22,7 +23,7 @@ import {ZippyComponent} from './zippy.component';
styleUrls: ['./demo-app.component.scss'],
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [HeavyComponent, RouterOutlet],
imports: [HeavyComponent, RouterOutlet, JsonPipe],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class DemoAppComponent {
@@ -35,6 +36,15 @@ export class DemoAppComponent {
@Output() outputOne = new EventEmitter();
@Output('output_two') outputTwo = new EventEmitter();
primitiveSignal = signal(123);
primitiveComputed = computed(() => this.primitiveSignal() ** 2);
objectSignal = signal({name: 'John', age: 40});
objectComputed = computed(() => {
const original = this.objectSignal();
return {...original, age: original.age + 1};
});
test = [signal(3)];
constructor() {
const el = createCustomElement(ZippyComponent, {injector: inject(Injector)});
customElements.define('app-zippy', el as any);
@@ -124,6 +124,7 @@ ts_library(
srcs = ["component-tree.ts"],
deps = [
":interfaces",
":utils",
"//devtools/projects/ng-devtools-backend/src/lib/directive-forest",
"//devtools/projects/ng-devtools-backend/src/lib/ng-debug-api",
"//devtools/projects/ng-devtools-backend/src/lib/state-serializer",
@@ -19,7 +19,7 @@ import {ComponentTreeNode} from './interfaces';
import {ngDebugDependencyInjectionApiIsSupported} from './ng-debug-api/ng-debug-api';
import {setConsoleReference} from './set-console-reference';
import {serializeDirectiveState} from './state-serializer/state-serializer';
import {runOutsideAngular} from './utils';
import {hasDiDebugAPIs, runOutsideAngular} from './utils';
export const subscribeToClientEvents = (messageBus: MessageBus<Events>): void => {
messageBus.on('shutdown', shutdownCallback(messageBus));
@@ -23,6 +23,7 @@ enum ChangeDetectionStrategy {
import {ComponentTreeNode, DirectiveInstanceType, ComponentInstanceType} from './interfaces';
import type {ClassProvider, ExistingProvider, FactoryProvider, InjectOptions, InjectionToken, Injector, Type, ValueProvider, ɵComponentDebugMetadata as ComponentDebugMetadata, ɵProviderRecord as ProviderRecord} from '@angular/core';
import {isSignal} from './utils';
export const injectorToId = new WeakMap<Injector|HTMLElement, string>();
export const nodeInjectorToResolutionPath = new WeakMap<HTMLElement, SerializedInjector[]>();
@@ -537,10 +538,19 @@ const mutateComponentOrDirective = (updatedStateData: UpdatedStateData, compOrDi
parentObjectOfValueToUpdate = parentObjectOfValueToUpdate[key];
});
if (isSignal(parentObjectOfValueToUpdate)) {
// we don't support updating nested objects in signals yet
return;
}
// When we try to set a property which only has a getter
// the line below could throw an error.
try {
parentObjectOfValueToUpdate[valueKey] = updatedStateData.newValue;
if (isSignal(parentObjectOfValueToUpdate[valueKey])) {
parentObjectOfValueToUpdate[valueKey].set(updatedStateData.newValue);
} else {
parentObjectOfValueToUpdate[valueKey] = updatedStateData.newValue;
}
} catch {
}
};
@@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
import {unwrapSignal} from '../utils';
// Intentionally do not include all the prototype (Except for getters and setters)
// because it contains inherited methods (hasOwnProperty, etc.). Also ignore symbols
// because it is tricky to pass a path to a symbol.
@@ -16,6 +18,7 @@ export function getKeys(obj: {}): string[] {
if (!obj) {
return [];
}
obj = unwrapSignal(obj);
const properties = Object.getOwnPropertyNames(obj);
const prototypeMembers = Object.getOwnPropertyDescriptors(Object.getPrototypeOf(obj));
@@ -39,6 +42,6 @@ export function getKeys(obj: {}): string[] {
* @param propName The string representation of the target property name
* @returns The Descriptor object of the property
*/
export const getDescriptor = (instance: any, propName: string) =>
export const getDescriptor = (instance: any, propName: string): PropertyDescriptor|undefined =>
Object.getOwnPropertyDescriptor(instance, propName) ||
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), propName);
@@ -8,6 +8,8 @@
import {PropType} from 'protocol';
import {isSignal} from '../utils';
const commonTypes = {
boolean: PropType.Boolean,
bigint: PropType.BigInt,
@@ -24,6 +26,10 @@ const commonTypes = {
* @see `devtools/projects/protocol`
*/
export const getPropType = (prop: unknown): PropType => {
if (isSignal(prop)) {
prop = prop();
}
if (prop === undefined) {
return PropType.Undefined;
}
@@ -6,7 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Descriptor, NestedProp, PropType} from 'protocol';
import {ContainerType, Descriptor, NestedProp, PropType} from 'protocol';
import {isSignal, unwrapSignal} from '../utils';
import {getDescriptor, getKeys} from './object-utils';
@@ -18,11 +20,13 @@ type NestedType = PropType.Array|PropType.Object;
export interface CompositeType {
type: Extract<PropType, NestedType>;
prop: any;
containerType: ContainerType;
}
export interface TerminalType {
type: Exclude<PropType, NestedType>;
prop: any;
containerType: ContainerType;
}
export type PropertyData = TerminalType|CompositeType;
@@ -36,23 +40,15 @@ interface LevelOptions {
level?: number;
}
const serializable: {[key in PropType]: boolean} = {
[PropType.Boolean]: true,
[PropType.String]: true,
[PropType.Null]: true,
[PropType.Number]: true,
[PropType.Object]: true,
[PropType.Undefined]: true,
[PropType.Unknown]: true,
[PropType.Array]: false,
[PropType.Set]: false,
[PropType.Map]: false,
[PropType.BigInt]: false,
[PropType.Function]: false,
[PropType.HTMLNode]: false,
[PropType.Symbol]: false,
[PropType.Date]: false,
};
const serializable: Set<PropType> = new Set([
PropType.Boolean,
PropType.String,
PropType.Null,
PropType.Number,
PropType.Object,
PropType.Undefined,
PropType.Unknown,
]);
const typeToDescriptorPreview: Formatter<string> = {
[PropType.Array]: (prop: Array<unknown>) => `Array(${prop.length})`,
@@ -78,6 +74,10 @@ const typeToDescriptorPreview: Formatter<string> = {
};
type Key = string|number;
type NestedSerializerFn =
(instance: any, propName: string|number, nodes: NestedProp[], isReadonly: boolean,
currentLevel: number, level?: number) => Descriptor;
const ignoreList: Set<Key> = new Set([METADATA_PROPERTY_NAME, '__ngSimpleChanges__']);
const shallowPropTypeToTreeMetaData:
@@ -137,8 +137,12 @@ const shallowPropTypeToTreeMetaData:
};
const isEditable =
(descriptor: any, propName: string|number, propData: TerminalType,
(descriptor: PropertyDescriptor|undefined, propName: string|number, propData: TerminalType,
isGetterOrSetter: boolean) => {
if (propData.containerType === 'ReadonlySignal') {
return false;
}
if (typeof propName === 'symbol') {
return false;
}
@@ -158,130 +162,138 @@ const isGetterOrSetter = (descriptor: any): boolean =>
(descriptor?.set || descriptor?.get) && !('value' in descriptor);
const getPreview = (propData: TerminalType|CompositeType, isGetterOrSetter: boolean) => {
if (propData.containerType === 'ReadonlySignal') {
return `Readonly Signal(${typeToDescriptorPreview[propData.type](propData.prop())})`;
} else if (propData.containerType === 'WritableSignal') {
return `Signal(${typeToDescriptorPreview[propData.type](propData.prop())})`;
}
return !isGetterOrSetter ? typeToDescriptorPreview[propData.type](propData.prop) :
typeToDescriptorPreview[PropType.Function]({name: ''});
};
export const createShallowSerializedDescriptor =
(instance: any, propName: string|number, propData: TerminalType): Descriptor => {
const {type} = propData;
export function createShallowSerializedDescriptor(
instance: any, propName: string|number, propData: TerminalType,
isReadonly: boolean): Descriptor {
const {type, containerType} = propData;
const descriptor = getDescriptor(instance, propName as string);
const getterOrSetter: boolean = isGetterOrSetter(descriptor);
const descriptor = getDescriptor(instance, propName as string);
const getterOrSetter: boolean = isGetterOrSetter(descriptor);
const shallowSerializedDescriptor: Descriptor = {
type,
expandable: shallowPropTypeToTreeMetaData[type].expandable,
editable: isEditable(descriptor, propName, propData, getterOrSetter),
preview: getPreview(propData, getterOrSetter),
};
const shallowSerializedDescriptor: Descriptor = {
type,
expandable: shallowPropTypeToTreeMetaData[type].expandable,
editable: isEditable(descriptor, propName, propData, getterOrSetter) && !isReadonly,
preview: getPreview(propData, getterOrSetter),
containerType,
};
if (propData.prop !== undefined && serializable[type]) {
shallowSerializedDescriptor.value = propData.prop;
}
if (propData.prop !== undefined && serializable.has(type)) {
shallowSerializedDescriptor.value = unwrapSignal(propData.prop);
}
return shallowSerializedDescriptor;
};
return shallowSerializedDescriptor;
}
export const createLevelSerializedDescriptor =
(instance: {}, propName: string|number, propData: CompositeType, levelOptions: LevelOptions,
continuation: (instance: any, propName: string|number, level?: number, max?: number) => void):
Descriptor => {
const {type, prop} = propData;
export function createLevelSerializedDescriptor(
instance: {}, propName: string|number, propData: CompositeType, levelOptions: LevelOptions,
continuation: (
instance: any, propName: string|number, isReadonly: boolean, level?: number,
max?: number) => Descriptor): Descriptor {
const {type, prop, containerType} = propData;
const descriptor = getDescriptor(instance, propName as string);
const getterOrSetter: boolean = isGetterOrSetter(descriptor);
const descriptor = getDescriptor(instance, propName as string);
const getterOrSetter: boolean = isGetterOrSetter(descriptor);
const levelSerializedDescriptor: Descriptor = {
type,
editable: false,
expandable: !getterOrSetter && getKeys(prop).length > 0,
preview: getPreview(propData, getterOrSetter),
};
const levelSerializedDescriptor: Descriptor = {
type,
editable: false,
expandable: !getterOrSetter && getKeys(prop).length > 0,
preview: getPreview(propData, getterOrSetter),
containerType,
};
if (levelOptions.level !== undefined && levelOptions.currentLevel < levelOptions.level) {
const value = getLevelDescriptorValue(propData, levelOptions, continuation);
if (value !== undefined) {
levelSerializedDescriptor.value = value;
}
}
if (levelOptions.level !== undefined && levelOptions.currentLevel < levelOptions.level) {
const value = getLevelDescriptorValue(propData, levelOptions, continuation);
if (value !== undefined) {
levelSerializedDescriptor.value = value;
}
}
return levelSerializedDescriptor;
};
return levelSerializedDescriptor;
}
export const createNestedSerializedDescriptor =
(instance: {}, propName: string|number, propData: CompositeType, levelOptions: LevelOptions,
nodes: NestedProp[],
nestedSerializer: (
instance: any, propName: string|number, nodes: NestedProp[], currentLevel: number,
level?: number) => void): Descriptor => {
const {type, prop} = propData;
export function createNestedSerializedDescriptor(
instance: {}, propName: string|number, propData: CompositeType, levelOptions: LevelOptions,
nodes: NestedProp[], nestedSerializer: NestedSerializerFn): Descriptor {
const {type, prop, containerType} = propData;
const descriptor = getDescriptor(instance, propName as string);
const getterOrSetter: boolean = isGetterOrSetter(descriptor);
const descriptor = getDescriptor(instance, propName as string);
const getterOrSetter: boolean = isGetterOrSetter(descriptor);
const nestedSerializedDescriptor: Descriptor = {
type,
editable: false,
expandable: !getterOrSetter && getKeys(prop).length > 0,
preview: getPreview(propData, getterOrSetter),
};
const nestedSerializedDescriptor: Descriptor = {
type,
editable: false,
expandable: !getterOrSetter && getKeys(prop).length > 0,
preview: getPreview(propData, getterOrSetter),
containerType,
};
if (nodes?.length) {
const value = getNestedDescriptorValue(propData, levelOptions, nodes, nestedSerializer);
if (value !== undefined) {
nestedSerializedDescriptor.value = value;
}
}
return nestedSerializedDescriptor;
};
if (nodes?.length) {
const value = getNestedDescriptorValue(propData, levelOptions, nodes, nestedSerializer);
if (value !== undefined) {
nestedSerializedDescriptor.value = value;
}
}
return nestedSerializedDescriptor;
}
function getNestedDescriptorValue(
propData: CompositeType, levelOptions: LevelOptions, nodes: NestedProp[],
nestedSerializer: (
instance: any, propName: string|number, nodes: NestedProp[], currentLevel: number,
level?: number) => void) {
nestedSerializer: NestedSerializerFn) {
const {type, prop} = propData;
const {currentLevel} = levelOptions;
const value = unwrapSignal(prop);
switch (type) {
case PropType.Array:
return nodes.map(
(nestedProp) =>
nestedSerializer(prop, nestedProp.name, nestedProp.children, currentLevel + 1));
(nestedProp) => nestedSerializer(
value, nestedProp.name, nestedProp.children, false, currentLevel + 1));
case PropType.Object:
return nodes.reduce((accumulator, nestedProp) => {
if (prop.hasOwnProperty(nestedProp.name) && !ignoreList.has(nestedProp.name)) {
accumulator[nestedProp.name] =
nestedSerializer(prop, nestedProp.name, nestedProp.children, currentLevel + 1);
accumulator[nestedProp.name] = nestedSerializer(
value, nestedProp.name, nestedProp.children, false, currentLevel + 1);
}
return accumulator;
}, {} as Record<string, void>);
}, {} as Record<string, Descriptor>);
}
}
function getLevelDescriptorValue(
propData: CompositeType, levelOptions: LevelOptions,
continuation: (instance: any, propName: string|number, level?: number, max?: number) => void) {
continuation: (
instance: any, propName: string|number, isReadonly: boolean, level?: number,
max?: number) => Descriptor) {
const {type, prop} = propData;
const {currentLevel, level} = levelOptions;
const value = unwrapSignal(prop);
const isReadonly = isSignal(prop);
switch (type) {
case PropType.Array:
return prop.map((_: any, idx: number) => continuation(prop, idx, currentLevel + 1, level));
return prop.map(
(_: any, idx: number) => continuation(value, idx, isReadonly, currentLevel + 1, level));
case PropType.Object:
return getKeys(prop).reduce((accumulator, propName) => {
if (!ignoreList.has(propName)) {
accumulator[propName] = continuation(prop, propName, currentLevel + 1, level);
accumulator[propName] =
continuation(value, propName, isReadonly, currentLevel + 1, level);
}
return accumulator;
}, {} as Record<string, void>);
}, {} as Record<string, Descriptor>);
}
}
const truncate = (str: string, max = 20): string => {
if (str.length > max) {
return str.substring(0, max) + '...';
}
return str;
};
function truncate(str: string, max = 20): string {
return str.length > max ? str.substring(0, max) + '...' : str;
}
@@ -93,6 +93,7 @@ describe('deeplySerializeSelectedProperties', () => {
editable: true,
preview: '1',
value: 1,
containerType: null
},
nested: {
type: PropType.Object,
@@ -105,8 +106,10 @@ describe('deeplySerializeSelectedProperties', () => {
expandable: true,
editable: false,
preview: 'Array(4)',
containerType: null
},
},
containerType: null
},
});
});
@@ -120,6 +123,7 @@ describe('deeplySerializeSelectedProperties', () => {
editable: true,
preview: '1',
value: 1,
containerType: null
},
nested: {
type: PropType.Object,
@@ -151,21 +155,26 @@ describe('deeplySerializeSelectedProperties', () => {
editable: true,
preview: '1',
value: 1,
containerType: null
},
},
containerType: null
},
],
containerType: null
},
{
type: PropType.Set,
editable: false,
expandable: false,
preview: 'Set(2)',
containerType: null
},
],
containerType: null
},
},
containerType: null
},
});
});
@@ -193,8 +202,10 @@ describe('deeplySerializeSelectedProperties', () => {
editable: false,
expandable: true,
preview: 'Array(3)',
containerType: null
},
},
containerType: null
},
});
});
@@ -233,8 +244,10 @@ describe('deeplySerializeSelectedProperties', () => {
editable: false,
expandable: true,
preview: '{...}',
containerType: null
},
},
containerType: null
},
});
});
@@ -265,8 +278,10 @@ describe('deeplySerializeSelectedProperties', () => {
editable: false,
expandable: true,
preview: '{...}',
containerType: null
},
},
containerType: null
},
});
});
@@ -292,6 +307,7 @@ describe('deeplySerializeSelectedProperties', () => {
editable: false,
preview: '(...)',
value: 42,
containerType: null
},
bar: {
type: PropType.Number,
@@ -299,6 +315,7 @@ describe('deeplySerializeSelectedProperties', () => {
editable: false,
preview: '(...)',
value: 42,
containerType: null
},
});
});
@@ -375,6 +392,7 @@ describe('deeplySerializeSelectedProperties', () => {
editable: true,
preview: '1',
value: 1,
containerType: null
},
bar: {
type: PropType.Number,
@@ -382,8 +400,10 @@ describe('deeplySerializeSelectedProperties', () => {
editable: true,
preview: '2',
value: 2,
containerType: null
},
},
containerType: null
},
foo: {
type: PropType.Number,
@@ -391,10 +411,13 @@ describe('deeplySerializeSelectedProperties', () => {
editable: false,
preview: '(...)',
value: 42,
containerType: null
},
},
containerType: null
},
},
containerType: null
},
});
});
@@ -414,12 +437,14 @@ describe('deeplySerializeSelectedProperties', () => {
editable: false,
expandable: false,
preview: '(...)',
containerType: null
},
bar: {
type: PropType.Object,
editable: false,
expandable: false,
preview: '(...)',
containerType: null,
value: {
foo: {
type: PropType.Number,
@@ -427,6 +452,7 @@ describe('deeplySerializeSelectedProperties', () => {
editable: true,
preview: '1',
value: 1,
containerType: null
},
},
},
@@ -441,6 +467,7 @@ describe('deeplySerializeSelectedProperties', () => {
editable: true,
expandable: false,
preview: 'undefined',
containerType: null
}
});
});
@@ -6,7 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Descriptor, NestedProp, PropType} from 'protocol';
import {ContainerType, Descriptor, NestedProp, PropType} from 'protocol';
import type {Signal} from '@angular/core';
import {isSignal, unwrapSignal} from '../utils';
import {getKeys} from './object-utils';
import {getPropType} from './prop-type';
@@ -19,44 +22,47 @@ const ignoreList = new Set([METADATA_PROPERTY_NAME, '__ngSimpleChanges__']);
const MAX_LEVEL = 1;
const nestedSerializer =
(instance: any, propName: string|number, nodes: NestedProp[], currentLevel = 0,
level = MAX_LEVEL): Descriptor => {
const serializableInstance = instance[propName];
const propData:
PropertyData = {prop: serializableInstance, type: getPropType(serializableInstance)};
function nestedSerializer(
instance: any, propName: string|number, nodes: NestedProp[], isReadonly: boolean,
currentLevel = 0, level = MAX_LEVEL): Descriptor {
instance = unwrapSignal(instance);
const serializableInstance = instance[propName];
const propData: PropertyData = {
prop: serializableInstance,
type: getPropType(serializableInstance),
containerType: getContainerType(serializableInstance)
};
if (currentLevel < level) {
return levelSerializer(
instance, propName, currentLevel, level, nestedSerializerContinuation(nodes, level));
}
if (currentLevel < level) {
const continuation =
(instance: any, propName: string|number, isReadonly: boolean, nestedLevel?: number,
_?: number) => {
const nodeChildren = nodes.find((v) => v.name === propName)?.children ?? [];
return nestedSerializer(instance, propName, nodeChildren, isReadonly, nestedLevel, level);
};
switch (propData.type) {
case PropType.Array:
case PropType.Object:
return createNestedSerializedDescriptor(
instance, propName, propData, {level, currentLevel}, nodes, nestedSerializer);
default:
return createShallowSerializedDescriptor(instance, propName, propData);
}
};
return levelSerializer(instance, propName, isReadonly, currentLevel, level, continuation);
}
const nestedSerializerContinuation = (nodes: NestedProp[], level: number) =>
(instance: any, propName: string|number, nestedLevel?: number, _?: number) => {
const idx = nodes.findIndex((v) => v.name === propName);
if (idx < 0) {
// The property is not specified in the query.
return nestedSerializer(instance, propName, [], nestedLevel, level);
}
return nestedSerializer(instance, propName, nodes[idx].children, nestedLevel, level);
};
switch (propData.type) {
case PropType.Array:
case PropType.Object:
return createNestedSerializedDescriptor(
instance, propName, propData, {level, currentLevel}, nodes, nestedSerializer);
default:
return createShallowSerializedDescriptor(instance, propName, propData, isReadonly);
}
}
function levelSerializer(
instance: any, propName: string|number, currentLevel = 0, level = MAX_LEVEL,
continuation = levelSerializer): Descriptor {
instance: any, propName: string|number, isReadonly: boolean, currentLevel = 0,
level = MAX_LEVEL, continuation = levelSerializer): Descriptor {
const serializableInstance = instance[propName];
const propData:
PropertyData = {prop: serializableInstance, type: getPropType(serializableInstance)};
const propData: PropertyData = {
prop: serializableInstance,
type: getPropType(serializableInstance),
containerType: getContainerType(serializableInstance)
};
switch (propData.type) {
case PropType.Array:
@@ -64,35 +70,50 @@ function levelSerializer(
return createLevelSerializedDescriptor(
instance, propName, propData, {level, currentLevel}, continuation);
default:
return createShallowSerializedDescriptor(instance, propName, propData);
return createShallowSerializedDescriptor(instance, propName, propData, isReadonly);
}
}
export function serializeDirectiveState(
instance: object, levels = MAX_LEVEL): {[key: string]: Descriptor} {
export function serializeDirectiveState(instance: object): Record<string, Descriptor> {
const result: Record<string, Descriptor> = {};
getKeys(instance).forEach((prop) => {
const value = unwrapSignal(instance);
const isReadonly = isSignal(instance);
getKeys(value).forEach((prop) => {
if (typeof prop === 'string' && ignoreList.has(prop)) {
return;
}
result[prop] = levelSerializer(instance, prop, 0, 0);
result[prop] = levelSerializer(value, prop, isReadonly, 0, 0);
});
return result;
}
export const deeplySerializeSelectedProperties =
(instance: any, props: NestedProp[]): {[name: string]: Descriptor} => {
const result: Record<string, Descriptor> = {};
getKeys(instance).forEach((prop) => {
if (ignoreList.has(prop)) {
return;
}
const idx = props.findIndex((v) => v.name === prop);
if (idx < 0) {
result[prop] = levelSerializer(instance, prop);
} else {
result[prop] = nestedSerializer(instance, prop, props[idx].children);
}
});
return result;
};
export function deeplySerializeSelectedProperties(
instance: object, props: NestedProp[]): Record<string, Descriptor> {
const result: Record<string, Descriptor> = {};
const isReadonly = isSignal(instance);
getKeys(instance).forEach((prop) => {
if (ignoreList.has(prop)) {
return;
}
const childrenProps = props.find((v) => v.name === prop)?.children;
if (!childrenProps) {
result[prop] = levelSerializer(instance, prop, isReadonly);
} else {
result[prop] = nestedSerializer(instance, prop, childrenProps, isReadonly);
}
});
return result;
}
function getContainerType(instance: unknown): ContainerType {
if (isSignal(instance)) {
return isWritableSignal(instance) ? 'WritableSignal' : 'ReadonlySignal';
}
return null;
}
function isWritableSignal(s: any): boolean {
return typeof s['set'] === 'function';
}
@@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
export const ngDebug = () => (window as any).ng;
export const runOutsideAngular = (f: () => any): void => {
const w = window as any;
if (!w.Zone || !w.Zone.current) {
@@ -34,3 +36,37 @@ export const isCustomElement = (node: Node) => {
const tagName = node.tagName.toLowerCase();
return !!customElements.get(tagName);
};
export function hasDiDebugAPIs(): boolean {
if (!ngDebugApiIsSupported('ɵgetInjectorResolutionPath')) {
return false;
}
if (!ngDebugApiIsSupported('ɵgetDependenciesFromInjectable')) {
return false;
}
if (!ngDebugApiIsSupported('ɵgetInjectorProviders')) {
return false;
}
if (!ngDebugApiIsSupported('ɵgetInjectorMetadata')) {
return false;
}
return true;
}
export function ngDebugApiIsSupported(api: string): boolean {
const ng = ngDebug();
return typeof ng[api] === 'function';
}
export function isSignal(prop: unknown): prop is() => unknown {
if (!ngDebugApiIsSupported('isSignal')) {
return false;
}
return (window as any).ng.isSignal(prop);
}
export function unwrapSignal(s: any): any {
return isSignal(s) ? s() : s;
}
@@ -18,12 +18,14 @@ describe('arrayify', () => {
expandable: true,
preview: '',
type: PropType.Array,
containerType: null,
},
bar: {
editable: true,
expandable: true,
preview: '',
type: PropType.Array,
containerType: null,
},
});
expect(arr).toEqual([
@@ -34,6 +36,7 @@ describe('arrayify', () => {
expandable: true,
preview: '',
type: PropType.Array,
containerType: null,
},
parent: null,
},
@@ -44,6 +47,7 @@ describe('arrayify', () => {
expandable: true,
preview: '',
type: PropType.Array,
containerType: null,
},
parent: null,
},
@@ -56,12 +60,14 @@ describe('arrayify', () => {
expandable: true,
preview: '',
type: PropType.Array,
containerType: null,
},
2: {
editable: true,
expandable: true,
preview: '',
type: PropType.Array,
containerType: null,
},
});
expect(arr).toEqual([
@@ -72,6 +78,7 @@ describe('arrayify', () => {
expandable: true,
preview: '',
type: PropType.Array,
containerType: null,
},
parent: null,
},
@@ -82,6 +89,7 @@ describe('arrayify', () => {
expandable: true,
preview: '',
type: PropType.Array,
containerType: null,
},
parent: null,
},
@@ -33,6 +33,7 @@ const properties: Properties = {
value: {},
},
},
containerType: null,
},
i: {
editable: false,
@@ -55,6 +56,7 @@ const properties: Properties = {
value: {},
},
},
containerType: null,
},
p: {
editable: false,
@@ -77,6 +79,7 @@ const properties: Properties = {
value: {},
},
},
containerType: null,
},
i_1: {
editable: true,
@@ -84,12 +87,14 @@ const properties: Properties = {
preview: 'input i1',
type: PropType.String,
value: 'input i1',
containerType: null,
},
o_1: {
editable: false,
expandable: true,
preview: '',
type: PropType.Object,
containerType: null,
},
},
metadata: {
@@ -54,6 +54,7 @@ const fooNestedProperties: Properties = {
value: {},
},
},
containerType: null,
},
},
};
@@ -66,6 +67,7 @@ const barNestedProps: Properties = {
preview: 'undefined',
type: PropType.Undefined,
value: undefined,
containerType: null,
},
},
};
@@ -26,6 +26,7 @@ describe('PropertyDataSource', () => {
preview: '42',
type: PropType.Number,
value: 42,
containerType: null,
},
},
getTreeFlattener(), flatTreeControl, {element: [1, 2, 3]}, null as any);
@@ -37,6 +38,7 @@ describe('PropertyDataSource', () => {
preview: '43',
type: PropType.Number,
value: 43,
containerType: null,
},
});
@@ -51,6 +53,7 @@ describe('PropertyDataSource', () => {
preview: '43',
type: PropType.Number,
value: 43,
containerType: null,
},
name: 'foo',
parent: null,
@@ -3,9 +3,14 @@
@switch (currentPropertyState) {
@case (readState) {
<span>
<span class="editable">
{{ valueToSubmit }}
</span>
@switch(containerType) {
@case('WritableSignal') {
<span class="editable">Signal({{valueToSubmit}})</span>
}
@default {
<span class="editable">{{ valueToSubmit }}</span>
}
}
</span>
}
@case (writeState) {
@@ -7,6 +7,7 @@
*/
import {AfterViewChecked, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnInit, Output,} from '@angular/core';
import {ContainerType} from 'protocol';
type EditorType = string|number|boolean;
type EditorResult = EditorType|Array<EditorType>;
@@ -32,6 +33,8 @@ const parseValue = (value: EditorResult): EditorResult => {
export class PropertyEditorComponent implements AfterViewChecked, OnInit {
@Input({required: true}) key!: string;
@Input({required: true}) initialValue!: EditorResult;
@Input() containerType: ContainerType = null;
@Output() updateValue = new EventEmitter<EditorResult>();
readState = PropertyEditorState.Read;
@@ -1,13 +1,14 @@
@if (treeControl) {
<mat-tree class="property-list" [dataSource]="dataSource" [treeControl]="treeControl">
<mat-tree-node matTreeNodePaddingIndent="14" *matTreeNodeDef="let node" matTreeNodePadding>
<span class="name non-expandable"> {{ node.prop.name }} </span>:&nbsp;
<span class="name non-expandable">{{ node.prop.name }}</span>:&nbsp;
@if (!node.prop.descriptor.editable) {
<ng-property-preview (inspect)="inspect.emit(node)" [node]="node"></ng-property-preview>
} @else {
<ng-property-editor
[key]="node.prop.name"
[initialValue]="node.prop.descriptor.value || node.prop.descriptor.preview"
[containerType]="node.prop.descriptor.containerType"
(updateValue)="handleUpdate(node, $event)"
/>
}
@@ -18,7 +19,7 @@
{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}
</mat-icon>
&nbsp;
<span class="name"> {{ node.prop.name }} </span>:&nbsp;
<span class="name">{{ node.prop.name }}</span>:
<span class="value">
{{ treeControl.isExpanded(node) ? '' : node.prop.descriptor.preview }}
</span>
@@ -55,6 +55,8 @@ export interface InjectedService {
providedIn: Injector;
}
export type ContainerType = 'WritableSignal'|'ReadonlySignal'|null;
export enum PropType {
Number,
String,
@@ -79,6 +81,7 @@ export interface Descriptor {
editable: boolean;
type: PropType;
preview: string;
containerType: ContainerType;
}
export interface DirectivesProperties {
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Component, ElementRef, EventEmitter, Input, Output, ViewChild, ViewEncapsulation} from '@angular/core';
import {Component, computed, ElementRef, EventEmitter, Input, Output, signal, ViewChild, ViewEncapsulation} from '@angular/core';
import {ZippyComponent} from './zippy.component';
@@ -26,6 +26,14 @@ export class DemoAppComponent {
@Output() outputOne = new EventEmitter();
@Output('output_two') outputTwo = new EventEmitter();
primitiveSignal = signal(123);
primitiveComputed = computed(() => this.primitiveSignal() ** 2);
objectSignal = signal({name: 'John', age: 40});
objectComputed = computed(() => {
const original = this.objectSignal();
return {...original, age: original.age + 1};
});
getTitle(): '► Click to expand'|'▼ Click to collapse' {
if (!this.zippy || !this.zippy.visible) {
return '► Click to expand';
@@ -9,6 +9,7 @@ import {assertDefined} from '../../util/assert';
import {global} from '../../util/global';
import {setupFrameworkInjectorProfiler} from '../debug/framework_injector_profiler';
import {setProfiler} from '../profiler';
import {isSignal} from '../reactivity/api';
import {applyChanges} from './change_detection_utils';
import {getComponent, getContext, getDirectiveMetadata, getDirectives, getHostElement, getInjector, getListeners, getOwningComponent, getRootComponents} from './discovery_utils';
@@ -55,6 +56,7 @@ const globalUtilsFunctions = {
'getRootComponents': getRootComponents,
'getDirectives': getDirectives,
'applyChanges': applyChanges,
'isSignal': isSignal,
};
type GlobalUtilsFunctions = keyof typeof globalUtilsFunctions;