From 641d934337fa0dfa37fe4d960091a744e6d35b04 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 1 Sep 2026 14:55:36 +0800 Subject: [PATCH] perf(vom): optimize rendering algorithms --- packages/vom/src/__tests__/render.test.ts | 105 ++++++++++++ packages/vom/src/render.ts | 200 +++++++++++++++------- 2 files changed, 239 insertions(+), 66 deletions(-) diff --git a/packages/vom/src/__tests__/render.test.ts b/packages/vom/src/__tests__/render.test.ts index fa02ae5..a064df6 100644 --- a/packages/vom/src/__tests__/render.test.ts +++ b/packages/vom/src/__tests__/render.test.ts @@ -298,6 +298,34 @@ describe("renderVom single-layer page", () => { expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 4 }]); }); + it("renders through deeply nested transparent wrappers without exhausting the call stack", () => { + const wrapperCount = 10_000; + const nodes = [node({ id: 1, role: "RootWebArea", name: "Doc" })]; + for (let index = 0; index < wrapperCount; index += 1) { + nodes.push( + node({ + id: index + 2, + parentId: index + 1, + role: "generic", + }), + ); + } + nodes.push( + node({ + id: wrapperCount + 2, + parentId: wrapperCount + 1, + role: "button", + name: "Deep action", + tag: "button", + }), + ); + + const out = renderVom(scene(nodes)); + + expect(out.text).toContain('@e1 button "Deep action"'); + expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: wrapperCount + 2 }]); + }); + it("truncates when maxTokens is exceeded while keeping refs in sync with rendered text", () => { const out = renderVom( scene([ @@ -785,6 +813,55 @@ describe("renderVom single-layer page", () => { expect(out.text).not.toContain("北京市小客车指标调控管理信息系统]"); }); + it("derives duplicate-label context from indexed DOM ancestry without semantic proximity", () => { + const out = renderVom( + scene([ + node({ id: 1, role: "RootWebArea", name: "Doc", contextScopeId: "root" }), + node({ + id: 3, + parentId: 1, + role: "button", + name: "View", + tag: "button", + domParentId: 2, + domAncestorIds: [2, 1], + contextScopeId: "root", + }), + node({ + id: 5, + parentId: 1, + role: "button", + name: "View", + tag: "button", + domParentId: 4, + domAncestorIds: [4, 1], + contextScopeId: "root", + }), + node({ + id: 2, + parentId: 1, + role: "group", + name: "Project Alpha", + domParentId: 1, + domAncestorIds: [1], + contextScopeId: "root", + }), + node({ + id: 4, + parentId: 1, + role: "group", + name: "Project Beta", + domParentId: 1, + domAncestorIds: [1], + contextScopeId: "root", + }), + ]), + ); + + expect(out.text).toContain('@e1 button "View" [ctx: Project Alpha]'); + expect(out.text).toContain('@e2 button "View" [ctx: Project Beta]'); + }); + it("does not add low-value root context to duplicated brand links", () => { const out = renderVom( scene([ @@ -833,6 +910,34 @@ describe("renderVom single-layer page", () => { }); describe("renderVom double-layer page", () => { + it("collects blocking-layer descendants even when children precede parents in source order", () => { + const out = renderVom( + scene([ + node({ id: 1, role: "RootWebArea", name: "Doc" }), + node({ + id: 3, + parentId: 2, + role: "button", + name: "Close", + tag: "button", + paintOrder: 0, + }), + node({ + id: 2, + parentId: 1, + role: "dialog", + name: "Modal", + rect: { x: 0, y: 0, w: 1280, h: 720 }, + paintOrder: 10, + position: "fixed", + }), + ]), + ); + + expect(out.text).toContain('@e1 button "Close"'); + expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 3 }]); + }); + it("renders a blocking modal first and folds the base page into an occluded summary", () => { const out = renderVom( scene([ diff --git a/packages/vom/src/render.ts b/packages/vom/src/render.ts index b943dbd..c7c4164 100644 --- a/packages/vom/src/render.ts +++ b/packages/vom/src/render.ts @@ -421,6 +421,41 @@ function buildChildren(nodes: VomNode[]): Map { return children; } +const CONTEXT_LABEL_ROLES = new Set([ + "heading", + "article", + "section", + "group", + "region", + "row", + "form", +]); + +interface DomContextIndex { + nonBoundaryByScopeParent: Map>; + sourceOrderById: Map; +} + +function buildDomContextIndex(nodes: VomNode[]): DomContextIndex { + const nonBoundaryByScopeParent = new Map>(); + const sourceOrderById = new Map(); + for (const [sourceOrder, node] of nodes.entries()) { + sourceOrderById.set(node.id, sourceOrder); + if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node)) || isContextBoundary(node)) continue; + const parentId = node.domParentId; + if (parentId === undefined || parentId === null) continue; + let byParent = nonBoundaryByScopeParent.get(node.contextScopeId); + if (!byParent) { + byParent = new Map(); + nonBoundaryByScopeParent.set(node.contextScopeId, byParent); + } + const siblings = byParent.get(parentId) ?? []; + siblings.push(node); + byParent.set(parentId, siblings); + } + return { nonBoundaryByScopeParent, sourceOrderById }; +} + function duplicateReferenceNames(nodes: VomNode[]): Set { const counts = new Map(); for (const node of nodes) { @@ -440,13 +475,7 @@ function needsHandleContext(nodeName: string, duplicateRefNames: Set): b } function contextLabel(node: VomNode, targetName: string): string | undefined { - if ( - !["heading", "article", "section", "group", "region", "row", "form"].includes( - normalizedRole(node), - ) - ) { - return undefined; - } + if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node))) return undefined; return weakContextText( cleaned(node.name) ?? cleaned(node.text) ?? cleaned(node.nearbyText), targetName, @@ -607,19 +636,45 @@ function collectDomContext( ): ContextCandidate[] { const ancestorIds = node.domAncestorIds ?? []; if (ancestorIds.length === 0) return []; - const ancestors = new Set(ancestorIds); const contexts: ContextCandidate[] = []; + const ancestorRank = new Map(); + for (const [rank, ancestorId] of ancestorIds.entries()) { + if (!ancestorRank.has(ancestorId)) ancestorRank.set(ancestorId, rank); + } - for (const candidate of state.nodesById.values()) { + const candidates = new Map(); + const nonBoundaryByParent = state.domContextIndex.nonBoundaryByScopeParent.get( + node.contextScopeId, + ); + for (const ancestorId of ancestorRank.keys()) { + const boundary = state.nodesById.get(ancestorId); + const boundaryParentId = boundary?.domParentId; + if ( + boundary && + sharesContextScope(node, boundary) && + isContextBoundary(boundary) && + boundaryParentId !== undefined && + boundaryParentId !== null && + ancestorRank.has(boundaryParentId) + ) { + candidates.set(boundary.id, boundary); + } + for (const candidate of nonBoundaryByParent?.get(ancestorId) ?? []) { + candidates.set(candidate.id, candidate); + } + } + + const sourceOrderById = state.domContextIndex.sourceOrderById; + const orderedCandidates = [...candidates.values()].sort( + (a, b) => (sourceOrderById.get(a.id) ?? 0) - (sourceOrderById.get(b.id) ?? 0), + ); + for (const candidate of orderedCandidates) { if (candidate.id === node.id) continue; - if (!sharesContextScope(node, candidate)) continue; - if (isContextBoundary(candidate) && !ancestorIds.includes(candidate.id)) continue; const parentId = candidate.domParentId; - if (parentId === undefined || parentId === null || !ancestors.has(parentId)) continue; + if (parentId === undefined || parentId === null) continue; const label = contextLabel(candidate, nodeName); if (!label) continue; - const rank = ancestorIds.indexOf(parentId); - contexts.push({ text: label, source: "dom", order: rank < 0 ? contexts.length : rank }); + contexts.push({ text: label, source: "dom", order: ancestorRank.get(parentId) ?? 0 }); } return contexts; @@ -734,6 +789,7 @@ interface RenderState { children: Map; parentMap: Map; nodesById: Map; + domContextIndex: DomContextIndex; duplicateRefNames: Set; surfaceMap: Map; scopeMap: Map; @@ -768,20 +824,25 @@ function emitActiveScopeBlock(scope: ActiveScopeBlock, depth: number, state: Ren } } -function renderTree( - children: Map, - parentId: number | null, +function pushRenderChildren( + stack: Array<{ node: VomNode; depth: number }>, + children: readonly VomNode[], depth: number, - state: RenderState, ): void { - if (state.stopped) return; + for (let index = children.length - 1; index >= 0; index -= 1) { + stack.push({ node: children[index], depth }); + } +} - const nodes = children.get(parentId) ?? []; - for (const node of nodes) { - if (state.stopped) return; +function renderTree(children: Map, state: RenderState): void { + const stack: Array<{ node: VomNode; depth: number }> = []; + pushRenderChildren(stack, children.get(null) ?? [], 1); + + while (stack.length > 0 && !state.stopped) { + const { node, depth } = stack.pop() as { node: VomNode; depth: number }; if (!shouldRender(node)) { - renderTree(children, node.id, depth, state); + pushRenderChildren(stack, children.get(node.id) ?? [], depth); continue; } @@ -798,7 +859,7 @@ function renderTree( if (nextTokens > state.maxTokens) { state.truncated = true; state.stopped = true; - return; + break; } state.lines.push(line); @@ -823,7 +884,7 @@ function renderTree( if ((ref && shouldSkipRedundantRefChildren(node)) || shouldSkipRedundantChildren(node, state)) { continue; } - renderTree(children, node.id, depth + 1, state); + pushRenderChildren(stack, children.get(node.id) ?? [], depth + 1); } } @@ -848,12 +909,13 @@ function renderNodes( children, parentMap: buildParentMap(nodes), nodesById: new Map(nodes.map((node) => [node.id, node])), + domContextIndex: buildDomContextIndex(nodes), duplicateRefNames: duplicateReferenceNames(nodes), surfaceMap: new Map(surfaces.map((surface) => [surface.triggerId, surface])), scopeMap: new Map(activeScopeBlocks.map((scope) => [scope.triggerId, scope])), }; - renderTree(children, null, 1, state); + renderTree(children, state); return state; } @@ -922,23 +984,33 @@ function paintLineageByFrame( return lineage; } +function cachedPaintLineageByFrame( + node: VomNode, + parentMap: Map, + nodesById: Map, + cache: Map>, +): Map { + const cached = cache.get(node.id); + if (cached) return cached; + const lineage = paintLineageByFrame(node, parentMap, nodesById); + cache.set(node.id, lineage); + return lineage; +} + /** Resolve both nodes to the nearest document where their paint order is comparable. */ function comparablePaintNodes( target: VomNode, blocker: VomNode, parentMap: Map, nodesById: Map, + lineageCache: Map>, ): { target: VomNode; blocker: VomNode } | null { if (target.frameId === blocker.frameId) return { target, blocker }; - const targetLineage = paintLineageByFrame(target, parentMap, nodesById); - let blockerInFrame: VomNode | undefined = blocker; - let guard = 0; - while (blockerInFrame && guard <= parentMap.size) { - const targetInFrame = targetLineage.get(blockerInFrame.frameId); + const targetLineage = cachedPaintLineageByFrame(target, parentMap, nodesById, lineageCache); + const blockerLineage = cachedPaintLineageByFrame(blocker, parentMap, nodesById, lineageCache); + for (const [frameId, blockerInFrame] of blockerLineage) { + const targetInFrame = targetLineage.get(frameId); if (targetInFrame) return { target: targetInFrame, blocker: blockerInFrame }; - const parentId: number | null = parentMap.get(blockerInFrame.id) ?? null; - blockerInFrame = parentId === null ? undefined : nodesById.get(parentId); - guard += 1; } return null; } @@ -949,12 +1021,13 @@ function isBlockedByRegion( parentMap: Map, nodesById: Map, viewportCoverage: number, + lineageCache: Map>, ): boolean { if (target.id === blocker.id) return false; if (isAncestorOf(parentMap, blocker.id, target.id)) return false; if (isAncestorOf(parentMap, target.id, blocker.id)) return false; if (!target.rect || !blocker.rect) return false; - const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById); + const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById, lineageCache); if (!paintNodes) return false; const points = interactionPoints(target.rect); @@ -979,17 +1052,22 @@ function isBlockedByRegion( return points.some(([x, y]) => rectContains(blocker.rect as Rect, x, y)); } -function collectDescendantsOfIds(nodes: VomNode[], roots: Set): Set { +function collectDescendants(nodes: VomNode[], roots: Set): Set { + const children = new Map(); + for (const node of nodes) { + if (node.parentId === null) continue; + const siblings = children.get(node.parentId) ?? []; + siblings.push(node.id); + children.set(node.parentId, siblings); + } + const included = new Set(roots); - let changed = true; - while (changed) { - changed = false; - for (const node of nodes) { - if (included.has(node.id)) continue; - if (node.parentId !== null && included.has(node.parentId)) { - included.add(node.id); - changed = true; - } + const queue = [...roots]; + for (let index = 0; index < queue.length; index += 1) { + for (const childId of children.get(queue[index]) ?? []) { + if (included.has(childId)) continue; + included.add(childId); + queue.push(childId); } } return included; @@ -998,6 +1076,7 @@ function collectDescendantsOfIds(nodes: VomNode[], roots: Set): Set [node.id, node])); + const lineageCache = new Map>(); const candidates = nodes .filter(isPositionedRegionCandidate) .map((node) => { @@ -1014,40 +1093,29 @@ function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] { for (const target of nodes) { if (!isVomReferenceNode(target)) continue; const blocked = candidates.some((candidate) => - isBlockedByRegion(target, candidate.node, parentMap, nodesById, candidate.viewportCoverage), + isBlockedByRegion( + target, + candidate.node, + parentMap, + nodesById, + candidate.viewportCoverage, + lineageCache, + ), ); if (blocked) blockedRoots.add(target.id); } if (blockedRoots.size === 0) return nodes; - const blocked = collectDescendantsOfIds(nodes, blockedRoots); + const blocked = collectDescendants(nodes, blockedRoots); return nodes.filter((node) => !blocked.has(node.id)); } -function collectDescendantsOfMembers(nodes: VomNode[], members: Set): Set { - const included = new Set(members); - let changed = true; - - while (changed) { - changed = false; - for (const node of nodes) { - if (included.has(node.id)) continue; - if (node.parentId !== null && included.has(node.parentId)) { - included.add(node.id); - changed = true; - } - } - } - - return included; -} - function countRenderable(nodes: VomNode[]): number { return nodes.filter(shouldRender).length; } function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOptions): VomResult { - const included = collectDescendantsOfMembers(scene.nodes, layer.members); + const included = collectDescendants(scene.nodes, layer.members); const visibleNodes = scene.nodes.filter((node) => included.has(node.id)); const hiddenCount = countRenderable(scene.nodes.filter((node) => !included.has(node.id))); const header = [