diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md
index 7cad1589..d20b115f 100644
--- a/npm_modules/cli/debugger/README.md
+++ b/npm_modules/cli/debugger/README.md
@@ -48,6 +48,19 @@ Important routes:
- `/api/devtools/snapshot`, `/api/devtools/highlight`, and `/api/devtools/evaluate`: proxy the explicit web debugger bridge contract through loopback CDP.
- `/api/devtools/performance/snapshot` and `/api/devtools/performance/trace/*`: sample the exact web preview and record one bounded global Chromium trace without changing the daemon/Hermes `/api/performance/*` routes.
+Web preview targets advertise the `component-properties` capability. Their
+Elements snapshots may include a read-only `Valdi props` projection captured
+from own enumerable data descriptors only; accessors, inherited fields, and
+symbol keys are omitted. Component properties share a 64 KiB UTF-8 budget and
+are discarded before the hierarchy if the complete snapshot reaches its
+existing envelope limit. JavaScript has no resumable own-key iterator, so
+reflecting a Proxy can execute its `ownKeys` and descriptor traps and may
+materialize their complete key result before the cap is applied. A throwing or
+revoked Proxy therefore causes that component's properties to be omitted while
+preserving the hierarchy. Prototype traversal is not used, and property values
+are never read through normal property access. Native targets do not advertise
+or emit this data.
+
Renderer tracing uses the runtime debugger protocol and the existing native
trace recorder. Captures are process-wide: the selected context is the capture
target used to reach the runtime, not the origin assigned to every event.
diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css
index 54b15a20..86c41eb9 100644
--- a/npm_modules/cli/debugger/devtools-panel.css
+++ b/npm_modules/cli/debugger/devtools-panel.css
@@ -471,6 +471,11 @@ button {
font-weight: 500;
}
+.component-properties {
+ margin-bottom: 12px;
+ border-bottom: 1px solid var(--border);
+}
+
.rule-origin {
float: right;
color: var(--muted);
diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js
index 83fe2d41..e6558e68 100644
--- a/npm_modules/cli/debugger/devtools-panel.js
+++ b/npm_modules/cli/debugger/devtools-panel.js
@@ -826,6 +826,7 @@ async function connectToInspectedPage() {
}
if (previousTargetKey !== nextTargetKey) state.targetGeneration++;
state.target = payload.target;
+ if (previousTargetKey !== null && previousTargetKey !== nextTargetKey) render();
elements.targetName.textContent = state.target.name || 'Valdi application';
elements.targetName.title = state.target.applicationUrl || inspectedUrl;
elements.targetMetadata.textContent = `Chromium · :${state.target.debuggingPort}`;
@@ -1024,6 +1025,25 @@ function propertyRows(attributes, options) {
.join('');
}
+function componentMetadata(node) {
+ if (!node.component) return {};
+ return {
+ ...(node.component.elementId === undefined ? {} : { elementId: node.component.elementId }),
+ key: node.component.key,
+ name: node.component.name,
+ };
+}
+
+function renderComponentProperties(node) {
+ if (node.component?.properties === undefined || !targetSupports('component-properties')) return '';
+ return `
+
+
+ ${propertyRows(node.component.properties, { css: false })}
+
+ `;
+}
+
function renderStyles(node) {
const attributes = nodeAttributes(node);
const domStyle = node.element?.dom?.attributes?.style
@@ -1104,23 +1124,24 @@ function renderInspector() {
}
const renderedNode = inspectedNode(node);
+ const componentProperties = renderComponentProperties(node);
if (node.component && renderedNode === node) {
- elements.inspector.innerHTML = `
${propertyRows(node.component, { css: false })}This component does not currently render a backing element.
`;
+ elements.inspector.innerHTML = `${propertyRows(componentMetadata(node), { css: false })}${componentProperties}This component does not currently render a backing element.
`;
return;
}
if (state.activeDetail === 'styles') {
- elements.inspector.innerHTML = renderStyles(renderedNode);
+ elements.inspector.innerHTML = `${componentProperties}${renderStyles(renderedNode)}`;
} else if (state.activeDetail === 'computed') {
- elements.inspector.innerHTML = renderComputed(renderedNode);
+ elements.inspector.innerHTML = `${componentProperties}${renderComputed(renderedNode)}`;
} else {
const textContent = renderedNode.element?.dom?.textContent
? valdiDebuggerTreeModel.formatValue(renderedNode.element.dom.textContent, 0)
: '';
const componentDetails = node.component
- ? `${propertyRows(node.component, { css: false })}`
+ ? `${propertyRows(componentMetadata(node), { css: false })}`
: '';
- elements.inspector.innerHTML = `${componentDetails}${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `${escapeHtml(textContent)} ` : ''}`;
+ elements.inspector.innerHTML = `${componentDetails}${componentProperties}${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `${escapeHtml(textContent)} ` : ''}`;
}
}
diff --git a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
index de51813e..50b08f7b 100644
--- a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
+++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
@@ -6,7 +6,7 @@ import { Script } from 'node:vm';
interface DevToolsTreeNode {
bounds?: { height: number; width: number; x: number; y: number };
children: DevToolsTreeNode[];
- component?: { elementId?: string; key: string; name: string };
+ component?: { elementId?: string; key: string; name: string; properties?: Record };
element?: {
attributes: Record;
dom: { attributes: Record; tagName: string; textContent?: string };
@@ -42,9 +42,11 @@ interface DevToolsHierarchyPanel {
selectedNodeId: string | null;
snapshot: { tree: DevToolsTreeNode } | null;
snapshotGeneration: number;
- target: { id: string; sessionId: string } | null;
+ target: { capabilities?: string[]; id: string; sessionId: string } | null;
+ targetGeneration: number;
};
treeContent: TreeStubElement;
+ clearTargetPresentation(message: string): void;
findNode(id: string): DevToolsTreeNode | null;
inspectedNodeId(node: DevToolsTreeNode | null): string | null;
queueHighlight(nodeId: string | null): void;
@@ -225,7 +227,12 @@ function componentTree(): DevToolsTreeNode {
tag: 'label',
},
],
- component: { elementId: '8', key: 'nested', name: 'NestedExampleComponent' },
+ component: {
+ elementId: '8',
+ key: 'nested',
+ name: 'NestedExampleComponent',
+ properties: { enabled: true, title: ' ' },
+ },
id: 'component:["7","nested"]',
tag: 'NestedExampleComponent',
},
@@ -625,7 +632,7 @@ describe('integrated DevTools component hierarchy', () => {
};
panel = new Script(
- `${treeModelSource}\n${panelSource}\n({ findNode, inspectedNodeId, inspectorContent: elements.inspector, queueHighlight, refreshSnapshot, renderInspector, renderTree, selectNode, state, treeContent: elements.tree })`,
+ `${treeModelSource}\n${panelSource}\n({ clearTargetPresentation, findNode, inspectedNodeId, inspectorContent: elements.inspector, queueHighlight, refreshSnapshot, renderInspector, renderTree, selectNode, state, treeContent: elements.tree })`,
).runInNewContext({
URL,
URLSearchParams,
@@ -640,7 +647,11 @@ describe('integrated DevTools component hierarchy', () => {
navigator: { clipboard: { writeText: () => Promise.resolve() } },
window,
}) as DevToolsHierarchyPanel;
- panel.state.target = { id: 'owl:web-preview', sessionId: 'web-preview' };
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'snapshot', 'highlight', 'console', 'performance'],
+ id: 'owl:web-preview',
+ sessionId: 'web-preview',
+ };
panel.state.snapshot = { tree: componentTree() };
panel.state.snapshotGeneration = 1;
});
@@ -681,6 +692,65 @@ describe('integrated DevTools component hierarchy', () => {
expect(panel.inspectorContent.innerHTML).toContain('Text content');
});
+ it('renders escaped read-only Valdi props only when the target advertises the capability', () => {
+ panel.selectNode('component:["7","nested"]');
+
+ expect(panel.inspectorContent.innerHTML).toContain('aria-label="Valdi props"');
+ expect(panel.inspectorContent.innerHTML).toContain('read only');
+ expect(panel.inspectorContent.innerHTML).toContain('<img src=x onerror=alert(1)>');
+ expect(panel.inspectorContent.innerHTML).not.toContain(' {
+ const node = panel.findNode('component:["7","nested"]');
+ if (!node?.component) throw new Error('Expected the nested component fixture.');
+ delete node.component.properties;
+ panel.selectNode(node.id);
+
+ expect(panel.inspectorContent.innerHTML).not.toContain('aria-label="Valdi props"');
+
+ node.component.properties = {};
+ panel.renderInspector();
+
+ expect(panel.inspectorContent.innerHTML).toContain('aria-label="Valdi props"');
+ expect(panel.inspectorContent.innerHTML).toContain('No properties available.');
+ });
+
+ it('clears properties on target change and drops a stale snapshot response', async () => {
+ let resolveSnapshot: ((response: { ok: boolean; json(): Promise> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveSnapshot = resolve;
+ }),
+ );
+ panel.selectNode('component:["7","nested"]');
+ const staleRefresh = panel.refreshSnapshot();
+ await Promise.resolve();
+
+ panel.state.targetGeneration++;
+ panel.clearTargetPresentation('Loading the replacement target…');
+ panel.state.target = {
+ capabilities: ['components', 'component-properties', 'snapshot', 'highlight', 'console', 'performance'],
+ id: 'replacement-target',
+ sessionId: 'replacement-session',
+ };
+ panel.renderInspector();
+ expect(panel.state.snapshot).toBeNull();
+ expect(panel.inspectorContent.innerHTML).not.toContain('onerror');
+
+ if (resolveSnapshot === undefined) throw new Error('Expected a deferred hierarchy snapshot.');
+ resolveSnapshot({ json: () => Promise.resolve({ tree: componentTree() }), ok: true });
+ await staleRefresh;
+
+ expect(panel.state.snapshot).toBeNull();
+ expect(panel.inspectorContent.innerHTML).not.toContain('onerror');
+ });
+
it('preserves keyed component selection across updates and safely falls back when it disappears', async () => {
panel.selectNode('component:["7","nested"]');
fetchResponse = { tree: componentTree() };
diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts
index 58fc211a..07e8e241 100644
--- a/npm_modules/cli/src/debugger/server.spec.ts
+++ b/npm_modules/cli/src/debugger/server.spec.ts
@@ -901,7 +901,7 @@ describe('debugger server', () => {
expect(JSON.parse(matching.body)).toEqual({
target: jasmine.objectContaining({
applicationUrl: 'http://127.0.0.1:54321/index.html?tenant=alpha&mode=dev',
- capabilities: ['components', 'snapshot', 'highlight', 'console', 'performance'],
+ capabilities: ['components', 'component-properties', 'snapshot', 'highlight', 'console', 'performance'],
debuggingPort: 9333,
id: 'owl:web-preview',
identityMode: 'inspected-page',
@@ -1022,7 +1022,14 @@ describe('debugger server', () => {
expect(second['capabilities']).toEqual(['components', 'snapshot']);
expect(second['identityMode']).toBe('target-id');
expect(webTargets[0]?.['identityMode']).toBe('inspected-page');
- expect(webTargets[0]?.['capabilities']).toEqual(['components', 'snapshot', 'highlight', 'console', 'performance']);
+ expect(webTargets[0]?.['capabilities']).toEqual([
+ 'components',
+ 'component-properties',
+ 'snapshot',
+ 'highlight',
+ 'console',
+ 'performance',
+ ]);
const resolved = await request(
new URL(`/api/devtools/target?targetId=${encodeURIComponent(targetId)}`, debuggerServer.url).toString(),
diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts
index 04ab45fd..ac6b0b33 100644
--- a/npm_modules/cli/src/debugger/server.ts
+++ b/npm_modules/cli/src/debugger/server.ts
@@ -1263,6 +1263,7 @@ function webPreviewTargetPayload(target: WebPreviewDebuggerTarget): DebuggerTarg
attachable: true,
capabilities: [
DebuggerTargetCapability.Components,
+ DebuggerTargetCapability.ComponentProperties,
DebuggerTargetCapability.Snapshot,
DebuggerTargetCapability.Highlight,
DebuggerTargetCapability.Console,
diff --git a/npm_modules/cli/src/debugger/targetRegistry.spec.ts b/npm_modules/cli/src/debugger/targetRegistry.spec.ts
index c11439da..783537fa 100644
--- a/npm_modules/cli/src/debugger/targetRegistry.spec.ts
+++ b/npm_modules/cli/src/debugger/targetRegistry.spec.ts
@@ -50,6 +50,7 @@ function webPreviewTarget(): DebuggerTargetDescriptor {
attachable: true,
capabilities: [
DebuggerTargetCapability.Components,
+ DebuggerTargetCapability.ComponentProperties,
DebuggerTargetCapability.Snapshot,
DebuggerTargetCapability.Console,
],
@@ -130,9 +131,11 @@ describe('debugger target registry', () => {
expect(second.find(target => target.transport === DebuggerTargetTransport.ValdiDaemon)?.id).toBe(native.id);
expect(replacement.id).not.toBe(native.id);
expect(native.capabilities).toEqual([DebuggerTargetCapability.Components, DebuggerTargetCapability.Snapshot]);
+ expect(native.capabilities).not.toContain(DebuggerTargetCapability.ComponentProperties);
expect(native.identityMode).toBe(DebuggerTargetIdentityMode.TargetId);
expect(first).toContain(
jasmine.objectContaining({
+ capabilities: jasmine.arrayContaining([DebuggerTargetCapability.ComponentProperties]),
id: 'owl:web-preview',
identityMode: DebuggerTargetIdentityMode.InspectedPage,
}),
diff --git a/npm_modules/cli/src/debugger/targetRegistry.ts b/npm_modules/cli/src/debugger/targetRegistry.ts
index ca6ffe9a..8f8be705 100644
--- a/npm_modules/cli/src/debugger/targetRegistry.ts
+++ b/npm_modules/cli/src/debugger/targetRegistry.ts
@@ -23,6 +23,7 @@ export enum DebuggerTargetTransport {
/** Capability names are serialized so frontends can hide unsupported tools. */
export enum DebuggerTargetCapability {
+ ComponentProperties = 'component-properties',
Components = 'components',
Console = 'console',
Highlight = 'highlight',
diff --git a/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts b/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts
index 48a021d4..315d26f1 100644
--- a/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts
+++ b/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts
@@ -26,6 +26,8 @@ export type ComponentDisposable = (() => void) | Unsubscribable;
export interface RendererDebugVirtualNodeSnapshot {
readonly children: readonly IRenderedVirtualNode[];
readonly component: IComponent | undefined;
+ /** Internal debugger input. Consumers must serialize a detached snapshot before exposing it. */
+ readonly componentViewModel?: unknown;
readonly element: IRenderedElement | undefined;
readonly key: string;
readonly parent: IRenderedVirtualNode | undefined;
diff --git a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts
index 6f43a6c3..676ae790 100644
--- a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts
+++ b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts
@@ -319,7 +319,7 @@ class VirtualNodeBridge implements IRenderedVirtualNode {
}
}
- return {
+ const snapshot: RendererDebugVirtualNodeSnapshot = {
children,
component: this.node.component?.instance,
element: this.node.element === undefined ? undefined : getRenderedElementBridge(this.renderer, this.node.element),
@@ -330,6 +330,11 @@ class VirtualNodeBridge implements IRenderedVirtualNode {
: getVirtualNodeBridge(this.renderer, parent),
traversedLinkCount,
};
+ Object.defineProperty(snapshot, 'componentViewModel', {
+ enumerable: false,
+ value: this.node.component?.viewModel,
+ });
+ return snapshot;
}
get parentIndex(): number {
diff --git a/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts b/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts
index 24714673..0ff88040 100644
--- a/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts
+++ b/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts
@@ -2327,6 +2327,9 @@ describe('Renderer', () => {
const headerContainerVirtualNode = componentDebugSnapshot.children[0];
const headerContainerDebugSnapshot = renderer.getDebugVirtualNodeSnapshot(headerContainerVirtualNode, 10, 20)!;
+ expect(componentDebugSnapshot.componentViewModel).toBe(componentDebugSnapshot.component?.viewModel);
+ expect(Object.getOwnPropertyDescriptor(componentDebugSnapshot, 'componentViewModel')?.enumerable).toBeFalse();
+ expect(Object.keys(componentDebugSnapshot)).not.toContain('componentViewModel');
expect(headerContainerDebugSnapshot.children.map(child => child.element?.tag)).toEqual(['header']);
expect(renderer.getDebugVirtualNodeSnapshot(headerContainerVirtualNode, 10, 2)).toBeUndefined();
});
diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts
index f9895cd5..d6503ce8 100644
--- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts
+++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts
@@ -37,6 +37,7 @@ export interface WebRendererDebugNodeSnapshot {
elementId?: string;
key: string;
name: string;
+ properties?: Record;
};
element?: {
id: number;
@@ -71,6 +72,7 @@ export interface WebRendererDebugComponentSnapshot extends WebRendererDebugNodeS
elementId?: string;
key: string;
name: string;
+ properties?: Record;
};
}
@@ -329,10 +331,25 @@ export class ValdiWebRendererDelegate implements IRendererDelegate {
return elementSnapshot;
}
const componentSnapshot: WebRendererDebugSnapshot = { tree: componentTree, viewport };
+ if (JSON.stringify(componentSnapshot).length <= snapshotCharacterLimit) {
+ return componentSnapshot;
+ }
+ stripComponentProperties(componentTree);
return JSON.stringify(componentSnapshot).length <= snapshotCharacterLimit ? componentSnapshot : elementSnapshot;
}
}
+function stripComponentProperties(root: WebRendererDebugNodeSnapshot): void {
+ const pending = [root];
+ while (pending.length > 0) {
+ const node = pending.pop()!;
+ if (node.component !== undefined) {
+ delete node.component.properties;
+ }
+ pending.push(...node.children);
+ }
+}
+
function captureRenderedElementAttributes(
element: IRenderedElement,
budget: DebugSerializationBudget,
diff --git a/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts b/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts
index de67ccdd..04cfe27f 100644
--- a/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts
+++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts
@@ -7,6 +7,7 @@ import type {
WebRendererDebugElementSnapshot,
WebRendererDebugNodeSnapshot,
} from '../ValdiWebRendererDelegate';
+import { captureDebuggerPropertiesSnapshot, type DebuggerValueSnapshotLimits } from './DebuggerValueSnapshot';
const MAX_COMPONENT_HIERARCHY_CHILD_LINKS = 1_000;
const MAX_COMPONENT_HIERARCHY_DEPTH = 64;
@@ -16,6 +17,13 @@ const MAX_COMPONENT_HIERARCHY_TRAVERSAL_LINKS = 4_096;
const MAX_COMPONENT_NAME_CHARACTERS = 256;
const MAX_COMPONENT_KEY_CHARACTERS = 256;
const MAX_COMPONENT_PROTOTYPE_DEPTH = 16;
+const MAX_COMPONENT_PROPERTY_BYTES = 65_536;
+const COMPONENT_PROPERTY_LIMITS: DebuggerValueSnapshotLimits = {
+ maximumDepth: 4,
+ maximumEntries: 50,
+ maximumPropertyNameCharacters: 256,
+ maximumStringBytes: 65_536,
+};
interface IndexedElementTree {
readonly childIdsByParentId: Map;
@@ -30,6 +38,11 @@ interface CapturedHierarchyNode {
interface CapturedVirtualNode extends RendererDebugVirtualNodeSnapshot {
readonly node: IRenderedVirtualNode;
+ componentOutput?: WebRendererDebugComponentSnapshot;
+}
+
+interface ComponentPropertyBudget {
+ remainingBytes: number;
}
interface VirtualTraversalFrame {
@@ -80,6 +93,7 @@ export function captureComponentHierarchySnapshot(
}
const capturedNodes: CapturedVirtualNode[] = [];
+ const componentPropertyBudget: ComponentPropertyBudget = { remainingBytes: MAX_COMPONENT_PROPERTY_BYTES };
const componentIds = new Set();
const consumedChildCountByParentId = new Map();
const usedElementIds = new Set();
@@ -138,6 +152,7 @@ export function captureComponentHierarchySnapshot(
frame.captured = {
children: debugSnapshot.children,
component: debugSnapshot.component,
+ componentViewModel: debugSnapshot.componentViewModel,
element: debugSnapshot.element,
key: debugSnapshot.key,
node: frame.node,
@@ -179,6 +194,7 @@ export function captureComponentHierarchySnapshot(
indexedElements,
componentIds,
consumedChildCountByParentId,
+ componentPropertyBudget,
usedElementIds,
);
if (result === undefined) {
@@ -214,6 +230,7 @@ function captureCompletedFrame(
indexedElements: IndexedElementTree,
componentIds: Set,
consumedChildCountByParentId: Map,
+ componentPropertyBudget: ComponentPropertyBudget,
usedElementIds: Set,
): CapturedHierarchyNode | undefined {
const captured = frame.captured;
@@ -256,6 +273,7 @@ function captureCompletedFrame(
componentIds.add(componentId);
const firstElementId = frame.capturedChildren.find(child => child.firstElementId !== undefined)?.firstElementId;
const backingElement = firstElementId === undefined ? undefined : indexedElements.elementsById.get(firstElementId);
+ const properties = captureComponentProperties(captured.componentViewModel, componentPropertyBudget);
const node: WebRendererDebugComponentSnapshot = {
...(backingElement === undefined ? {} : { bounds: backingElement.bounds }),
children: frame.capturedChildren.map(child => child.node),
@@ -263,10 +281,12 @@ function captureCompletedFrame(
...(firstElementId === undefined ? {} : { elementId: firstElementId }),
key: captured.key,
name: componentName,
+ ...(properties === undefined ? {} : { properties }),
},
id: componentId,
tag: componentName,
};
+ captured.componentOutput = node;
return {
...(firstElementId === undefined ? {} : { firstElementId }),
node,
@@ -377,6 +397,9 @@ function isCapturedVirtualTopologyCurrent(
) {
return false;
}
+ if (current.componentViewModel !== captured.componentViewModel && captured.componentOutput !== undefined) {
+ delete captured.componentOutput.component.properties;
+ }
remainingChildLinks -= children.length;
remainingTraversalLinks -= current.traversedLinkCount;
if (children.length !== captured.children.length) {
@@ -391,6 +414,29 @@ function isCapturedVirtualTopologyCurrent(
return true;
}
+function captureComponentProperties(
+ viewModel: unknown,
+ budget: ComponentPropertyBudget,
+): Record | undefined {
+ if (budget.remainingBytes <= 0) {
+ return undefined;
+ }
+ const captured = captureDebuggerPropertiesSnapshot(
+ viewModel,
+ MAX_COMPONENT_PROPERTY_BYTES,
+ COMPONENT_PROPERTY_LIMITS,
+ );
+ if (captured === undefined) {
+ return undefined;
+ }
+ if (captured.serializedBytes > budget.remainingBytes) {
+ budget.remainingBytes = 0;
+ return undefined;
+ }
+ budget.remainingBytes -= captured.serializedBytes;
+ return captured.value;
+}
+
function readElementId(element: IRenderedElement): string | undefined {
const id = element.id;
return Number.isSafeInteger(id) && id >= 0 ? String(id) : undefined;
diff --git a/src/valdi_modules/src/valdi/web_renderer/src/debug/DebuggerValueSnapshot.ts b/src/valdi_modules/src/valdi/web_renderer/src/debug/DebuggerValueSnapshot.ts
new file mode 100644
index 00000000..85f1aa9f
--- /dev/null
+++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/DebuggerValueSnapshot.ts
@@ -0,0 +1,403 @@
+export interface DebuggerValueSnapshotLimits {
+ readonly maximumDepth: number;
+ readonly maximumEntries: number;
+ readonly maximumPropertyNameCharacters: number;
+ readonly maximumStringBytes: number;
+}
+
+export interface DebuggerValueSnapshotCapture {
+ readonly serializedBytes: number;
+ readonly value: T;
+}
+
+interface DebuggerValueSnapshotBudget {
+ remainingBytes: number;
+}
+
+const DEBUG_ACCESSOR_OMISSION_MARKER = 'accessors or unsupported fields omitted';
+const DEBUG_CIRCULAR_MARKER = ' ';
+const DEBUG_EMPTY_ARRAY_ITEM_MARKER = ' ';
+const DEBUG_BIGINT_MARKER = ' ';
+const DEBUG_SYMBOL_MARKER = ' ';
+const DEBUG_TRUNCATION_MARKER = '... ';
+const DEBUG_UNAVAILABLE_MARKER = ' ';
+
+/**
+ * Produces a detached JSON value without reading property values through normal
+ * JavaScript property access. Only own, enumerable data descriptors are copied.
+ */
+export function captureDebuggerPropertiesSnapshot(
+ source: unknown,
+ maximumSerializedBytes: number,
+ limits: DebuggerValueSnapshotLimits,
+): DebuggerValueSnapshotCapture> | undefined {
+ if (typeof source !== 'object' || source === null || !validMaximum(maximumSerializedBytes) || !validLimits(limits)) {
+ return undefined;
+ }
+
+ const budget: DebuggerValueSnapshotBudget = { remainingBytes: maximumSerializedBytes };
+ try {
+ if (Array.isArray(source)) {
+ return undefined;
+ }
+ const activePath = new Set([source]);
+ const value = captureObject(source, 0, activePath, budget, limits);
+ const serialized = JSON.stringify(value);
+ const serializedBytes = utf8ByteLength(serialized);
+ return serializedBytes <= maximumSerializedBytes ? { serializedBytes, value } : undefined;
+ } catch (_error) {
+ // Throwing or revoked Proxy reflection is an expected trust-boundary
+ // outcome. The caller treats undefined as a properties-only omission.
+ return undefined;
+ }
+}
+
+function validMaximum(value: number): boolean {
+ return Number.isSafeInteger(value) && value > 0;
+}
+
+function validLimits(limits: DebuggerValueSnapshotLimits): boolean {
+ return (
+ validMaximum(limits.maximumDepth) &&
+ validMaximum(limits.maximumEntries) &&
+ validMaximum(limits.maximumPropertyNameCharacters) &&
+ validMaximum(limits.maximumStringBytes)
+ );
+}
+
+function captureValue(
+ value: unknown,
+ depth: number,
+ activePath: Set,
+ budget: DebuggerValueSnapshotBudget,
+ limits: DebuggerValueSnapshotLimits,
+): unknown {
+ if (depth >= limits.maximumDepth) {
+ return captureString(DEBUG_TRUNCATION_MARKER, budget, limits.maximumStringBytes);
+ }
+ if (value === undefined) {
+ consumeBudget(budget, 4);
+ return null;
+ }
+ if (value === null || typeof value === 'boolean') {
+ consumeBudget(budget, value === null ? 4 : value ? 4 : 5);
+ return value;
+ }
+ if (typeof value === 'number') {
+ const serialized = JSON.stringify(value) ?? 'null';
+ consumeBudget(budget, serialized.length);
+ return value;
+ }
+ if (typeof value === 'string') {
+ return captureString(value, budget, limits.maximumStringBytes);
+ }
+ if (typeof value === 'function') {
+ return captureString('[function]', budget, limits.maximumStringBytes);
+ }
+ if (typeof value === 'bigint') {
+ return captureString(DEBUG_BIGINT_MARKER, budget, limits.maximumStringBytes);
+ }
+ if (typeof value === 'symbol') {
+ return captureString(DEBUG_SYMBOL_MARKER, budget, limits.maximumStringBytes);
+ }
+ if (typeof value !== 'object') {
+ return captureString(DEBUG_UNAVAILABLE_MARKER, budget, limits.maximumStringBytes);
+ }
+ if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(value)) {
+ return captureString(' ', budget, limits.maximumStringBytes);
+ }
+ if (activePath.has(value)) {
+ return captureString(DEBUG_CIRCULAR_MARKER, budget, limits.maximumStringBytes);
+ }
+
+ activePath.add(value);
+ try {
+ return Array.isArray(value)
+ ? captureArray(value, depth, activePath, budget, limits)
+ : captureObject(value, depth, activePath, budget, limits);
+ } catch (_error) {
+ // A nested throwing Proxy is represented without discarding safe sibling
+ // fields; its enclosing top-level capture remains detached and bounded.
+ return captureString(DEBUG_UNAVAILABLE_MARKER, budget, limits.maximumStringBytes);
+ } finally {
+ activePath.delete(value);
+ }
+}
+
+function captureArray(
+ value: unknown[],
+ depth: number,
+ activePath: Set,
+ budget: DebuggerValueSnapshotBudget,
+ limits: DebuggerValueSnapshotLimits,
+): unknown[] {
+ const output: unknown[] = [];
+ if (!tryConsumeBudget(budget, 2)) {
+ return output;
+ }
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
+ const length =
+ typeof lengthDescriptor?.value === 'number' && Number.isSafeInteger(lengthDescriptor.value)
+ ? Math.max(0, lengthDescriptor.value)
+ : 0;
+ const itemCount = Math.min(length, limits.maximumEntries);
+ let inspectedItemCount = 0;
+ for (; inspectedItemCount < itemCount; inspectedItemCount++) {
+ if (!tryConsumeArrayItemPrefix(output, budget, 2)) {
+ break;
+ }
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(inspectedItemCount));
+ if (
+ descriptor === undefined ||
+ !descriptor.enumerable ||
+ !Object.prototype.hasOwnProperty.call(descriptor, 'value')
+ ) {
+ output.push(captureString(DEBUG_EMPTY_ARRAY_ITEM_MARKER, budget, limits.maximumStringBytes));
+ continue;
+ }
+ output.push(captureValue(descriptor.value, depth + 1, activePath, budget, limits));
+ }
+ if (
+ length > inspectedItemCount &&
+ output.length < limits.maximumEntries &&
+ tryConsumeArrayItemPrefix(output, budget, 2)
+ ) {
+ output.push(captureString(`${length - inspectedItemCount} more items`, budget, limits.maximumStringBytes));
+ }
+ return output;
+}
+
+function captureObject(
+ value: object,
+ depth: number,
+ activePath: Set,
+ budget: DebuggerValueSnapshotBudget,
+ limits: DebuggerValueSnapshotLimits,
+): Record {
+ const output = Object.create(null) as Record;
+ if (!tryConsumeBudget(budget, 2)) {
+ return output;
+ }
+ let inspectedEntryCount = 0;
+ let omitted = false;
+ // JavaScript has no resumable own-key iterator. This may materialize a
+ // Proxy's complete own-key result, but it never traverses the prototype.
+ // Property values are still read only from data descriptors.
+ const propertyNames = Object.getOwnPropertyNames(value);
+ for (const propertyName of propertyNames) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, propertyName);
+ if (descriptor === undefined || !descriptor.enumerable) {
+ continue;
+ }
+ if (inspectedEntryCount >= limits.maximumEntries) {
+ omitted = true;
+ break;
+ }
+ inspectedEntryCount++;
+ if (
+ propertyName.length > limits.maximumPropertyNameCharacters ||
+ !Object.prototype.hasOwnProperty.call(descriptor, 'value')
+ ) {
+ omitted = true;
+ continue;
+ }
+ if (!tryConsumePropertyPrefix(output, propertyName, budget, 2)) {
+ omitted = true;
+ break;
+ }
+ setDataProperty(output, propertyName, captureValue(descriptor.value, depth + 1, activePath, budget, limits));
+ }
+ if (omitted) {
+ addTruncationProperty(output, DEBUG_ACCESSOR_OMISSION_MARKER, budget, limits);
+ }
+ return output;
+}
+
+function addTruncationProperty(
+ target: Record,
+ message: string,
+ budget: DebuggerValueSnapshotBudget,
+ limits: DebuggerValueSnapshotLimits,
+): void {
+ if (
+ Object.keys(target).length >= limits.maximumEntries ||
+ Object.prototype.hasOwnProperty.call(target, '__truncated__') ||
+ !tryConsumePropertyPrefix(target, '__truncated__', budget, 2)
+ ) {
+ return;
+ }
+ setDataProperty(target, '__truncated__', captureString(message, budget, limits.maximumStringBytes));
+}
+
+function setDataProperty(target: Record, propertyName: string, value: unknown): void {
+ Object.defineProperty(target, propertyName, {
+ configurable: true,
+ enumerable: true,
+ value,
+ writable: true,
+ });
+}
+
+function captureString(value: string, budget: DebuggerValueSnapshotBudget, maximumStringBytes: number): string {
+ const availableContentBytes = Math.max(0, budget.remainingBytes - 2);
+ const completePrefix = boundedStringPrefix(value, maximumStringBytes, availableContentBytes);
+ if (completePrefix.end === value.length) {
+ consumeBudget(budget, completePrefix.jsonBytes + 2);
+ return value;
+ }
+
+ const markerRawBytes = utf8ByteLength(DEBUG_TRUNCATION_MARKER);
+ const markerJsonBytes = jsonStringUtf8ByteLength(DEBUG_TRUNCATION_MARKER) - 2;
+ const markerFits = markerRawBytes <= maximumStringBytes && markerJsonBytes <= availableContentBytes;
+ const marker = markerFits ? DEBUG_TRUNCATION_MARKER : '';
+ const truncatedPrefix = boundedStringPrefix(
+ value,
+ maximumStringBytes - (markerFits ? markerRawBytes : 0),
+ availableContentBytes - (markerFits ? markerJsonBytes : 0),
+ );
+ const captured = `${value.slice(0, truncatedPrefix.end)}${marker}`;
+ consumeBudget(budget, truncatedPrefix.jsonBytes + (markerFits ? markerJsonBytes : 0) + 2);
+ return captured;
+}
+
+interface BoundedStringPrefix {
+ readonly end: number;
+ readonly jsonBytes: number;
+}
+
+function boundedStringPrefix(value: string, maximumRawBytes: number, maximumJsonBytes: number): BoundedStringPrefix {
+ let rawBytes = 0;
+ let jsonBytes = 0;
+ let end = 0;
+ while (end < value.length) {
+ const characterCode = value.charCodeAt(end);
+ const nextCharacterCode = value.charCodeAt(end + 1);
+ const isSurrogatePair =
+ characterCode >= 0xd800 && characterCode <= 0xdbff && nextCharacterCode >= 0xdc00 && nextCharacterCode <= 0xdfff;
+ const nextEnd = end + (isSurrogatePair ? 2 : 1);
+ const characterRawBytes = characterCode < 0x80 ? 1 : characterCode < 0x800 ? 2 : isSurrogatePair ? 4 : 3;
+ let characterJsonBytes: number;
+ if (
+ characterCode === 0x22 ||
+ characterCode === 0x5c ||
+ characterCode === 0x08 ||
+ characterCode === 0x09 ||
+ characterCode === 0x0a ||
+ characterCode === 0x0c ||
+ characterCode === 0x0d
+ ) {
+ characterJsonBytes = 2;
+ } else if (characterCode < 0x20 || (!isSurrogatePair && characterCode >= 0xd800 && characterCode <= 0xdfff)) {
+ characterJsonBytes = 6;
+ } else {
+ characterJsonBytes = characterRawBytes;
+ }
+ if (rawBytes + characterRawBytes > maximumRawBytes || jsonBytes + characterJsonBytes > maximumJsonBytes) {
+ break;
+ }
+ rawBytes += characterRawBytes;
+ jsonBytes += characterJsonBytes;
+ end = nextEnd;
+ }
+ return { end, jsonBytes };
+}
+
+function tryConsumePropertyPrefix(
+ target: object,
+ propertyName: string,
+ budget: DebuggerValueSnapshotBudget,
+ minimumValueBytes: number,
+): boolean {
+ const separatorBytes = Object.keys(target).length === 0 ? 0 : 1;
+ const prefixBytes = separatorBytes + jsonStringUtf8ByteLength(propertyName) + 1;
+ if (prefixBytes + minimumValueBytes > budget.remainingBytes) {
+ return false;
+ }
+ consumeBudget(budget, prefixBytes);
+ return true;
+}
+
+function tryConsumeArrayItemPrefix(
+ target: unknown[],
+ budget: DebuggerValueSnapshotBudget,
+ minimumValueBytes: number,
+): boolean {
+ const prefixBytes = target.length === 0 ? 0 : 1;
+ if (prefixBytes + minimumValueBytes > budget.remainingBytes) {
+ return false;
+ }
+ consumeBudget(budget, prefixBytes);
+ return true;
+}
+
+function tryConsumeBudget(budget: DebuggerValueSnapshotBudget, byteCount: number): boolean {
+ if (byteCount > budget.remainingBytes) {
+ return false;
+ }
+ consumeBudget(budget, byteCount);
+ return true;
+}
+
+function consumeBudget(budget: DebuggerValueSnapshotBudget, byteCount: number): void {
+ budget.remainingBytes = Math.max(0, budget.remainingBytes - Math.max(0, byteCount));
+}
+
+function jsonStringUtf8ByteLength(value: string): number {
+ let byteCount = 2;
+ for (let index = 0; index < value.length; index++) {
+ const characterCode = value.charCodeAt(index);
+ if (
+ characterCode === 0x22 ||
+ characterCode === 0x5c ||
+ characterCode === 0x08 ||
+ characterCode === 0x09 ||
+ characterCode === 0x0a ||
+ characterCode === 0x0c ||
+ characterCode === 0x0d
+ ) {
+ byteCount += 2;
+ } else if (characterCode < 0x20) {
+ byteCount += 6;
+ } else if (characterCode < 0x80) {
+ byteCount += 1;
+ } else if (characterCode < 0x800) {
+ byteCount += 2;
+ } else if (characterCode >= 0xd800 && characterCode <= 0xdbff) {
+ const nextCharacterCode = value.charCodeAt(index + 1);
+ if (nextCharacterCode >= 0xdc00 && nextCharacterCode <= 0xdfff) {
+ byteCount += 4;
+ index++;
+ } else {
+ byteCount += 6;
+ }
+ } else if (characterCode >= 0xdc00 && characterCode <= 0xdfff) {
+ byteCount += 6;
+ } else {
+ byteCount += 3;
+ }
+ }
+ return byteCount;
+}
+
+function utf8ByteLength(value: string): number {
+ let byteCount = 0;
+ for (let index = 0; index < value.length; index++) {
+ const characterCode = value.charCodeAt(index);
+ if (characterCode < 0x80) {
+ byteCount += 1;
+ } else if (characterCode < 0x800) {
+ byteCount += 2;
+ } else if (characterCode >= 0xd800 && characterCode <= 0xdbff) {
+ const nextCharacterCode = value.charCodeAt(index + 1);
+ if (nextCharacterCode >= 0xdc00 && nextCharacterCode <= 0xdfff) {
+ byteCount += 4;
+ index++;
+ } else {
+ byteCount += 3;
+ }
+ } else {
+ byteCount += 3;
+ }
+ }
+ return byteCount;
+}
diff --git a/src/valdi_modules/src/valdi/web_renderer/test/DebuggerValueSnapshot.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/DebuggerValueSnapshot.spec.ts
new file mode 100644
index 00000000..311c0ad0
--- /dev/null
+++ b/src/valdi_modules/src/valdi/web_renderer/test/DebuggerValueSnapshot.spec.ts
@@ -0,0 +1,131 @@
+import 'jasmine/src/jasmine';
+import {
+ captureDebuggerPropertiesSnapshot,
+ type DebuggerValueSnapshotLimits,
+} from '../src/debug/DebuggerValueSnapshot';
+
+const LIMITS: DebuggerValueSnapshotLimits = {
+ maximumDepth: 4,
+ maximumEntries: 50,
+ maximumPropertyNameCharacters: 256,
+ maximumStringBytes: 65_536,
+};
+
+describe('DebuggerValueSnapshot', () => {
+ it('captures only own enumerable data descriptors without invoking accessors', () => {
+ let getterCalls = 0;
+ const inherited = { inherited: 'hidden' };
+ const source = Object.create(inherited) as Record;
+ source.visible = { nested: true };
+ source[Symbol('symbol-property')] = 'hidden';
+ Object.defineProperty(source, 'secret', {
+ enumerable: true,
+ get: () => {
+ getterCalls++;
+ return 'must not be read';
+ },
+ });
+
+ const captured = captureDebuggerPropertiesSnapshot(source, 65_536, LIMITS);
+
+ expect(getterCalls).toBe(0);
+ expect(JSON.stringify(captured?.value.visible)).toBe('{"nested":true}');
+ expect(Object.prototype.hasOwnProperty.call(captured?.value, 'secret')).toBeFalse();
+ expect(Object.prototype.hasOwnProperty.call(captured?.value, 'inherited')).toBeFalse();
+ expect(Object.getOwnPropertySymbols(captured?.value ?? {})).toEqual([]);
+ });
+
+ it('bounds keys and entries', () => {
+ const source: Record = {};
+ source['k'.repeat(257)] = 'hidden';
+ for (let index = 0; index < 60; index++) {
+ source[`property-${index}`] = index;
+ }
+ const captured = captureDebuggerPropertiesSnapshot(source, 65_536, LIMITS);
+
+ expect(captured).toBeDefined();
+ expect(Object.keys(captured!.value).length).toBeLessThanOrEqual(50);
+ expect(Object.prototype.hasOwnProperty.call(captured!.value, 'k'.repeat(257))).toBeFalse();
+ });
+
+ it('limits every nested container to 50 entries including truncation markers', () => {
+ const nestedObject: Record = {};
+ for (let index = 0; index < 60; index++) {
+ nestedObject[`property-${index}`] = index;
+ }
+ const captured = captureDebuggerPropertiesSnapshot(
+ { nestedArray: Array.from({ length: 60 }, (_value, index) => index), nestedObject },
+ 65_536,
+ LIMITS,
+ );
+
+ expect(captured).toBeDefined();
+ expect((captured!.value.nestedArray as unknown[]).length).toBeLessThanOrEqual(50);
+ expect(Object.keys(captured!.value.nestedObject as Record).length).toBeLessThanOrEqual(50);
+ });
+
+ it('bounds nested depth, strings, and the complete UTF-8 payload', () => {
+ const captured = captureDebuggerPropertiesSnapshot(
+ {
+ nested: { one: { two: { three: { four: 'hidden' } } } },
+ unicode: '😀'.repeat(65_536),
+ },
+ 65_536,
+ LIMITS,
+ );
+ const serialized = JSON.stringify(captured?.value);
+
+ expect(captured).toBeDefined();
+ expect(captured!.serializedBytes).toBeLessThanOrEqual(65_536);
+ expect(captured!.serializedBytes).toBeGreaterThanOrEqual(serialized.length);
+ expect(JSON.stringify(captured!.value.nested)).toContain('... ');
+ expect(String(captured!.value.unicode)).toContain('... ');
+ });
+
+ it('uses bounded markers for bigint and symbol values', () => {
+ const bigintFactory = Reflect.get(globalThis, 'BigInt') as (value: number) => unknown;
+ const captured = captureDebuggerPropertiesSnapshot(
+ { bigint: bigintFactory(1), symbol: Symbol('description is intentionally not copied') },
+ 65_536,
+ LIMITS,
+ );
+
+ expect(captured?.value.bigint).toBe(' ');
+ expect(captured?.value.symbol).toBe(' ');
+ });
+
+ it('fails closed when a Proxy refuses or revokes descriptor inspection', () => {
+ const source = new Proxy(
+ { visible: 'value' },
+ {
+ ownKeys: () => {
+ throw new Error('unavailable');
+ },
+ },
+ );
+
+ expect(captureDebuggerPropertiesSnapshot(source, 65_536, LIMITS)).toBeUndefined();
+
+ const revocable = Proxy.revocable({ visible: 'value' }, {});
+ revocable.revoke();
+ expect(captureDebuggerPropertiesSnapshot(revocable.proxy, 65_536, LIMITS)).toBeUndefined();
+ });
+
+ it('does not traverse Proxy prototypes while capturing own fields', () => {
+ let prototypeTrapCalls = 0;
+ const source = new Proxy(
+ { visible: 'value' },
+ {
+ getPrototypeOf: () => {
+ prototypeTrapCalls++;
+ throw new Error('prototype traversal is forbidden');
+ },
+ },
+ );
+
+ const captured = captureDebuggerPropertiesSnapshot(source, 65_536, LIMITS);
+
+ expect(prototypeTrapCalls).toBe(0);
+ expect(captured?.value.visible).toBe('value');
+ });
+});
diff --git a/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts
index f02b451f..38495db0 100644
--- a/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts
+++ b/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts
@@ -196,6 +196,7 @@ function makeRenderer(elements: IRenderedElement[]): IRenderer {
interface MutableVirtualNode {
children: MutableVirtualNode[];
component?: IComponent;
+ componentViewModel?: unknown;
element?: IRenderedElement;
key: string;
parent?: MutableVirtualNode;
@@ -235,6 +236,7 @@ function makeHierarchyRenderer(
return {
children: children.slice(),
component: mutableNode.component,
+ componentViewModel: mutableNode.componentViewModel,
element: mutableNode.element,
key: mutableNode.key,
parent: mutableNode.parent as unknown as IRenderedVirtualNode | undefined,
@@ -465,7 +467,7 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
expect(second.tree?.children[0].element?.attributes.value).toBe('second');
});
- it('does not inspect component fields or invoke an instance constructor accessor', () => {
+ it('captures the renderer-owned ViewModel without inspecting component instance accessors', () => {
class SafeComponent {}
const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
@@ -487,7 +489,16 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
throw new Error('Component view models belong to a later debugger layer.');
},
});
+ const viewModel: Record = { visible: 'safe' };
+ Object.defineProperty(viewModel, 'secret', {
+ enumerable: true,
+ get: () => {
+ getterCalls++;
+ throw new Error('ViewModel accessors must not be invoked.');
+ },
+ });
const component = makeVirtualNode('safe', { component: componentInstance });
+ component.componentViewModel = viewModel;
const elementNode = makeVirtualNode('layout', { element });
setVirtualChildren(component, [elementNode]);
@@ -498,6 +509,143 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
expect(getterCalls).toBe(0);
expect(snapshot.tree?.component?.name).toBe('SafeComponent');
+ expect(snapshot.tree?.component?.properties?.visible).toBe('safe');
+ expect(Object.prototype.hasOwnProperty.call(snapshot.tree?.component?.properties ?? {}, 'secret')).toBeFalse();
+ });
+
+ it('shares the component property budget without discarding over-budget component hierarchy', () => {
+ class ParentBudgetComponent {}
+ class ChildBudgetComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementBecameRoot(1);
+ const element = makeRenderedElement(1, 'layout', {});
+ const parentComponent = makeVirtualNode('parent-budget', {
+ component: new ParentBudgetComponent() as unknown as IComponent,
+ });
+ parentComponent.componentViewModel = { payload: 'p'.repeat(40_000) };
+ const childComponent = makeVirtualNode('child-budget', {
+ component: new ChildBudgetComponent() as unknown as IComponent,
+ });
+ childComponent.componentViewModel = { payload: 'c'.repeat(40_000) };
+ const elementNode = makeVirtualNode('layout', { element });
+ setVirtualChildren(childComponent, [elementNode]);
+ setVirtualChildren(parentComponent, [childComponent]);
+
+ const snapshot = delegate.getDebugSnapshot(
+ makeHierarchyRenderer([element], parentComponent),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+ const childSnapshot = snapshot.tree?.children[0];
+
+ expect(snapshot.tree?.component?.name).toBe('ParentBudgetComponent');
+ expect(snapshot.tree?.component?.properties).toBeUndefined();
+ expect(childSnapshot?.component?.name).toBe('ChildBudgetComponent');
+ expect(String(childSnapshot?.component?.properties?.payload).length).toBe(40_000);
+ expect(JSON.stringify(childSnapshot?.component?.properties).length).toBeLessThanOrEqual(65_536);
+ expect(childSnapshot?.children[0].id).toBe('1');
+ });
+
+ it('drops stale properties but keeps hierarchy when the ViewModel identity changes during capture', () => {
+ class ReplacedViewModelComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementBecameRoot(1);
+ const element = makeRenderedElement(1, 'layout', {});
+ const component = makeVirtualNode('replaced-view-model', {
+ component: new ReplacedViewModelComponent() as unknown as IComponent,
+ });
+ const elementNode = makeVirtualNode('layout', { element });
+ setVirtualChildren(component, [elementNode]);
+ const baseRenderer = makeHierarchyRenderer([element], component);
+ const readSnapshot = baseRenderer.getDebugVirtualNodeSnapshot!;
+ const initialViewModel = { value: 'stale' };
+ const replacementViewModel = { value: 'fresh' };
+ let componentSnapshotReads = 0;
+ const renderer = Object.assign(baseRenderer, {
+ getDebugVirtualNodeSnapshot: (
+ node: IRenderedVirtualNode,
+ maximumChildLinks: number,
+ maximumTraversalLinks: number,
+ ) => {
+ const snapshot = readSnapshot.call(baseRenderer, node, maximumChildLinks, maximumTraversalLinks);
+ if (snapshot === undefined || node !== (component as unknown as IRenderedVirtualNode)) {
+ return snapshot;
+ }
+ componentSnapshotReads++;
+ return {
+ ...snapshot,
+ componentViewModel: componentSnapshotReads === 1 ? initialViewModel : replacementViewModel,
+ };
+ },
+ });
+
+ const snapshot = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS);
+
+ expect(componentSnapshotReads).toBe(2);
+ expect(snapshot.tree?.component?.name).toBe('ReplacedViewModelComponent');
+ expect(snapshot.tree?.component?.properties).toBeUndefined();
+ expect(snapshot.tree?.children[0].id).toBe('1');
+ });
+
+ it('strips component properties before the complete hierarchy envelope overflows', () => {
+ class PropertyHeavyComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementBecameRoot(1);
+ const element = makeRenderedElement(1, 'layout', {
+ first: 'a'.repeat(50_000),
+ fourth: 'd'.repeat(50_000),
+ second: 'b'.repeat(50_000),
+ third: 'c'.repeat(50_000),
+ });
+ const component = makeVirtualNode('property-heavy', {
+ component: new PropertyHeavyComponent() as unknown as IComponent,
+ });
+ component.componentViewModel = { payload: 'x'.repeat(65_536) };
+ const elementNode = makeVirtualNode('layout', { element });
+ setVirtualChildren(component, [elementNode]);
+
+ const snapshot = delegate.getDebugSnapshot(
+ makeHierarchyRenderer([element], component),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+
+ expect(snapshot.tree?.id).toBe('component:[null,"property-heavy"]');
+ expect(snapshot.tree?.component?.name).toBe('PropertyHeavyComponent');
+ expect(snapshot.tree?.component?.properties).toBeUndefined();
+ expect(snapshot.tree?.children[0].id).toBe('1');
+ expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS);
+ });
+
+ it('omits properties from a revoked Proxy without discarding the hierarchy', () => {
+ class ProxyBackedComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementBecameRoot(1);
+ const element = makeRenderedElement(1, 'layout', {});
+ const component = makeVirtualNode('proxy-backed', {
+ component: new ProxyBackedComponent() as unknown as IComponent,
+ });
+ const revocableViewModel = Proxy.revocable({ visible: 'value' }, {});
+ component.componentViewModel = revocableViewModel.proxy;
+ revocableViewModel.revoke();
+ const elementNode = makeVirtualNode('layout', { element });
+ setVirtualChildren(component, [elementNode]);
+
+ const snapshot = delegate.getDebugSnapshot(
+ makeHierarchyRenderer([element], component),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+
+ expect(snapshot.tree?.id).toBe('component:[null,"proxy-backed"]');
+ expect(snapshot.tree?.component?.name).toBe('ProxyBackedComponent');
+ expect(snapshot.tree?.component?.properties).toBeUndefined();
+ expect(snapshot.tree?.children[0].id).toBe('1');
});
it('falls back atomically for shared, cyclic, partial, and over-deep virtual trees', () => {