From f1e8374dd1338d213aadfb458c3dd5be75e52a3a Mon Sep 17 00:00:00 2001 From: wizardchen Date: Thu, 17 Sep 2026 17:30:11 +0800 Subject: [PATCH] perf(vom): reuse sibling context for repeated action labels --- .github/workflows/ci.yml | 3 ++ packages/vom/src/__tests__/render.test.ts | 25 +++++++++++++++ packages/vom/src/render.ts | 38 ++++++++++++++++++++--- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07bbf7bd..b7e044d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,9 @@ jobs: - name: Run localization tests run: pnpm --filter @browser-skill/i18n test + - name: Run VOM tests + run: pnpm --filter @browser-skill/vom test + - name: Run extension tests run: pnpm ext:test diff --git a/packages/vom/src/__tests__/render.test.ts b/packages/vom/src/__tests__/render.test.ts index 0223a80f..bf7ff797 100644 --- a/packages/vom/src/__tests__/render.test.ts +++ b/packages/vom/src/__tests__/render.test.ts @@ -52,6 +52,31 @@ describe("renderVom single-layer page", () => { expect(out.text).toContain('@e1 checkbox "Subscribe"' + marker + ' ="on"'); expect(out.refs).toHaveLength(1); }); + it.each([100, 600, 2000])("bounds sibling-context reads for %i repeated actions", (count) => { + let nameReads = 0; + const nodes = [node({ id: 1, role: "RootWebArea" })]; + for (let i = 0; i < count; i++) { + const label = node({ id: 2 + i * 2, parentId: 1, role: "StaticText" }); + Object.defineProperty(label, "name", { + enumerable: true, + get() { + nameReads++; + return `Record ${i}`; + }, + }); + nodes.push( + label, + node({ id: 3 + i * 2, parentId: 1, tag: "button", role: "button", name: "Open" }), + ); + } + const out = renderVom(scene(nodes)); + expect(out.refs).toHaveLength(count); + expect(out.refs.map((ref) => ref.ctx)).toEqual( + Array.from({ length: count }, (_, index) => `Record ${Math.max(0, index - 2)}`), + ); + // Count semantic reads rather than asserting a machine-dependent duration. + expect(nameReads).toBeLessThan(count * 10); + }); it("does not derive handle context across frame scopes", () => { const out = renderVom( diff --git a/packages/vom/src/render.ts b/packages/vom/src/render.ts index 77c10c6b..c66e65d7 100644 --- a/packages/vom/src/render.ts +++ b/packages/vom/src/render.ts @@ -614,11 +614,32 @@ function collectSameContainerContext( let guard = 0; while (parentId !== null && guard <= state.parentMap.size) { const siblings = state.children.get(parentId) ?? []; - for (const sibling of siblings) { - if (sibling.id === childId) break; - collectWeakLabelsFromSubtree(sibling, node, nodeName, state, labels); - while (labels.length > MAX_HANDLE_CONTEXT_ITEMS) labels.shift(); + const end = state.siblingIndex.get(childId) ?? siblings.length; + let cache = state.siblingContextCache.get(parentId); + if (!cache) { + cache = new Map(); + state.siblingContextCache.set(parentId, cache); } + // The subtree collector depends on scope, target name and incoming labels. + // Include all three so descendants arriving from different containers retain + // the original context semantics. DFS visits sibling prefixes in order. + const key = JSON.stringify([node.contextScopeId, nodeName, labels]); + let prefix = cache.get(key); + if (!prefix || prefix.nextIndex > end) prefix = { nextIndex: 0, labels: [...labels] }; + for (; prefix.nextIndex < end; prefix.nextIndex++) { + collectWeakLabelsFromSubtree( + siblings[prefix.nextIndex], + node, + nodeName, + state, + prefix.labels, + ); + while (prefix.labels.length > MAX_HANDLE_CONTEXT_ITEMS) prefix.labels.shift(); + } + labels.splice(0, labels.length, ...prefix.labels); + // Bound retained variants on pages with many distinct action names. + if (!cache.has(key) && cache.size >= 64) cache.delete(cache.keys().next().value!); + cache.set(key, prefix); const parent = state.nodesById.get(parentId); if (!parent || !sharesContextScope(node, parent) || isContextBoundary(parent)) break; @@ -791,6 +812,8 @@ interface RenderState { redactValues: boolean; truncated: boolean; children: Map; + siblingIndex: Map; + siblingContextCache: Map>; parentMap: Map; nodesById: Map; domContextIndex: DomContextIndex; @@ -907,8 +930,15 @@ function createRenderState( activeScopeBlocks: ActiveScopeBlock[], ): RenderState { const children = buildChildren(nodes); + const siblingIndex = new Map(); + for (const siblings of children.values()) { + for (let index = 0; index < siblings.length; index++) + siblingIndex.set(siblings[index].id, index); + } const state: RenderState = { visualAncestors: new Set(), + siblingIndex, + siblingContextCache: new Map(), lines: [...initialLines], refs: [], nextRef: 1,