diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css index cf849b84..f0552afc 100644 --- a/npm_modules/cli/debugger/devtools-panel.css +++ b/npm_modules/cli/debugger/devtools-panel.css @@ -356,6 +356,11 @@ button { color: var(--tag); } +.component-row .tag-name { + color: var(--accent); + font-weight: 600; +} + .attribute-name { padding-left: 5px; color: var(--attribute); diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js index ad1a6a8f..6173b05b 100644 --- a/npm_modules/cli/debugger/devtools-panel.js +++ b/npm_modules/cli/debugger/devtools-panel.js @@ -17,8 +17,14 @@ const state = { autoRefresh: true, refreshTimer: null, refreshPending: false, + snapshotGeneration: 0, + snapshotRequestGeneration: 0, hoveredNodeId: null, + hoveredSnapshotGeneration: 0, highlightTimer: null, + highlightIntentGeneration: 0, + highlightMayBeActive: false, + highlightRequestTail: Promise.resolve(), consoleEntries: [], consoleEntryKeys: new Set(), consoleHistory: [], @@ -107,6 +113,19 @@ function nodeId(node) { return valdiDebuggerTreeModel.id(node); } +function inspectedNodeId(node) { + if (!node) return null; + if (node.component) { + return node.component.elementId === undefined ? null : String(node.component.elementId); + } + return nodeId(node); +} + +function inspectedNode(node) { + const id = inspectedNodeId(node); + return id === null ? node : findNode(id) || node; +} + function treeRowId(id) { return `valdi-tree-node-${encodeURIComponent(id)}`; } @@ -238,15 +257,25 @@ async function connectToInspectedApplication() { async function refreshSnapshot() { if (!state.target || state.refreshPending) return; state.refreshPending = true; + const requestTarget = state.target; + const requestGeneration = ++state.snapshotRequestGeneration; try { const snapshot = await requestJson( '/api/devtools/snapshot', - { inspectedUrl, sessionId: state.target.sessionId, targetNonce: inspectedTargetNonce }, + { inspectedUrl, sessionId: requestTarget.sessionId, targetNonce: inspectedTargetNonce }, {}, ); + if (state.target !== requestTarget || state.snapshotRequestGeneration !== requestGeneration) return; snapshot.tree = valdiDebuggerTreeModel.restoreTree(snapshot.tree); const wasEmpty = !state.snapshot?.tree; + const shouldClearHighlight = state.hoveredNodeId !== null || state.highlightMayBeActive; state.snapshot = snapshot; + state.snapshotGeneration++; + if (state.highlightTimer) window.clearTimeout(state.highlightTimer); + state.highlightTimer = null; + state.hoveredNodeId = null; + state.hoveredSnapshotGeneration = state.snapshotGeneration - 1; + if (shouldClearHighlight) queueHighlight(null); state.error = null; setConnected(true); @@ -340,7 +369,7 @@ function renderTree() { matches++; } rows.push(` -
+
<${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 = `
Valdi component ${escapeHtml(node.tag)}
${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 = `
Rendered <${escapeHtml(valdiDebuggerTreeModel.formatValue(node.element?.dom?.tagName || 'div', 0))}>
${propertyRows(node.element?.dom?.attributes, { css: false })}${textContent ? `
Text content
${escapeHtml(textContent)}
` : ''}`; + const componentDetails = node.component + ? `
Valdi component ${escapeHtml(node.tag)}
${propertyRows(node.component, { css: false })}` + : ''; + elements.inspector.innerHTML = `${componentDetails}
Rendered <${escapeHtml(valdiDebuggerTreeModel.formatValue(renderedNode.element?.dom?.tagName || 'div', 0))}>
${propertyRows(renderedNode.element?.dom?.attributes, { css: false })}${textContent ? `
Text content
${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);