<${escapeHtml(node.tag || 'view')}
${attribute ? `
${escapeHtml(attribute[0])}=
"${escapeHtml(attribute[1])}"` : ''}
>
@@ -460,15 +489,24 @@ function renderInspector() {
return;
}
+ const renderedNode = inspectedNode(node);
+ if (node.component && renderedNode === node) {
+ elements.inspector.innerHTML = `${propertyRows(node.component, { css: false })}
This component does not currently render a backing element.
`;
+ return;
+ }
+
if (state.activeDetail === 'styles') {
- elements.inspector.innerHTML = renderStyles(node);
+ elements.inspector.innerHTML = renderStyles(renderedNode);
} else if (state.activeDetail === 'computed') {
- elements.inspector.innerHTML = renderComputed(node);
+ elements.inspector.innerHTML = renderComputed(renderedNode);
} else {
- const textContent = node.element?.dom?.textContent
- ? valdiDebuggerTreeModel.formatValue(node.element.dom.textContent, 0)
+ const textContent = renderedNode.element?.dom?.textContent
+ ? valdiDebuggerTreeModel.formatValue(renderedNode.element.dom.textContent, 0)
: '';
- elements.inspector.innerHTML = `${propertyRows(node.element?.dom?.attributes, { css: false })}${textContent ? `
${escapeHtml(textContent)}` : ''}`;
+ const componentDetails = node.component
+ ? `${propertyRows(node.component, { css: false })}`
+ : '';
+ elements.inspector.innerHTML = `${componentDetails}${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `
${escapeHtml(textContent)}` : ''}`;
}
}
@@ -550,24 +588,62 @@ function handleTreeNavigation(event) {
scrollSelectedTreeRowIntoView();
}
-function queueHighlight(nodeIdValue) {
- if (!state.target || state.hoveredNodeId === nodeIdValue) return;
- state.hoveredNodeId = nodeIdValue;
- if (state.highlightTimer) window.clearTimeout(state.highlightTimer);
- state.highlightTimer = window.setTimeout(
- () => {
- void requestJson(
+function enqueueHighlightRequest(intentGeneration, target, targetSessionId, snapshotGeneration, nodeIdValue) {
+ const request = state.highlightRequestTail.then(async () => {
+ if (
+ intentGeneration !== state.highlightIntentGeneration ||
+ state.target !== target ||
+ target.sessionId !== targetSessionId ||
+ state.snapshotGeneration !== snapshotGeneration
+ ) {
+ return;
+ }
+ try {
+ await requestJson(
'/api/devtools/highlight',
{},
{
body: {
inspectedUrl,
- sessionId: state.target.sessionId,
+ sessionId: targetSessionId,
targetNonce: inspectedTargetNonce,
...(nodeIdValue ? { nodeId: nodeIdValue } : {}),
},
},
- ).catch(error => console.warn('Unable to update the inspected Valdi highlight.', error));
+ );
+ if (nodeIdValue === null && intentGeneration === state.highlightIntentGeneration) {
+ state.highlightMayBeActive = false;
+ }
+ } catch (error) {
+ console.warn('Unable to update the inspected Valdi highlight.', error);
+ }
+ });
+ state.highlightRequestTail = request.catch(error => {
+ console.warn('Unable to order the inspected Valdi highlight request.', error);
+ });
+}
+
+function queueHighlight(nodeIdValue) {
+ if (
+ !state.target ||
+ (state.hoveredNodeId === nodeIdValue &&
+ state.hoveredSnapshotGeneration === state.snapshotGeneration &&
+ !(nodeIdValue === null && state.highlightMayBeActive))
+ )
+ return;
+ const target = state.target;
+ const targetSessionId = target.sessionId;
+ const snapshotGeneration = state.snapshotGeneration;
+ const intentGeneration = ++state.highlightIntentGeneration;
+ state.hoveredNodeId = nodeIdValue;
+ state.hoveredSnapshotGeneration = snapshotGeneration;
+ if (state.highlightTimer) window.clearTimeout(state.highlightTimer);
+ state.highlightTimer = window.setTimeout(
+ () => {
+ state.highlightTimer = null;
+ if (state.target !== target || state.snapshotGeneration !== snapshotGeneration) return;
+ if (nodeIdValue !== null) state.highlightMayBeActive = true;
+ enqueueHighlightRequest(intentGeneration, target, targetSessionId, snapshotGeneration, nodeIdValue);
},
nodeIdValue ? 80 : 20,
);
@@ -787,7 +863,7 @@ function wireEvents() {
});
elements.tree.addEventListener('pointerover', event => {
const row = event.target.closest('[data-node-id]');
- if (row) queueHighlight(row.dataset.nodeId);
+ if (row) queueHighlight(inspectedNodeId(findNode(row.dataset.nodeId)));
});
elements.tree.addEventListener('pointerleave', () => queueHighlight(null));
elements.breadcrumbs.addEventListener('click', event => {
diff --git a/npm_modules/cli/src/core/packageFiles.spec.ts b/npm_modules/cli/src/core/packageFiles.spec.ts
index a3589d88..99c44412 100644
--- a/npm_modules/cli/src/core/packageFiles.spec.ts
+++ b/npm_modules/cli/src/core/packageFiles.spec.ts
@@ -92,6 +92,31 @@ describe('npm package contents', () => {
}
expect(orderedBundle).toContain('dispatchDebuggerInput');
expect(orderedBundle).toContain("apiPost('/api/input'");
+
+ const devToolsPanelAssets = [
+ 'devtools-panel.css',
+ 'devtools-panel.html',
+ 'devtools-panel.js',
+ 'debugger-tree-model.js',
+ ];
+ for (const assetPath of devToolsPanelAssets) {
+ expect(packedFiles).toContain(`debugger/${assetPath}`);
+ }
+ const devToolsPanelHtml = fs.readFileSync(path.join(debuggerRoot, 'devtools-panel.html'), 'utf8');
+ const treeModelScriptIndex = devToolsPanelHtml.indexOf('debugger-tree-model.js');
+ const panelScriptIndex = devToolsPanelHtml.indexOf('devtools-panel.js');
+ expect(treeModelScriptIndex).toBeGreaterThan(-1);
+ expect(panelScriptIndex).toBeGreaterThan(treeModelScriptIndex);
+ expect(
+ () =>
+ new vm.Script(
+ [
+ fs.readFileSync(path.join(debuggerRoot, 'debugger-tree-model.js'), 'utf8'),
+ fs.readFileSync(path.join(debuggerRoot, 'devtools-panel.js'), 'utf8'),
+ ].join('\n'),
+ { filename: 'valdi-devtools-panel.js' },
+ ),
+ ).not.toThrow();
});
it('does not let projected trees auto-load remote media', () => {
diff --git a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
index 0fbf581d..8fc91933 100644
--- a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
+++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts
@@ -3,6 +3,57 @@ import fs from 'node:fs';
import path from 'node:path';
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 };
+ element?: {
+ attributes: Record
;
+ dom: { attributes: Record; tagName: string; textContent?: string };
+ id: number;
+ };
+ id: string;
+ tag: string;
+}
+
+interface TreeStubElement {
+ checked: boolean;
+ className: string;
+ innerHTML: string;
+ scrollTop: number;
+ textContent: string;
+ value: string;
+ addEventListener(): void;
+ contains(): boolean;
+ focus(): void;
+ removeAttribute(): void;
+ setAttribute(): void;
+}
+
+interface DevToolsHierarchyPanel {
+ inspectorContent: TreeStubElement;
+ state: {
+ activeDetail: string;
+ expandedNodeIds: Set;
+ highlightMayBeActive: boolean;
+ highlightRequestTail: Promise;
+ highlightTimer: number | null;
+ search: string;
+ selectedNodeId: string | null;
+ snapshot: { tree: DevToolsTreeNode } | null;
+ snapshotGeneration: number;
+ target: { id: string; sessionId: string } | null;
+ };
+ treeContent: TreeStubElement;
+ findNode(id: string): DevToolsTreeNode | null;
+ inspectedNodeId(node: DevToolsTreeNode | null): string | null;
+ queueHighlight(nodeId: string | null): void;
+ refreshSnapshot(): Promise;
+ renderInspector(): void;
+ renderTree(): void;
+ selectNode(id: string): void;
+}
+
interface StubElement {
checked: boolean;
className: string;
@@ -37,6 +88,316 @@ interface DevToolsConsolePanel {
dispatchWindowEvent(type: string): void;
}
+function componentTree(): DevToolsTreeNode {
+ return {
+ bounds: { height: 80, width: 220, x: 4, y: 8 },
+ children: [
+ {
+ bounds: { height: 80, width: 220, x: 4, y: 8 },
+ children: [
+ {
+ bounds: { height: 20, width: 140, x: 12, y: 24 },
+ children: [
+ {
+ bounds: { height: 20, width: 140, x: 12, y: 24 },
+ children: [],
+ element: {
+ attributes: { accessibilityLabel: 'Continue' },
+ dom: { attributes: { role: 'button' }, tagName: 'span', textContent: 'Continue' },
+ id: 8,
+ },
+ id: '8',
+ tag: 'label',
+ },
+ ],
+ component: { elementId: '8', key: 'nested', name: 'NestedExampleComponent' },
+ id: 'component:["7","nested"]',
+ tag: 'NestedExampleComponent',
+ },
+ ],
+ element: {
+ attributes: { accessibilityId: 'sample.root' },
+ dom: { attributes: { id: 'sample.root' }, tagName: 'div' },
+ id: 7,
+ },
+ id: '7',
+ tag: 'view',
+ },
+ ],
+ component: { elementId: '7', key: 'root', name: 'RootExampleComponent' },
+ id: 'component:[null,"root"]',
+ tag: 'RootExampleComponent',
+ };
+}
+
+describe('integrated DevTools component hierarchy', () => {
+ let fetchRequests: Array<{ body?: string; url: string }>;
+ let fetchResponse: Record;
+ let queuedFetchResponses: Array> }>>;
+ let panel: DevToolsHierarchyPanel;
+ let timers: Map void>;
+
+ beforeEach(() => {
+ fetchRequests = [];
+ fetchResponse = { highlighted: true };
+ queuedFetchResponses = [];
+ timers = new Map();
+ let nextTimerId = 1;
+ const treeModelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'debugger-tree-model.js'), 'utf8');
+ const rawPanelSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.js'), 'utf8');
+ const panelSource = rawPanelSource.replace('void connectToInspectedApplication();', 'void 0;');
+ const elements = new Map();
+ const document = {
+ addEventListener() {},
+ documentElement: { dataset: {} },
+ getElementById(id: string): TreeStubElement {
+ let element = elements.get(id);
+ if (element === undefined) {
+ element = {
+ checked: true,
+ className: '',
+ innerHTML: '',
+ scrollTop: 0,
+ textContent: '',
+ value: '',
+ addEventListener() {},
+ contains: () => false,
+ focus() {},
+ removeAttribute() {},
+ setAttribute() {},
+ };
+ elements.set(id, element);
+ }
+ return element;
+ },
+ querySelectorAll(): TreeStubElement[] {
+ return [];
+ },
+ };
+ const window = {
+ addEventListener() {},
+ clearInterval() {},
+ clearTimeout(timerId: number) {
+ timers.delete(timerId);
+ },
+ location: {
+ origin: 'http://127.0.0.1:18768',
+ search:
+ '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456',
+ },
+ parent: {},
+ setInterval: () => 1,
+ setTimeout(callback: () => void): number {
+ const timerId = nextTimerId++;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ };
+
+ panel = new Script(
+ `${treeModelSource}\n${panelSource}\n({ findNode, inspectedNodeId, inspectorContent: elements.inspector, queueHighlight, refreshSnapshot, renderInspector, renderTree, selectNode, state, treeContent: elements.tree })`,
+ ).runInNewContext({
+ URL,
+ URLSearchParams,
+ console,
+ document,
+ fetch: (url: URL, options: { body?: string }) => {
+ fetchRequests.push({ ...(options.body === undefined ? {} : { body: options.body }), url: url.toString() });
+ return (
+ queuedFetchResponses.shift() ?? Promise.resolve({ json: () => Promise.resolve(fetchResponse), ok: true })
+ );
+ },
+ navigator: { clipboard: { writeText: () => Promise.resolve() } },
+ window,
+ }) as DevToolsHierarchyPanel;
+ panel.state.target = { id: 'owl:web-preview', sessionId: 'web-preview' };
+ panel.state.snapshot = { tree: componentTree() };
+ panel.state.snapshotGeneration = 1;
+ });
+
+ it('renders and searches component rows without hiding their physical descendants', () => {
+ panel.state.expandedNodeIds.add('component:[null,"root"]');
+ panel.state.expandedNodeIds.add('7');
+ panel.state.expandedNodeIds.add('component:["7","nested"]');
+ panel.state.selectedNodeId = '8';
+
+ panel.renderTree();
+
+ expect(panel.treeContent.innerHTML).toContain('class="tree-row component-row"');
+ expect(panel.treeContent.innerHTML).toContain('RootExampleComponent');
+ expect(panel.treeContent.innerHTML).toContain('NestedExampleComponent');
+ expect(panel.treeContent.innerHTML).toContain('data-node-id="7"');
+ expect(panel.treeContent.innerHTML).toContain('data-node-id="8"');
+ expect(panel.treeContent.innerHTML).toContain('Continue');
+
+ panel.state.search = 'nestedexample';
+ panel.renderTree();
+ expect(panel.treeContent.innerHTML).toContain('RootExampleComponent');
+ expect(panel.treeContent.innerHTML).toContain('data-node-id="7"');
+ expect(panel.treeContent.innerHTML).toContain('NestedExampleComponent');
+ expect(panel.treeContent.innerHTML).not.toContain('data-node-id="8"');
+ });
+
+ it('keeps component selection while inspecting the current backing element', () => {
+ panel.state.activeDetail = 'dom';
+
+ panel.selectNode('component:["7","nested"]');
+
+ expect(panel.state.selectedNodeId).toBe('component:["7","nested"]');
+ expect(panel.inspectorContent.innerHTML).toContain('Valdi component');
+ expect(panel.inspectorContent.innerHTML).toContain('NestedExampleComponent');
+ expect(panel.inspectorContent.innerHTML).toContain('elementId');
+ expect(panel.inspectorContent.innerHTML).toContain('Rendered <span>');
+ expect(panel.inspectorContent.innerHTML).toContain('Text content');
+ });
+
+ it('preserves keyed component selection across updates and safely falls back when it disappears', async () => {
+ panel.selectNode('component:["7","nested"]');
+ fetchResponse = { tree: componentTree() };
+
+ await panel.refreshSnapshot();
+
+ expect(panel.state.selectedNodeId).toBe('component:["7","nested"]');
+ expect(panel.state.expandedNodeIds.has('component:[null,"root"]')).toBeTrue();
+ expect(panel.state.expandedNodeIds.has('7')).toBeTrue();
+
+ const elementRoot = componentTree().children[0];
+ elementRoot.children = [elementRoot.children[0].children[0]];
+ fetchResponse = { tree: elementRoot };
+ await panel.refreshSnapshot();
+
+ expect(panel.state.selectedNodeId).toBe('7');
+ expect(panel.findNode('component:["7","nested"]')).toBeNull();
+ });
+
+ it('maps highlights to backing elements and drops timers from stale snapshot generations', async () => {
+ const nestedComponent = panel.findNode('component:["7","nested"]');
+ panel.queueHighlight(panel.inspectedNodeId(nestedComponent));
+ const currentTimerId = panel.state.highlightTimer;
+ if (currentTimerId === null) throw new Error('Expected a current-generation highlight timer id.');
+ const currentTimer = timers.get(currentTimerId);
+ if (currentTimer === undefined) throw new Error('Expected a current-generation highlight timer.');
+ currentTimer();
+ await Promise.resolve();
+
+ expect(fetchRequests.length).toBe(1);
+ const highlightRequest = fetchRequests[0];
+ if (highlightRequest === undefined || highlightRequest.body === undefined) {
+ throw new Error('Expected a serialized highlight request.');
+ }
+ expect(highlightRequest.url).toBe('http://127.0.0.1:18768/api/devtools/highlight');
+ expect(JSON.parse(highlightRequest.body)).toEqual({
+ inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ nodeId: '8',
+ sessionId: 'web-preview',
+ targetNonce: 'panel-target-nonce-123456',
+ });
+
+ panel.queueHighlight('7');
+ const staleTimerId = panel.state.highlightTimer;
+ if (staleTimerId === null) throw new Error('Expected a queued highlight timer id.');
+ const staleTimer = timers.get(staleTimerId);
+ if (staleTimer === undefined) throw new Error('Expected a queued highlight timer.');
+ panel.state.snapshotGeneration++;
+ staleTimer();
+ await Promise.resolve();
+
+ expect(fetchRequests.length).toBe(1);
+ });
+
+ it('orders a final clear after an older in-flight highlight request', async () => {
+ let resolveHighlight: ((response: { ok: boolean; json(): Promise> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveHighlight = resolve;
+ }),
+ );
+
+ panel.queueHighlight('8');
+ const highlightTimerId = panel.state.highlightTimer;
+ if (highlightTimerId === null) throw new Error('Expected a highlight timer id.');
+ const highlightTimer = timers.get(highlightTimerId);
+ if (highlightTimer === undefined) throw new Error('Expected a highlight timer.');
+ highlightTimer();
+ await Promise.resolve();
+
+ panel.queueHighlight(null);
+ const clearTimerId = panel.state.highlightTimer;
+ if (clearTimerId === null) throw new Error('Expected a clear timer id.');
+ const clearTimer = timers.get(clearTimerId);
+ if (clearTimer === undefined) throw new Error('Expected a clear timer.');
+ clearTimer();
+ await Promise.resolve();
+
+ expect(fetchRequests.length).toBe(1);
+ expect(panel.state.highlightMayBeActive).toBeTrue();
+ if (resolveHighlight === undefined) throw new Error('Expected a deferred highlight response.');
+ resolveHighlight({ json: () => Promise.resolve({ highlighted: true }), ok: true });
+ await panel.state.highlightRequestTail;
+
+ expect(fetchRequests.length).toBe(2);
+ const clearRequest = fetchRequests[1];
+ if (clearRequest === undefined || clearRequest.body === undefined) {
+ throw new Error('Expected a serialized clear request.');
+ }
+ expect(JSON.parse(clearRequest.body)).toEqual({
+ inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1',
+ sessionId: 'web-preview',
+ targetNonce: 'panel-target-nonce-123456',
+ });
+ expect(panel.state.highlightMayBeActive).toBeFalse();
+ });
+
+ it('requeues the final clear when a snapshot refresh supersedes a pending clear', async () => {
+ let resolveHighlight: ((response: { ok: boolean; json(): Promise> }) => void) | undefined;
+ queuedFetchResponses.push(
+ new Promise(resolve => {
+ resolveHighlight = resolve;
+ }),
+ );
+
+ panel.queueHighlight('8');
+ const highlightTimerId = panel.state.highlightTimer;
+ if (highlightTimerId === null) throw new Error('Expected a highlight timer id.');
+ const highlightTimer = timers.get(highlightTimerId);
+ if (highlightTimer === undefined) throw new Error('Expected a highlight timer.');
+ highlightTimer();
+ await Promise.resolve();
+
+ fetchResponse = { tree: componentTree() };
+ await panel.refreshSnapshot();
+ const firstClearTimerId = panel.state.highlightTimer;
+ if (firstClearTimerId === null) throw new Error('Expected the first refresh clear timer.');
+ const firstClearTimer = timers.get(firstClearTimerId);
+ if (firstClearTimer === undefined) throw new Error('Expected the first refresh clear timer callback.');
+ firstClearTimer();
+
+ await panel.refreshSnapshot();
+ const finalClearTimerId = panel.state.highlightTimer;
+ if (finalClearTimerId === null) throw new Error('Expected the final refresh clear timer.');
+ const finalClearTimer = timers.get(finalClearTimerId);
+ if (finalClearTimer === undefined) throw new Error('Expected the final refresh clear timer callback.');
+ finalClearTimer();
+ await Promise.resolve();
+
+ const highlightRequestsBeforeResolution = fetchRequests.filter(request =>
+ request.url.endsWith('/api/devtools/highlight'),
+ );
+ expect(highlightRequestsBeforeResolution.length).toBe(1);
+ if (resolveHighlight === undefined) throw new Error('Expected a deferred highlight response.');
+ resolveHighlight({ json: () => Promise.resolve({ highlighted: true }), ok: true });
+ await panel.state.highlightRequestTail;
+
+ const highlightRequests = fetchRequests.filter(request => request.url.endsWith('/api/devtools/highlight'));
+ expect(highlightRequests.length).toBe(2);
+ const finalRequestBody = highlightRequests[1]?.body;
+ if (finalRequestBody === undefined) throw new Error('Expected a serialized final clear request.');
+ const finalRequest = JSON.parse(finalRequestBody) as Record;
+ expect(finalRequest.nodeId).toBeUndefined();
+ expect(panel.state.highlightMayBeActive).toBeFalse();
+ });
+});
+
describe('integrated DevTools console panel', () => {
let eventSources: MockConsoleEventSource[];
let panel: DevToolsConsolePanel;
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 abd1139a..48a021d4 100644
--- a/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts
+++ b/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts
@@ -18,6 +18,20 @@ interface Unsubscribable {
export type ComponentDisposable = (() => void) | Unsubscribable;
+/**
+ * A bounded, immutable view of one live virtual node for debugger consumers.
+ * `traversedLinkCount` includes both flattened slot-child links and parent
+ * links inspected while producing the snapshot.
+ */
+export interface RendererDebugVirtualNodeSnapshot {
+ readonly children: readonly IRenderedVirtualNode[];
+ readonly component: IComponent | undefined;
+ readonly element: IRenderedElement | undefined;
+ readonly key: string;
+ readonly parent: IRenderedVirtualNode | undefined;
+ readonly traversedLinkCount: number;
+}
+
export interface IRenderer {
contextId: string;
renderComponent(component: IComponent, properties: any | undefined): void;
@@ -31,6 +45,11 @@ export interface IRenderer {
getComponentVirtualNode(component: IComponent): IRenderedVirtualNode;
getElementForId(elementId: number): IRenderedElement | undefined;
getRootVirtualNode(): IRenderedVirtualNode | undefined;
+ getDebugVirtualNodeSnapshot?(
+ node: IRenderedVirtualNode,
+ maximumChildLinks: number,
+ maximumTraversalLinks: number,
+ ): RendererDebugVirtualNodeSnapshot | undefined;
/**
* Registers a function which will be called right after the component is destroyed.
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 ac848eee..6f43a6c3 100644
--- a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts
+++ b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts
@@ -17,7 +17,7 @@ import { ConsoleRepresentable } from './ConsoleRepresentable';
import { ComponentConstructor, IComponent } from './IComponent';
import { IRenderedElement } from './IRenderedElement';
import { IRenderedVirtualNode } from './IRenderedVirtualNode';
-import { ComponentDisposable, IRenderer, RendererObserver } from './IRenderer';
+import { ComponentDisposable, IRenderer, RendererDebugVirtualNodeSnapshot, RendererObserver } from './IRenderer';
import { IRendererDelegate } from './IRendererDelegate';
import { IRendererEventListener } from './IRendererEventListener';
import { NodePrototype } from './NodePrototype';
@@ -251,6 +251,87 @@ class VirtualNodeBridge implements IRenderedVirtualNode {
return out;
}
+ getDebugSnapshot(
+ maximumChildLinks: number,
+ maximumTraversalLinks: number,
+ ): RendererDebugVirtualNodeSnapshot | undefined {
+ if (
+ !Number.isSafeInteger(maximumChildLinks) ||
+ maximumChildLinks < 0 ||
+ !Number.isSafeInteger(maximumTraversalLinks) ||
+ maximumTraversalLinks <= 0
+ ) {
+ return undefined;
+ }
+
+ let traversedLinkCount = 0;
+ let parent = this.node.parent;
+ const visitedParentSlots = new Set();
+ while (parent?.slot === true) {
+ if (visitedParentSlots.has(parent)) {
+ return undefined;
+ }
+ visitedParentSlots.add(parent);
+ traversedLinkCount++;
+ if (traversedLinkCount > maximumTraversalLinks) {
+ return undefined;
+ }
+ parent = parent.parent;
+ }
+ if (parent !== undefined) {
+ traversedLinkCount++;
+ if (traversedLinkCount > maximumTraversalLinks) {
+ return undefined;
+ }
+ }
+
+ const children: IRenderedVirtualNode[] = [];
+ const visitedSlots = new Set();
+ const childFrames: Array<{ children: VirtualNode[]; index: number }> = [];
+ if (this.node.children !== undefined) {
+ childFrames.push({ children: this.node.children.children, index: 0 });
+ }
+ while (childFrames.length > 0) {
+ const frame = childFrames[childFrames.length - 1];
+ if (frame.index >= frame.children.length) {
+ childFrames.pop();
+ continue;
+ }
+ const child = frame.children[frame.index];
+ frame.index++;
+ traversedLinkCount++;
+ if (traversedLinkCount > maximumTraversalLinks) {
+ return undefined;
+ }
+ if (child.slot === true) {
+ if (visitedSlots.has(child)) {
+ return undefined;
+ }
+ visitedSlots.add(child);
+ if (child.children !== undefined) {
+ childFrames.push({ children: child.children.children, index: 0 });
+ }
+ } else {
+ if (children.length >= maximumChildLinks) {
+ return undefined;
+ }
+ children.push(getVirtualNodeBridge(this.renderer, child));
+ }
+ }
+
+ return {
+ children,
+ component: this.node.component?.instance,
+ element: this.node.element === undefined ? undefined : getRenderedElementBridge(this.renderer, this.node.element),
+ key: this.node.key,
+ parent:
+ parent === undefined || parent === this.renderer.nodeTree
+ ? undefined
+ : getVirtualNodeBridge(this.renderer, parent),
+ traversedLinkCount,
+ };
+ }
+
get parentIndex(): number {
return this.node.parentIndex;
}
@@ -2663,6 +2744,17 @@ export class Renderer implements IRenderer {
return undefined;
}
+ getDebugVirtualNodeSnapshot(
+ node: IRenderedVirtualNode,
+ maximumChildLinks: number,
+ maximumTraversalLinks: number,
+ ): RendererDebugVirtualNodeSnapshot | undefined {
+ if (!(node instanceof VirtualNodeBridge) || node.renderer !== this) {
+ return undefined;
+ }
+ return node.getDebugSnapshot(maximumChildLinks, maximumTraversalLinks);
+ }
+
getComponentVirtualNode(component: IComponent): IRenderedVirtualNode {
const renderedComponent = this.resolveRenderedComponent(component);
const virtualNodeBridge = getVirtualNodeBridge(this, renderedComponent.virtualNode);
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 cd8ee352..24714673 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
@@ -2319,6 +2319,51 @@ describe('Renderer', () => {
],
},
]);
+
+ const rootVirtualNode = renderer.getRootVirtualNode()!;
+ const rootDebugSnapshot = renderer.getDebugVirtualNodeSnapshot(rootVirtualNode, 10, 20)!;
+ const componentVirtualNode = rootDebugSnapshot.children[0];
+ const componentDebugSnapshot = renderer.getDebugVirtualNodeSnapshot(componentVirtualNode, 10, 20)!;
+ const headerContainerVirtualNode = componentDebugSnapshot.children[0];
+ const headerContainerDebugSnapshot = renderer.getDebugVirtualNodeSnapshot(headerContainerVirtualNode, 10, 20)!;
+
+ expect(headerContainerDebugSnapshot.children.map(child => child.element?.tag)).toEqual(['header']);
+ expect(renderer.getDebugVirtualNodeSnapshot(headerContainerVirtualNode, 10, 2)).toBeUndefined();
+ });
+
+ it('bounds wide, deep, and cyclic internal slot traversal', () => {
+ interface DebugRawVirtualNode {
+ children?: { children: DebugRawVirtualNode[] };
+ key: string;
+ slot?: boolean;
+ }
+
+ const output = new RendererTestDelegate();
+ const renderer = makeRenderer(output);
+ const root = makeNodeProtoype('root');
+ renderer.begin();
+ renderer.beginElement(root);
+ renderer.endElement();
+ renderer.end();
+
+ const rootVirtualNode = renderer.getRootVirtualNode()!;
+ const rawRoot = (rootVirtualNode as unknown as { node: DebugRawVirtualNode }).node;
+ const leaf: DebugRawVirtualNode = { key: 'leaf' };
+
+ rawRoot.children = { children: new Array(10_000).fill(leaf) };
+ expect(renderer.getDebugVirtualNodeSnapshot(rootVirtualNode, 10, 100)).toBeUndefined();
+
+ let nestedSlotChild = leaf;
+ for (let depth = 0; depth < 10_000; depth++) {
+ nestedSlotChild = { children: { children: [nestedSlotChild] }, key: `slot-${depth}`, slot: true };
+ }
+ rawRoot.children = { children: [nestedSlotChild] };
+ expect(renderer.getDebugVirtualNodeSnapshot(rootVirtualNode, 10, 100)).toBeUndefined();
+
+ const cyclicSlot: DebugRawVirtualNode = { key: 'cycle', slot: true };
+ cyclicSlot.children = { children: [cyclicSlot] };
+ rawRoot.children = { children: [cyclicSlot] };
+ expect(renderer.getDebugVirtualNodeSnapshot(rootVirtualNode, 10, 100)).toBeUndefined();
});
it('can avoid re-render slotted components', () => {
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 c9c33abb..f9895cd5 100644
--- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts
+++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts
@@ -15,16 +15,30 @@ import {
registerElements,
setAllElementsAttributeDelegate,
} from './HTMLRenderer';
+import { captureComponentHierarchySnapshot } from './debug/ComponentHierarchySnapshot';
import type { WebValdiLayout } from './views/WebValdiLayout';
export interface UpdateAttributeDelegate {
updateAttribute(elementId: number, attributeName: string, attributeValue: any): void;
}
-export interface WebRendererDebugElementSnapshot {
+export interface WebRendererDebugNodeSnapshot {
id: string;
tag: string;
- element: {
+ bounds?: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ };
+ children: WebRendererDebugNodeSnapshot[];
+ childrenTruncated?: boolean;
+ component?: {
+ elementId?: string;
+ key: string;
+ name: string;
+ };
+ element?: {
id: number;
attributes: Record;
dom: {
@@ -32,6 +46,9 @@ export interface WebRendererDebugElementSnapshot {
tagName: string;
};
};
+}
+
+export interface WebRendererDebugElementSnapshot extends WebRendererDebugNodeSnapshot {
bounds: {
x: number;
y: number;
@@ -39,11 +56,26 @@ export interface WebRendererDebugElementSnapshot {
height: number;
};
children: WebRendererDebugElementSnapshot[];
- childrenTruncated?: boolean;
+ element: {
+ id: number;
+ attributes: Record;
+ dom: {
+ attributes: Record;
+ tagName: string;
+ };
+ };
+}
+
+export interface WebRendererDebugComponentSnapshot extends WebRendererDebugNodeSnapshot {
+ component: {
+ elementId?: string;
+ key: string;
+ name: string;
+ };
}
export interface WebRendererDebugSnapshot {
- tree: WebRendererDebugElementSnapshot | null;
+ tree: WebRendererDebugNodeSnapshot | null;
viewport: {
width: number;
height: number;
@@ -81,6 +113,7 @@ export class ValdiWebRendererDelegate implements IRendererDelegate {
private frameObserver?: FrameObserver;
private resizeObserver?: ResizeObserver;
private elementIdByHtmlElement = new WeakMap();
+ private debugTopologyRevision = 0;
// Owned per delegate (i.e. per renderer/page) so element ids can't collide
// with another page's (github.com/Snapchat/Valdi#115).
private nodesRef: NodesRef = createNodesRef();
@@ -98,12 +131,15 @@ export class ValdiWebRendererDelegate implements IRendererDelegate {
onElementBecameRoot(id: number): void {
makeElementRoot(this.nodesRef, id, this.htmlRoot);
this.rootElementId = id;
+ this.debugTopologyRevision++;
}
onElementMoved(id: number, parentId: number, parentIndex: number): void {
moveElement(this.nodesRef, id, parentId, parentIndex);
+ this.debugTopologyRevision++;
}
onElementCreated(id: number, viewClass: string): void {
createElement(this.nodesRef, id, viewClass, this.attributeDelegate);
+ this.debugTopologyRevision++;
const element = this.nodesRef.get(id);
if (element?.htmlElement) {
this.elementIdByHtmlElement.set(element.htmlElement, id);
@@ -148,6 +184,7 @@ export class ValdiWebRendererDelegate implements IRendererDelegate {
this.rootElementId = undefined;
}
}
+ this.debugTopologyRevision++;
}
onElementAttributeChangeAny(id: number, attributeName: string, attributeValue: any): void {
changeAttributeOnElement(this.nodesRef, id, attributeName, attributeValue);
@@ -269,18 +306,30 @@ export class ValdiWebRendererDelegate implements IRendererDelegate {
},
};
const rootNode = this.rootElementId === undefined ? undefined : this.nodesRef.get(this.rootElementId);
- const snapshot: WebRendererDebugSnapshot = {
- tree:
- rootNode === undefined
- ? null
- : (captureDebugElementSnapshot(rootNode, null, this.nodesRef, renderer, budget, 0, false) ?? null),
+ const elementTree =
+ rootNode === undefined
+ ? null
+ : (captureDebugElementSnapshot(rootNode, null, this.nodesRef, renderer, budget, 0, false) ?? null);
+ const elementSnapshot: WebRendererDebugSnapshot = {
+ tree: elementTree,
viewport,
};
// The snapshot only contains fresh data objects and primitives, so this final
// serialization check cannot invoke getters from renderer-owned values.
- return JSON.stringify(snapshot).length <= snapshotCharacterLimit
- ? snapshot
- : { tree: null, viewport };
+ if (JSON.stringify(elementSnapshot).length > snapshotCharacterLimit) {
+ return { tree: null, viewport };
+ }
+ if (elementTree === null) {
+ return elementSnapshot;
+ }
+
+ const topologyRevision = this.debugTopologyRevision;
+ const componentTree = captureComponentHierarchySnapshot(elementTree, renderer);
+ if (componentTree === undefined || topologyRevision !== this.debugTopologyRevision) {
+ return elementSnapshot;
+ }
+ const componentSnapshot: WebRendererDebugSnapshot = { tree: componentTree, viewport };
+ return JSON.stringify(componentSnapshot).length <= snapshotCharacterLimit ? componentSnapshot : elementSnapshot;
}
}
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
new file mode 100644
index 00000000..de67ccdd
--- /dev/null
+++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts
@@ -0,0 +1,422 @@
+import type { IComponent } from 'valdi_core/src/IComponent';
+import type { IRenderedElement } from 'valdi_core/src/IRenderedElement';
+import type { IRenderedVirtualNode } from 'valdi_core/src/IRenderedVirtualNode';
+import type { IRenderer, RendererDebugVirtualNodeSnapshot } from 'valdi_core/src/IRenderer';
+import type {
+ WebRendererDebugComponentSnapshot,
+ WebRendererDebugElementSnapshot,
+ WebRendererDebugNodeSnapshot,
+} from '../ValdiWebRendererDelegate';
+
+const MAX_COMPONENT_HIERARCHY_CHILD_LINKS = 1_000;
+const MAX_COMPONENT_HIERARCHY_DEPTH = 64;
+const MAX_COMPONENT_HIERARCHY_ID_CHARACTERS = 4_096;
+const MAX_COMPONENT_HIERARCHY_NODES = 1_000;
+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;
+
+interface IndexedElementTree {
+ readonly childIdsByParentId: Map;
+ readonly elementsById: Map;
+ readonly parentIdById: Map;
+}
+
+interface CapturedHierarchyNode {
+ readonly firstElementId?: string;
+ readonly node: WebRendererDebugNodeSnapshot;
+}
+
+interface CapturedVirtualNode extends RendererDebugVirtualNodeSnapshot {
+ readonly node: IRenderedVirtualNode;
+}
+
+interface VirtualTraversalFrame {
+ readonly componentPath: string[];
+ readonly depth: number;
+ readonly expectedParent: IRenderedVirtualNode | undefined;
+ readonly nearestElementId: string | null;
+ readonly node: IRenderedVirtualNode;
+ captured?: CapturedVirtualNode;
+ capturedChildren: CapturedHierarchyNode[];
+ childIndex: number;
+}
+
+type DebugVirtualNodeSnapshotReader = NonNullable;
+
+/**
+ * Transactionally overlays the Valdi component tree on an already-captured
+ * physical web-renderer tree. Returning undefined means callers must retain
+ * the physical tree verbatim.
+ */
+export function captureComponentHierarchySnapshot(
+ elementTree: WebRendererDebugElementSnapshot,
+ renderer: IRenderer,
+): WebRendererDebugNodeSnapshot | undefined {
+ const indexedElements = indexCompleteElementTree(elementTree);
+ if (indexedElements === undefined) {
+ return undefined;
+ }
+
+ let getDebugVirtualNodeSnapshot: DebugVirtualNodeSnapshotReader;
+ let getRootVirtualNode: IRenderer['getRootVirtualNode'];
+ let rootVirtualNode: IRenderedVirtualNode | undefined;
+ try {
+ const debugSnapshotReader = renderer.getDebugVirtualNodeSnapshot;
+ const rootReader = renderer.getRootVirtualNode;
+ if (typeof debugSnapshotReader !== 'function' || typeof rootReader !== 'function') {
+ return undefined;
+ }
+ getDebugVirtualNodeSnapshot = debugSnapshotReader;
+ getRootVirtualNode = rootReader;
+ rootVirtualNode = getRootVirtualNode.call(renderer);
+ } catch (_error) {
+ console.warn('Valdi debugger could not read the component root; using the element hierarchy.');
+ return undefined;
+ }
+ if (rootVirtualNode === undefined) {
+ return undefined;
+ }
+
+ const capturedNodes: CapturedVirtualNode[] = [];
+ const componentIds = new Set();
+ const consumedChildCountByParentId = new Map();
+ const usedElementIds = new Set();
+ const visitedVirtualNodes = new Set();
+ let remainingChildLinks = MAX_COMPONENT_HIERARCHY_CHILD_LINKS;
+ let remainingNodes = MAX_COMPONENT_HIERARCHY_NODES;
+ let remainingTraversalLinks = MAX_COMPONENT_HIERARCHY_TRAVERSAL_LINKS;
+ let mergedRoot: CapturedHierarchyNode | undefined;
+ const stack: VirtualTraversalFrame[] = [
+ {
+ capturedChildren: [],
+ childIndex: 0,
+ componentPath: [],
+ depth: 0,
+ expectedParent: undefined,
+ nearestElementId: null,
+ node: rootVirtualNode,
+ },
+ ];
+
+ try {
+ while (stack.length > 0) {
+ const frame = stack[stack.length - 1];
+ if (frame.captured === undefined) {
+ if (frame.depth >= MAX_COMPONENT_HIERARCHY_DEPTH || remainingNodes <= 0) {
+ return undefined;
+ }
+ if (visitedVirtualNodes.has(frame.node)) {
+ return undefined;
+ }
+ visitedVirtualNodes.add(frame.node);
+ remainingNodes--;
+
+ const debugSnapshot = getDebugVirtualNodeSnapshot.call(
+ renderer,
+ frame.node,
+ remainingChildLinks,
+ remainingTraversalLinks,
+ );
+ if (
+ debugSnapshot === undefined ||
+ debugSnapshot.parent !== frame.expectedParent ||
+ (debugSnapshot.element === undefined) === (debugSnapshot.component === undefined) ||
+ typeof debugSnapshot.key !== 'string' ||
+ debugSnapshot.key.length > MAX_COMPONENT_KEY_CHARACTERS ||
+ !Array.isArray(debugSnapshot.children) ||
+ debugSnapshot.children.length > remainingChildLinks ||
+ !Number.isSafeInteger(debugSnapshot.traversedLinkCount) ||
+ debugSnapshot.traversedLinkCount < 0 ||
+ debugSnapshot.traversedLinkCount > remainingTraversalLinks
+ ) {
+ return undefined;
+ }
+ remainingChildLinks -= debugSnapshot.children.length;
+ remainingTraversalLinks -= debugSnapshot.traversedLinkCount;
+ frame.captured = {
+ children: debugSnapshot.children,
+ component: debugSnapshot.component,
+ element: debugSnapshot.element,
+ key: debugSnapshot.key,
+ node: frame.node,
+ parent: debugSnapshot.parent,
+ traversedLinkCount: debugSnapshot.traversedLinkCount,
+ };
+ capturedNodes.push(frame.captured);
+ }
+
+ const captured = frame.captured;
+ if (frame.childIndex < captured.children.length) {
+ const child = captured.children[frame.childIndex];
+ frame.childIndex++;
+ if (typeof child !== 'object' || child === null) {
+ return undefined;
+ }
+ let currentElementId = frame.nearestElementId;
+ if (captured.element !== undefined) {
+ const capturedElementId = readElementId(captured.element);
+ if (capturedElementId === undefined) {
+ return undefined;
+ }
+ currentElementId = capturedElementId;
+ }
+ stack.push({
+ capturedChildren: [],
+ childIndex: 0,
+ componentPath: captured.component === undefined ? [] : [...frame.componentPath, captured.key],
+ depth: frame.depth + 1,
+ expectedParent: frame.node,
+ nearestElementId: currentElementId,
+ node: child,
+ });
+ continue;
+ }
+
+ const result = captureCompletedFrame(
+ frame,
+ indexedElements,
+ componentIds,
+ consumedChildCountByParentId,
+ usedElementIds,
+ );
+ if (result === undefined) {
+ return undefined;
+ }
+ stack.pop();
+ const parentFrame = stack[stack.length - 1];
+ if (parentFrame === undefined) {
+ mergedRoot = result;
+ } else {
+ parentFrame.capturedChildren.push(result);
+ }
+ }
+
+ if (
+ mergedRoot === undefined ||
+ usedElementIds.size !== indexedElements.elementsById.size ||
+ !allElementChildrenConsumed(indexedElements, consumedChildCountByParentId) ||
+ getRootVirtualNode.call(renderer) !== rootVirtualNode ||
+ !isCapturedVirtualTopologyCurrent(capturedNodes, renderer, getDebugVirtualNodeSnapshot)
+ ) {
+ return undefined;
+ }
+ return mergedRoot.node;
+ } catch (_error) {
+ console.warn('Valdi debugger could not capture a stable component hierarchy; using the element hierarchy.');
+ return undefined;
+ }
+}
+
+function captureCompletedFrame(
+ frame: VirtualTraversalFrame,
+ indexedElements: IndexedElementTree,
+ componentIds: Set,
+ consumedChildCountByParentId: Map,
+ usedElementIds: Set,
+): CapturedHierarchyNode | undefined {
+ const captured = frame.captured;
+ if (captured === undefined) {
+ return undefined;
+ }
+ if (captured.element !== undefined) {
+ const elementId = readElementId(captured.element);
+ if (elementId === undefined || usedElementIds.has(elementId)) {
+ return undefined;
+ }
+ const elementSnapshot = indexedElements.elementsById.get(elementId);
+ if (
+ elementSnapshot === undefined ||
+ indexedElements.parentIdById.get(elementId) !== frame.nearestElementId ||
+ !consumeElementInPhysicalOrder(elementId, frame.nearestElementId, indexedElements, consumedChildCountByParentId)
+ ) {
+ return undefined;
+ }
+ usedElementIds.add(elementId);
+ return {
+ firstElementId: elementId,
+ node: {
+ ...elementSnapshot,
+ children: frame.capturedChildren.map(child => child.node),
+ },
+ };
+ }
+
+ const component = captured.component;
+ if (component === undefined) {
+ return undefined;
+ }
+ const componentName = readComponentName(component);
+ const componentPath = [...frame.componentPath, captured.key];
+ const componentId = createComponentId(frame.nearestElementId, componentPath);
+ if (componentName === undefined || componentId === undefined || componentIds.has(componentId)) {
+ return undefined;
+ }
+ componentIds.add(componentId);
+ const firstElementId = frame.capturedChildren.find(child => child.firstElementId !== undefined)?.firstElementId;
+ const backingElement = firstElementId === undefined ? undefined : indexedElements.elementsById.get(firstElementId);
+ const node: WebRendererDebugComponentSnapshot = {
+ ...(backingElement === undefined ? {} : { bounds: backingElement.bounds }),
+ children: frame.capturedChildren.map(child => child.node),
+ component: {
+ ...(firstElementId === undefined ? {} : { elementId: firstElementId }),
+ key: captured.key,
+ name: componentName,
+ },
+ id: componentId,
+ tag: componentName,
+ };
+ return {
+ ...(firstElementId === undefined ? {} : { firstElementId }),
+ node,
+ };
+}
+
+function indexCompleteElementTree(root: WebRendererDebugElementSnapshot): IndexedElementTree | undefined {
+ const childIdsByParentId = new Map();
+ const elementsById = new Map();
+ const parentIdById = new Map();
+ const visited = new Set();
+ let childLinks = 0;
+ const stack: Array<{ depth: number; node: WebRendererDebugElementSnapshot; parentId: string | null }> = [
+ { depth: 0, node: root, parentId: null },
+ ];
+ while (stack.length > 0) {
+ const frame = stack.pop()!;
+ const node = frame.node;
+ if (
+ frame.depth >= MAX_COMPONENT_HIERARCHY_DEPTH ||
+ visited.size >= MAX_COMPONENT_HIERARCHY_NODES ||
+ visited.has(node) ||
+ node.childrenTruncated === true ||
+ !Array.isArray(node.children) ||
+ !Number.isSafeInteger(node.element.id) ||
+ node.element.id < 0 ||
+ node.id !== String(node.element.id) ||
+ elementsById.has(node.id)
+ ) {
+ return undefined;
+ }
+ childLinks += node.children.length;
+ if (childLinks > MAX_COMPONENT_HIERARCHY_CHILD_LINKS) {
+ return undefined;
+ }
+ visited.add(node);
+ elementsById.set(node.id, node);
+ parentIdById.set(node.id, frame.parentId);
+ childIdsByParentId.set(
+ node.id,
+ node.children.map(child => child.id),
+ );
+ for (let index = node.children.length - 1; index >= 0; index--) {
+ const child = node.children[index];
+ if (typeof child !== 'object' || child === null) {
+ return undefined;
+ }
+ stack.push({ depth: frame.depth + 1, node: child, parentId: node.id });
+ }
+ }
+ childIdsByParentId.set(null, [root.id]);
+ return { childIdsByParentId, elementsById, parentIdById };
+}
+
+function consumeElementInPhysicalOrder(
+ elementId: string,
+ parentId: string | null,
+ indexedElements: IndexedElementTree,
+ consumedChildCountByParentId: Map,
+): boolean {
+ const childIds = indexedElements.childIdsByParentId.get(parentId);
+ const childIndex = consumedChildCountByParentId.get(parentId) ?? 0;
+ if (childIds === undefined || childIds[childIndex] !== elementId) {
+ return false;
+ }
+ consumedChildCountByParentId.set(parentId, childIndex + 1);
+ return true;
+}
+
+function allElementChildrenConsumed(
+ indexedElements: IndexedElementTree,
+ consumedChildCountByParentId: Map,
+): boolean {
+ for (const [parentId, childIds] of indexedElements.childIdsByParentId) {
+ if ((consumedChildCountByParentId.get(parentId) ?? 0) !== childIds.length) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function isCapturedVirtualTopologyCurrent(
+ capturedNodes: CapturedVirtualNode[],
+ renderer: IRenderer,
+ getDebugVirtualNodeSnapshot: DebugVirtualNodeSnapshotReader,
+): boolean {
+ let remainingChildLinks = MAX_COMPONENT_HIERARCHY_CHILD_LINKS;
+ let remainingTraversalLinks = MAX_COMPONENT_HIERARCHY_TRAVERSAL_LINKS;
+ for (const captured of capturedNodes) {
+ const current = getDebugVirtualNodeSnapshot.call(
+ renderer,
+ captured.node,
+ remainingChildLinks,
+ remainingTraversalLinks,
+ );
+ const children = current?.children;
+ if (
+ current === undefined ||
+ !Array.isArray(children) ||
+ children.length > remainingChildLinks ||
+ current.parent !== captured.parent ||
+ current.element !== captured.element ||
+ current.component !== captured.component ||
+ current.key !== captured.key ||
+ !Number.isSafeInteger(current.traversedLinkCount) ||
+ current.traversedLinkCount < 0 ||
+ current.traversedLinkCount > remainingTraversalLinks
+ ) {
+ return false;
+ }
+ remainingChildLinks -= children.length;
+ remainingTraversalLinks -= current.traversedLinkCount;
+ if (children.length !== captured.children.length) {
+ return false;
+ }
+ for (let index = 0; index < children.length; index++) {
+ if (children[index] !== captured.children[index]) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+function readElementId(element: IRenderedElement): string | undefined {
+ const id = element.id;
+ return Number.isSafeInteger(id) && id >= 0 ? String(id) : undefined;
+}
+
+function readComponentName(component: IComponent): string | undefined {
+ let prototype: object | null = Object.getPrototypeOf(component);
+ let depth = 0;
+ while (prototype !== null && depth < MAX_COMPONENT_PROTOTYPE_DEPTH) {
+ const constructorDescriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor');
+ const constructorValue = constructorDescriptor?.value;
+ if (typeof constructorValue === 'function') {
+ const nameDescriptor = Object.getOwnPropertyDescriptor(constructorValue, 'name');
+ const name = nameDescriptor?.value;
+ if (typeof name === 'string' && name.length > 0 && name.length <= MAX_COMPONENT_NAME_CHARACTERS) {
+ return name;
+ }
+ }
+ prototype = Object.getPrototypeOf(prototype);
+ depth++;
+ }
+ return undefined;
+}
+
+function createComponentId(nearestElementId: string | null, componentPath: string[]): string | undefined {
+ const serializedPath = JSON.stringify([nearestElementId, ...componentPath]);
+ const id = `component:${serializedPath}`;
+ return id.length <= MAX_COMPONENT_HIERARCHY_ID_CHARACTERS ? id : undefined;
+}
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 db8a5cb9..f02b451f 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
@@ -1,5 +1,7 @@
import 'jasmine/src/jasmine';
+import type { IComponent } from 'valdi_core/src/IComponent';
import type { IRenderedElement } from 'valdi_core/src/IRenderedElement';
+import type { IRenderedVirtualNode } from 'valdi_core/src/IRenderedVirtualNode';
import type { IRenderer } from 'valdi_core/src/IRenderer';
import {
MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
@@ -191,6 +193,58 @@ function makeRenderer(elements: IRenderedElement[]): IRenderer {
} as IRenderer;
}
+interface MutableVirtualNode {
+ children: MutableVirtualNode[];
+ component?: IComponent;
+ element?: IRenderedElement;
+ key: string;
+ parent?: MutableVirtualNode;
+}
+
+function makeVirtualNode(
+ key: string,
+ value: { component?: IComponent; element?: IRenderedElement },
+): MutableVirtualNode {
+ return { children: [], key, ...value };
+}
+
+function setVirtualChildren(parent: MutableVirtualNode, children: MutableVirtualNode[]): void {
+ parent.children = children;
+ for (const child of children) {
+ child.parent = parent;
+ }
+}
+
+function makeHierarchyRenderer(
+ elements: IRenderedElement[],
+ rootVirtualNode: MutableVirtualNode | undefined,
+): IRenderer {
+ const renderer = makeRenderer(elements);
+ return Object.assign(renderer, {
+ getDebugVirtualNodeSnapshot: (
+ node: IRenderedVirtualNode,
+ maximumChildLinks: number,
+ maximumTraversalLinks: number,
+ ) => {
+ const mutableNode = node as unknown as MutableVirtualNode;
+ const children = mutableNode.children;
+ const traversedLinkCount = children.length + (mutableNode.parent === undefined ? 0 : 1);
+ if (children.length > maximumChildLinks || traversedLinkCount > maximumTraversalLinks) {
+ return undefined;
+ }
+ return {
+ children: children.slice(),
+ component: mutableNode.component,
+ element: mutableNode.element,
+ key: mutableNode.key,
+ parent: mutableNode.parent as unknown as IRenderedVirtualNode | undefined,
+ traversedLinkCount,
+ };
+ },
+ getRootVirtualNode: () => rootVirtualNode as unknown as IRenderedVirtualNode | undefined,
+ });
+}
+
function captureSnapshot(
delegate: ValdiWebRendererDelegate,
elements: IRenderedElement[],
@@ -318,6 +372,297 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
);
});
+ it('transactionally interleaves component boundaries with every physical element in render order', () => {
+ class RootExampleComponent {}
+ class NestedExampleComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementCreated(2, 'label');
+ delegate.onElementCreated(3, 'label');
+ delegate.onElementBecameRoot(1);
+ delegate.onElementMoved(2, 1, 0);
+ delegate.onElementMoved(3, 1, 1);
+ (delegate.getDebugNode(1)!.htmlElement as unknown as { rect: object }).rect = {
+ left: 4,
+ top: 8,
+ width: 220,
+ height: 80,
+ };
+ (delegate.getDebugNode(2)!.htmlElement as unknown as { rect: object }).rect = {
+ left: 12,
+ top: 24,
+ width: 140,
+ height: 20,
+ };
+
+ const rootElement = makeRenderedElement(1, 'layout', { accessibilityId: 'sample.root' });
+ const nestedElement = makeRenderedElement(2, 'label', { accessibilityLabel: 'Continue' });
+ const siblingElement = makeRenderedElement(3, 'label', { value: 'Later' });
+ const rootComponent = makeVirtualNode('root', {
+ component: new RootExampleComponent() as unknown as IComponent,
+ });
+ const rootElementNode = makeVirtualNode('layout', { element: rootElement });
+ const nestedComponent = makeVirtualNode('nested', {
+ component: new NestedExampleComponent() as unknown as IComponent,
+ });
+ const nestedElementNode = makeVirtualNode('continue', { element: nestedElement });
+ const siblingElementNode = makeVirtualNode('later', { element: siblingElement });
+ setVirtualChildren(rootComponent, [rootElementNode]);
+ setVirtualChildren(rootElementNode, [nestedComponent, siblingElementNode]);
+ setVirtualChildren(nestedComponent, [nestedElementNode]);
+
+ const snapshot = delegate.getDebugSnapshot(
+ makeHierarchyRenderer([rootElement, nestedElement, siblingElement], rootComponent),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+
+ expect(snapshot.tree).toEqual({
+ bounds: { x: 4, y: 8, width: 220, height: 80 },
+ children: [
+ jasmine.objectContaining({
+ id: '1',
+ children: [
+ {
+ bounds: { x: 12, y: 24, width: 140, height: 20 },
+ children: [jasmine.objectContaining({ id: '2', tag: 'label' })],
+ component: { elementId: '2', key: 'nested', name: 'NestedExampleComponent' },
+ id: 'component:["1","nested"]',
+ tag: 'NestedExampleComponent',
+ },
+ jasmine.objectContaining({ id: '3', tag: 'label' }),
+ ],
+ }),
+ ],
+ component: { elementId: '1', key: 'root', name: 'RootExampleComponent' },
+ id: 'component:[null,"root"]',
+ tag: 'RootExampleComponent',
+ });
+ expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS);
+ });
+
+ it('keeps stable component ids across fresh snapshots and captures updated physical values', () => {
+ class StableComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'label');
+ delegate.onElementBecameRoot(1);
+ const attributes = { value: 'first' };
+ const element = makeRenderedElement(1, 'label', attributes);
+ const component = makeVirtualNode('stable-key', {
+ component: new StableComponent() as unknown as IComponent,
+ });
+ const elementNode = makeVirtualNode('label', { element });
+ setVirtualChildren(component, [elementNode]);
+ const renderer = makeHierarchyRenderer([element], component);
+
+ const first = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS);
+ attributes.value = 'second';
+ const second = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS);
+
+ expect(first.tree?.id).toBe('component:[null,"stable-key"]');
+ expect(second.tree?.id).toBe(first.tree?.id);
+ expect(second.tree?.children[0].element?.attributes.value).toBe('second');
+ });
+
+ it('does not inspect component fields or invoke an instance constructor accessor', () => {
+ class SafeComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementBecameRoot(1);
+ const element = makeRenderedElement(1, 'layout', {});
+ const componentInstance = new SafeComponent() as unknown as IComponent;
+ let getterCalls = 0;
+ Object.defineProperty(componentInstance, 'constructor', {
+ configurable: true,
+ get: () => {
+ getterCalls++;
+ throw new Error('Component fields are not debugger protocol data.');
+ },
+ });
+ Object.defineProperty(componentInstance, 'viewModel', {
+ get: () => {
+ getterCalls++;
+ throw new Error('Component view models belong to a later debugger layer.');
+ },
+ });
+ const component = makeVirtualNode('safe', { component: componentInstance });
+ const elementNode = makeVirtualNode('layout', { element });
+ setVirtualChildren(component, [elementNode]);
+
+ const snapshot = delegate.getDebugSnapshot(
+ makeHierarchyRenderer([element], component),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+
+ expect(getterCalls).toBe(0);
+ expect(snapshot.tree?.component?.name).toBe('SafeComponent');
+ });
+
+ it('falls back atomically for shared, cyclic, partial, and over-deep virtual trees', () => {
+ class BoundaryComponent {}
+
+ const captureWithRoot = (rootVirtualNode: MutableVirtualNode) => {
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementBecameRoot(1);
+ const element = makeRenderedElement(1, 'layout', {});
+ const pendingNodes = [rootVirtualNode];
+ const visitedNodes = new Set();
+ while (pendingNodes.length > 0) {
+ const node = pendingNodes.pop()!;
+ if (visitedNodes.has(node)) continue;
+ visitedNodes.add(node);
+ if (node.element !== undefined) node.element = element;
+ pendingNodes.push(...node.children);
+ }
+ return delegate.getDebugSnapshot(
+ makeHierarchyRenderer([element], rootVirtualNode),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+ };
+
+ const sharedElement = makeVirtualNode('layout', { element: makeRenderedElement(99, 'layout', {}) });
+ const sharedRoot = makeVirtualNode('shared', {
+ component: new BoundaryComponent() as unknown as IComponent,
+ });
+ setVirtualChildren(sharedRoot, [sharedElement, sharedElement]);
+
+ const cyclicRoot = makeVirtualNode('cycle', {
+ component: new BoundaryComponent() as unknown as IComponent,
+ });
+ cyclicRoot.children = [cyclicRoot];
+ cyclicRoot.parent = cyclicRoot;
+
+ const partialRoot = makeVirtualNode('partial', {
+ component: new BoundaryComponent() as unknown as IComponent,
+ });
+
+ const deepElement = makeVirtualNode('layout', { element: makeRenderedElement(99, 'layout', {}) });
+ let deepRoot = deepElement;
+ for (let depth = 0; depth < 65; depth++) {
+ const parent = makeVirtualNode(`depth-${depth}`, {
+ component: new BoundaryComponent() as unknown as IComponent,
+ });
+ setVirtualChildren(parent, [deepRoot]);
+ deepRoot = parent;
+ }
+
+ for (const snapshot of [
+ captureWithRoot(sharedRoot),
+ captureWithRoot(cyclicRoot),
+ captureWithRoot(partialRoot),
+ captureWithRoot(deepRoot),
+ ]) {
+ expect(snapshot.tree?.id).toBe('1');
+ expect(snapshot.tree?.component).toBeUndefined();
+ expect(snapshot.tree?.children).toEqual([]);
+ }
+ });
+
+ it('bounds virtual child arrays before indexing and falls back without partial component data', () => {
+ class WideComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementBecameRoot(1);
+ const element = makeRenderedElement(1, 'layout', {});
+ const root = makeVirtualNode('wide', {
+ component: new WideComponent() as unknown as IComponent,
+ });
+ let indexedReads = 0;
+ const children = new Array(1_001);
+ Object.defineProperty(children, '0', {
+ get: () => {
+ indexedReads++;
+ throw new Error('An over-cap child must not be inspected.');
+ },
+ });
+ root.children = children;
+
+ const snapshot = delegate.getDebugSnapshot(
+ makeHierarchyRenderer([element], root),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+
+ expect(indexedReads).toBe(0);
+ expect(snapshot.tree?.id).toBe('1');
+ expect(snapshot.tree?.component).toBeUndefined();
+ });
+
+ it('falls back to the element tree when complete component metadata exceeds the envelope budget', () => {
+ class LongNameComponent {}
+ Object.defineProperty(LongNameComponent, 'name', { value: 'C'.repeat(256) });
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ delegate.onElementCreated(1, 'layout');
+ delegate.onElementBecameRoot(1);
+ const element = makeRenderedElement(1, 'layout', {});
+ const root = makeVirtualNode('layout', { element });
+ const components = Array.from({ length: 999 }, (_value, index) =>
+ makeVirtualNode(`${index}:`.padEnd(256, 'k'), {
+ component: new LongNameComponent() as unknown as IComponent,
+ }),
+ );
+ setVirtualChildren(root, components);
+
+ const snapshot = delegate.getDebugSnapshot(
+ makeHierarchyRenderer([element], root),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+
+ expect(snapshot.tree?.id).toBe('1');
+ expect(snapshot.tree?.children).toEqual([]);
+ expect(snapshot.tree?.component).toBeUndefined();
+ expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS);
+ });
+
+ it('falls back to the captured element tree when hierarchy access reparents a backing node', () => {
+ class MutatingComponent {}
+
+ const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
+ for (const id of [1, 2, 3]) {
+ delegate.onElementCreated(id, 'layout');
+ }
+ delegate.onElementBecameRoot(1);
+ delegate.onElementMoved(2, 1, 0);
+ delegate.onElementMoved(3, 1, 1);
+ const elements = [
+ makeRenderedElement(1, 'layout', {}),
+ makeRenderedElement(2, 'layout', {}),
+ makeRenderedElement(3, 'layout', {}),
+ ];
+ const rootElement = makeVirtualNode('root-layout', { element: elements[0] });
+ const firstChild = makeVirtualNode('first', { element: elements[1] });
+ const secondChild = makeVirtualNode('second', { element: elements[2] });
+ setVirtualChildren(rootElement, [firstChild, secondChild]);
+ const mutatingComponent = makeVirtualNode('mutating', {
+ component: new MutatingComponent() as unknown as IComponent,
+ });
+ setVirtualChildren(mutatingComponent, [rootElement]);
+ let mutated = false;
+ Object.defineProperty(mutatingComponent, 'key', {
+ configurable: true,
+ get: () => {
+ if (!mutated) {
+ mutated = true;
+ delegate.onElementMoved(2, 3, 0);
+ }
+ return 'mutating';
+ },
+ });
+
+ const snapshot = delegate.getDebugSnapshot(
+ makeHierarchyRenderer(elements, mutatingComponent),
+ MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS,
+ );
+
+ expect(snapshot.tree?.id).toBe('1');
+ expect(snapshot.tree?.component).toBeUndefined();
+ expect(snapshot.tree?.children.map(child => child.id)).toEqual(['2', '3']);
+ });
+
it('keeps debugger lookup scoped to this renderer and removes destroyed nodes', () => {
const delegate = new ValdiWebRendererDelegate(dom.createElement('main'));
delegate.onElementCreated(7, 'layout');
@@ -580,7 +925,7 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
Object.defineProperty(metadata, '__proto__', { enumerable: true, value: 'own property' });
const snapshot = captureSnapshot(delegate, [makeRenderedElement(1, 'layout', { metadata })]);
- const debugMetadata = snapshot.tree?.element.attributes.metadata as Record;
+ const debugMetadata = snapshot.tree?.element?.attributes.metadata as Record;
expect(getterCalls).toBe(0);
expect(debugMetadata.dangerous).toEqual({ secret: '' });
@@ -618,7 +963,7 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
const snapshot = captureSnapshot(delegate, [
makeRenderedElement(1, 'layout', { items: inspectedArray }),
]);
- const debugItems = snapshot.tree?.element.attributes.items as unknown[];
+ const debugItems = snapshot.tree?.element?.attributes.items as unknown[];
expect(getterCalls).toBe(0);
expect(inspectedProperties).toEqual([
@@ -641,7 +986,7 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
}
const snapshot = captureSnapshot(delegate, [makeRenderedElement(1, 'layout', attributes)]);
- const debugAttributes = snapshot.tree?.element.attributes ?? {};
+ const debugAttributes = snapshot.tree?.element?.attributes ?? {};
expect(String(debugAttributes.value0).length).toBeLessThanOrEqual(65_536);
expect(String(debugAttributes.value0)).toContain('');
@@ -662,12 +1007,12 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
value: unicodeBoundaryValue,
}),
]);
- const attributes = snapshot.tree!.element.attributes;
+ const attributes = snapshot.tree!.element!.attributes;
const nested = attributes.metadata as Record;
expectUnicodeSafeTruncation(attributes.value);
expectUnicodeSafeTruncation(nested.nested);
- expectUnicodeSafeTruncation(snapshot.tree!.element.dom.attributes['data-emoji']);
+ expectUnicodeSafeTruncation(snapshot.tree!.element!.dom.attributes['data-emoji']);
expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS);
});
@@ -682,7 +1027,7 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
const snapshot = delegate.getDebugSnapshot(renderer, 8_192);
expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(8_192);
- expect(String(snapshot.tree?.element.attributes.payload)).toContain('');
+ expect(String(snapshot.tree?.element?.attributes.payload)).toContain('');
});
it('bounds the complete serialized snapshot and omits over-budget property names before insertion', () => {
@@ -714,14 +1059,14 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
const snapshot = captureSnapshot(delegate, [element]);
const serializedSnapshot = JSON.stringify(snapshot);
- const debugAttributes = snapshot.tree?.element.attributes ?? {};
+ const debugAttributes = snapshot.tree?.element?.attributes ?? {};
expect(serializedSnapshot.length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS);
expect(String(debugAttributes.hugeValue).length).toBeLessThanOrEqual(65_536);
expect(String(debugAttributes.hugeValue)).toContain('');
expect((debugAttributes.metadata as Record).__truncated__).toBeDefined();
expect(Object.prototype.hasOwnProperty.call(debugAttributes, hugeKey)).toBeFalse();
- expect(Object.prototype.hasOwnProperty.call(snapshot.tree?.element.dom.attributes ?? {}, hugeKey)).toBeFalse();
+ expect(Object.prototype.hasOwnProperty.call(snapshot.tree?.element?.dom.attributes ?? {}, hugeKey)).toBeFalse();
expect(attributeReads).not.toContain(hugeKey);
expect(getterCalls).toBe(0);
});
@@ -758,7 +1103,7 @@ describe('ValdiWebRendererDelegate debugger adapter', () => {
const snapshot = captureSnapshot(delegate, [
makeRenderedElement(1, 'layout', { nested, properties }),
]);
- const debugAttributes = snapshot.tree?.element.attributes ?? {};
+ const debugAttributes = snapshot.tree?.element?.attributes ?? {};
const debugProperties = debugAttributes.properties as Record;
expect(debugProperties.property49).toBe(49);