From 6c879226115e3f5939401b18b0da1d4fb275a5fd Mon Sep 17 00:00:00 2001 From: Leonel Rivas Date: Sat, 8 Aug 2026 14:20:59 -0700 Subject: [PATCH] perf(sdk): reduce composition paint query work --- packages/sdk/src/adapters/iframe.test.ts | 189 ++++++++++++++++++++++- packages/sdk/src/adapters/iframe.ts | 175 +++++++++++++++++---- 2 files changed, 329 insertions(+), 35 deletions(-) diff --git a/packages/sdk/src/adapters/iframe.test.ts b/packages/sdk/src/adapters/iframe.test.ts index a285a24c86..ce3debbf35 100644 --- a/packages/sdk/src/adapters/iframe.test.ts +++ b/packages/sdk/src/adapters/iframe.test.ts @@ -1032,7 +1032,7 @@ function paintDoc(nodes: object[], readyState = "complete"): Document { } /** OffscreenCanvas stub that counts pixel reads, for the lazy-sampling guard. */ -function stubCountingCanvas(): { restore: () => void; reads: () => number } { +function stubCountingCanvas(alpha = 255): { restore: () => void; reads: () => number } { const orig = globalThis.OffscreenCanvas as typeof OffscreenCanvas | undefined; let reads = 0; globalThis.OffscreenCanvas = class { @@ -1045,7 +1045,7 @@ function stubCountingCanvas(): { restore: () => void; reads: () => number } { drawImage() {}, getImageData() { reads++; - return { data: new Uint8ClampedArray([255, 0, 0, 255]), width: 1, height: 1 }; + return { data: new Uint8ClampedArray([255, 0, 0, alpha]), width: 1, height: 1 }; }, }; } @@ -1400,6 +1400,17 @@ describe("compositionPaintsAt", () => { expect(compositionPaintsAt(paintDoc([root(), wrapper, child]), paintWin(), 50, 50)).toBe(false); }); + it("keeps ancestor memoization iterative for a deeply nested DOM", () => { + let parent: PaintNode | null = null; + for (let depth = 0; depth < 12_000; depth++) parent = pnode({ parent }); + const leaf = pnode({ + style: { backgroundColor: "#f00" }, + rect: { left: 0, top: 0, width: 10, height: 10 }, + parent, + }); + expect(compositionPaintsAt(paintDoc([leaf]), paintWin(), 5, 5)).toBe(true); + }); + it("addressableOnly:false picks up a node the stamping pass never saw", () => { // Runtime-generated nodes (split-text word spans, clones) carry no data-hf-id. const generated = pnode({ @@ -1436,6 +1447,180 @@ describe("compositionPaintsAt", () => { canvas.restore(); } }); + + it("keeps the deeper candidate first when boxes have exactly the same area", () => { + const compositionRoot = root(); + const shallow = pnode({ + style: { backgroundColor: "#f00" }, + rect: { left: 0, top: 0, width: 10, height: 10 }, + parent: compositionRoot, + }); + const wrapper = pnode({ parent: compositionRoot }); + const deep = pnode({ + style: { backgroundColor: "#0f0" }, + rect: { left: 0, top: 0, width: 10, height: 10 }, + parent: wrapper, + }); + const checked: PaintNode[] = []; + const baseWin = paintWin(); + const orderedWin = { + HTMLImageElement: baseWin.HTMLImageElement, + getComputedStyle(element: Element) { + if (element === el(shallow) || element === el(deep)) + checked.push(element as unknown as PaintNode); + return baseWin.getComputedStyle(element); + }, + } as unknown as Window & typeof globalThis; + + expect( + compositionPaintsAt(paintDoc([compositionRoot, shallow, wrapper, deep]), orderedWin, 5, 5), + ).toBe(true); + expect(checked[0]).toBe(deep); + }); + + it("continues past a non-painting minimum instead of treating one tracked minimum as final", () => { + const nodes = [ + root(), + pnode({ rect: { left: 0, top: 0, width: 10, height: 10 } }), + pnode({ + style: { backgroundColor: "#f00" }, + rect: { left: 0, top: 0, width: 20, height: 20 }, + }), + ]; + expect(compositionPaintsAt(paintDoc(nodes), paintWin(), 5, 5)).toBe(true); + }); + + it("does not retain frame geometry across calls when an animated root moves", () => { + const outer = root(); + const inner = pnode({ + attrs: { "data-composition-id": "inner" }, + rect: { left: 0, top: 0, width: 300, height: 300 }, + }); + let innerRect = domRect({ left: 0, top: 0, width: 300, height: 300 }); + inner.getBoundingClientRect = () => innerRect; + const painter = pnode({ + style: { backgroundColor: "#f00" }, + rect: { left: 0, top: 0, width: 300, height: 300 }, + }); + const doc = paintDoc([outer, inner, painter]); + + expect(compositionPaintsAt(doc, paintWin(), 150, 150, { fullBleedFraction: 0.9 })).toBe(false); + + innerRect = domRect({ left: 600, top: 600, width: 300, height: 300 }); + expect(compositionPaintsAt(doc, paintWin(), 150, 150, { fullBleedFraction: 0.9 })).toBe(true); + }); + + it("bounds the work of a 1000-node paint query while preserving lazy alpha sampling", () => { + // Captured on the pre-optimization implementation at b1f7d8881. These are deterministic + // DOM-operation counts, not wall-clock thresholds that become flaky under CI load. + const baseline = { + rectReads: 1002, + computedStyleReads: 3004, + ancestorReads: 4000, + selectorQueries: 2, + alphaReads: 1, + }; + const metrics = { + rectReads: 0, + computedStyleReads: 0, + ancestorReads: 0, + selectorQueries: 0, + }; + + const instrument = (node: PaintNode, parent: PaintNode | null): void => { + const readRect = node.getBoundingClientRect.bind(node); + node.getBoundingClientRect = () => { + metrics.rectReads++; + return readRect(); + }; + Object.defineProperty(node, "parentElement", { + configurable: true, + get() { + metrics.ancestorReads++; + return parent; + }, + }); + }; + + const benchmarkRoot = pnode({ attrs: { "data-composition-id": "main" }, rect: FRAME }); + instrument(benchmarkRoot, null); + + const winner = pimg( + "hf-winner", + { left: 0, top: 0, width: 10, height: 10 }, + { parent: benchmarkRoot, src: "http://x/winner.png" }, + ); + const winnerRect = winner.getBoundingClientRect.bind(winner); + winner.getBoundingClientRect = () => { + metrics.rectReads++; + return winnerRect(); + }; + Object.defineProperty(winner, "parentElement", { + configurable: true, + get() { + metrics.ancestorReads++; + return benchmarkRoot; + }, + }); + + const cssWinner = pnode({ + attrs: { "data-hf-id": "hf-css-winner" }, + style: { backgroundColor: "#f00" }, + rect: { left: 0, top: 0, width: 20, height: 20 }, + parent: benchmarkRoot, + }); + instrument(cssWinner, benchmarkRoot); + + const nodes: object[] = [benchmarkRoot, winner, cssWinner]; + for (let index = 0; index < 997; index++) { + const size = 30 + index / 2; + const node = pnode({ + attrs: { "data-hf-id": `hf-wrapper-${index}` }, + rect: { left: 0, top: 0, width: size, height: size }, + parent: benchmarkRoot, + }); + instrument(node, benchmarkRoot); + nodes.push(node); + } + + const baseWin = paintWin(); + const benchmarkWin = { + HTMLImageElement: baseWin.HTMLImageElement, + DOMMatrix: class {}, + getComputedStyle(element: Element) { + metrics.computedStyleReads++; + return baseWin.getComputedStyle(element); + }, + } as unknown as Window & typeof globalThis; + const baseDoc = paintDoc(nodes); + const benchmarkDoc = { + readyState: baseDoc.readyState, + querySelectorAll(selector: string) { + metrics.selectorQueries++; + return baseDoc.querySelectorAll(selector); + }, + } as unknown as Document; + + const canvas = stubCountingCanvas(0); + try { + expect( + compositionPaintsAt(benchmarkDoc, benchmarkWin, 5, 5, { fullBleedFraction: 0.9 }), + ).toBe(true); + expect(metrics).toEqual({ + rectReads: 1000, + computedStyleReads: 3, + ancestorReads: 1005, + selectorQueries: 2, + }); + expect(metrics.rectReads).toBeLessThan(baseline.rectReads); + expect(metrics.computedStyleReads).toBeLessThan(baseline.computedStyleReads); + expect(metrics.ancestorReads).toBeLessThan(baseline.ancestorReads); + expect(metrics.selectorQueries).toBe(baseline.selectorQueries); + expect(canvas.reads()).toBe(baseline.alphaReads); + } finally { + canvas.restore(); + } + }); }); describe("IframePreviewAdapter.isProvablyEmptyAt", () => { diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts index 6e5346f580..3418f892fc 100644 --- a/packages/sdk/src/adapters/iframe.ts +++ b/packages/sdk/src/adapters/iframe.ts @@ -283,14 +283,67 @@ function clamp(v: number, min: number, max: number): number { * accurate. For speculative times this is NOT seeked — WS-A1 does not mutate * the timeline; accurate out-of-band opacity queries are WS-G follow-on. */ -function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean { +interface PaintQueryReads { + readonly styles: Map; + readonly rects: Map; + readonly depths: Map; + readonly opacityVisible: Map; +} + +function computedStyle( + el: Element, + win: Window & typeof globalThis, + reads?: PaintQueryReads, +): CSSStyleDeclaration { + if (!reads) return win.getComputedStyle(el); + const cached = reads.styles.get(el); + if (cached) return cached; + const style = win.getComputedStyle(el); + reads.styles.set(el, style); + return style; +} + +function elementRect(el: Element, reads?: PaintQueryReads): DOMRect { + if (!reads) return el.getBoundingClientRect(); + const cached = reads.rects.get(el); + if (cached) return cached; + const rect = el.getBoundingClientRect(); + reads.rects.set(el, rect); + return rect; +} + +function isOpacityVisible( + el: Element, + win: Window & typeof globalThis, + reads?: PaintQueryReads, +): boolean { + if (!reads) { + let node: Element | null = el; + while (node !== null) { + if (parseFloat(computedStyle(node, win).opacity) === 0) return false; + node = node.parentElement; + } + return true; + } + + const path: Element[] = []; let node: Element | null = el; + let visible = true; while (node !== null) { - const style = win.getComputedStyle(node); - if (parseFloat(style.opacity) === 0) return false; + const cached = reads.opacityVisible.get(node); + if (cached !== undefined) { + visible = cached; + break; + } + path.push(node); + if (parseFloat(computedStyle(node, win, reads).opacity) === 0) { + visible = false; + break; + } node = node.parentElement; } - return true; + for (const traversed of path) reads.opacityVisible.set(traversed, visible); + return visible; } // ─── Image-alpha canvas cache (WS-G phase 1) ───────────────────────────────── @@ -356,10 +409,14 @@ function warnTaintOnce(src: string): void { * (returns false) when DOMMatrix is unavailable (e.g. the test env), preserving * existing behavior there. */ -function hasRotationOrSkew(el: Element | null, win: Window & typeof globalThis): boolean { +function hasRotationOrSkew( + el: Element | null, + win: Window & typeof globalThis, + reads?: PaintQueryReads, +): boolean { if (typeof win.DOMMatrix !== "function") return false; for (let node: Element | null = el; node; node = node.parentElement) { - const t = win.getComputedStyle(node).transform; + const t = computedStyle(node, win, reads).transform; if (!t || t === "none") continue; try { const m = new win.DOMMatrix(t); @@ -388,7 +445,6 @@ function hasRotationOrSkew(el: Element | null, win: Window & typeof globalThis): * `win` is the iframe's contentWindow, used to call getComputedStyle on the * element which lives in the iframe's document. */ -// fallow-ignore-next-line complexity export function imageAlphaOpaqueAt( img: HTMLImageElement, clientX: number, @@ -397,6 +453,19 @@ export function imageAlphaOpaqueAt( /** Set to true only when pixels were actually read (or the point provably missed the * rendered image). Lets callers tell a measured answer from a fail-safe assumption. */ probe?: { sampled: boolean }, +): boolean { + return imageAlphaOpaqueAtWithReads(img, clientX, clientY, win, probe); +} + +// This is the existing pixel-mapping body; the new reads argument only reuses DOM reads. +// fallow-ignore-next-line complexity +function imageAlphaOpaqueAtWithReads( + img: HTMLImageElement, + clientX: number, + clientY: number, + win: Window & typeof globalThis, + probe?: { sampled: boolean }, + reads?: PaintQueryReads, ): boolean { // Not loaded yet — treat as opaque (safe fallback) if (img.naturalWidth === 0 || img.naturalHeight === 0) return true; @@ -408,7 +477,7 @@ export function imageAlphaOpaqueAt( // rect→natural-pixel mapping below (getBoundingClientRect returns the AABB), // so we'd sample the wrong pixel. Fail safe to opaque rather than guess. // Full transform-inverse mapping is phase 2. - if (hasRotationOrSkew(img, win)) return true; + if (hasRotationOrSkew(img, win, reads)) return true; // Pathological-size guard: don't allocate a huge canvas for one hit-test. if (img.naturalWidth * img.naturalHeight > _MAX_ALPHA_TEST_PIXELS) return true; @@ -416,8 +485,8 @@ export function imageAlphaOpaqueAt( // object-fit/object-position lay the image out within the CONTENT box, not // the border box that getBoundingClientRect() returns. Inset by border + // padding so the mapping is correct for an that has a border or padding. - const rect = img.getBoundingClientRect(); - const style = win.getComputedStyle(img); + const rect = elementRect(img, reads); + const style = computedStyle(img, win, reads); const borderL = parseFloat(style.borderLeftWidth) || 0; const borderT = parseFloat(style.borderTopWidth) || 0; const borderR = parseFloat(style.borderRightWidth) || 0; @@ -590,8 +659,8 @@ function hasVisibleBorder(cs: CSSStyleDeclaration): boolean { } /** Computed-style ink: background, border, or the element's own text. */ -function styleInk(el: Element, win: Window & typeof globalThis): InkKind { - const cs = win.getComputedStyle(el); +function styleInk(el: Element, win: Window & typeof globalThis, reads?: PaintQueryReads): InkKind { + const cs = computedStyle(el, win, reads); if (!isTransparentColor(cs.backgroundColor)) return "inferred"; if (cs.backgroundImage && cs.backgroundImage !== "none") return "inferred"; @@ -604,28 +673,29 @@ function inkAt( el: Element, win: Window & typeof globalThis, point?: { x: number; y: number }, + reads?: PaintQueryReads, ): InkKind { const tag = el.tagName.toLowerCase(); if (tag === "picture") { const img = el.querySelector("img"); - return img ? inkAt(img, win, point) : "inferred"; + return img ? inkAt(img, win, point, reads) : "inferred"; } if (point && win.HTMLImageElement && el instanceof win.HTMLImageElement) { const probe = { sampled: false }; - if (imageAlphaOpaqueAt(el, point.x, point.y, win, probe)) { + if (imageAlphaOpaqueAtWithReads(el, point.x, point.y, win, probe, reads)) { // An unsampled "opaque" is the fail-safe, not a measurement, so it stays inferred. return probe.sampled ? "verified" : "inferred"; } // A clear pixel does not settle the element: its own background plate, padding and // border still paint, and the point may have landed on them rather than the bitmap. - return styleInk(el, win); + return styleInk(el, win, reads); } if (INTRINSIC_PAINT_TAGS.has(tag)) return "inferred"; - return styleInk(el, win); + return styleInk(el, win, reads); } export function elementPaintsInk( @@ -656,10 +726,10 @@ interface PaintCandidate { * Infinity when no root contains the point, which disables the full-bleed rule rather * than guessing at a frame. */ -function compositionFrameArea(doc: Document, x: number, y: number): number { +function compositionFrameArea(doc: Document, x: number, y: number, reads: PaintQueryReads): number { let smallest = Infinity; doc.querySelectorAll("[data-composition-id]").forEach((root) => { - const rect = root.getBoundingClientRect(); + const rect = elementRect(root, reads); if (rect.width <= 0 || rect.height <= 0) return; if (!boxContains(rect, x, y)) return; smallest = Math.min(smallest, rect.width * rect.height); @@ -680,16 +750,42 @@ function boxContains(rect: DOMRect, x: number, y: number): boolean { * the ancestor chain — a fully opaque child inside a fade-in wrapper that has not started * yet is invisible, and getComputedStyle does not multiply the cascade for us. */ -function isRenderedVisible(el: Element, win: Window & typeof globalThis): boolean { - const cs = win.getComputedStyle(el); +function isRenderedVisible( + el: Element, + win: Window & typeof globalThis, + reads?: PaintQueryReads, +): boolean { + const cs = computedStyle(el, win, reads); if (cs.display === "none" || cs.visibility === "hidden") return false; - return isOpacityVisible(el, win); + return isOpacityVisible(el, win, reads); } -function depthOf(el: Element): number { - let depth = 0; - for (let p = el.parentElement; p; p = p.parentElement) depth++; - return depth; +function depthOf(el: Element, reads?: PaintQueryReads): number { + if (!reads) { + let depth = 0; + for (let parent = el.parentElement; parent; parent = parent.parentElement) depth++; + return depth; + } + + const path: Element[] = []; + let node: Element | null = el; + let depth = -1; + while (node !== null) { + const cached = reads.depths.get(node); + if (cached !== undefined) { + depth = cached; + break; + } + path.push(node); + node = node.parentElement; + } + for (let index = path.length - 1; index >= 0; index--) { + const traversed = path[index]; + if (!traversed) continue; + depth++; + reads.depths.set(traversed, depth); + } + return reads.depths.get(el) ?? 0; } /** @@ -699,20 +795,19 @@ function depthOf(el: Element): number { */ function paintCandidateAt( el: Element, - win: Window & typeof globalThis, x: number, y: number, + reads: PaintQueryReads, ): PaintCandidate | null { // Composition roots are candidates like anything else: a root carrying a background is // painting, and at fullBleedFraction 0 the literal ink question has to say so. They are // not excluded here because the veto already handles them — a root's box IS the frame, // so any non-zero fraction discounts it as background without a special case. - const rect = el.getBoundingClientRect(); + const rect = elementRect(el, reads); if (rect.width <= 0 || rect.height <= 0) return null; if (!boxContains(rect, x, y)) return null; - if (!isRenderedVisible(el, win)) return null; - return { el, area: rect.width * rect.height, depth: depthOf(el) }; + return { el, area: rect.width * rect.height, depth: depthOf(el, reads) }; } /** @@ -749,22 +844,36 @@ export function compositionPaintsAt( opts?: PaintQueryOptions, ): boolean { const selector = (opts?.addressableOnly ?? true) ? "[data-hf-id]" : "*"; + // A paint query observes one layout snapshot. Cache DOM reads only for this invocation: + // compositions animate, so retaining rects/styles across frames would return stale answers. + const reads: PaintQueryReads = { + styles: new Map(), + rects: new Map(), + depths: new Map(), + opacityVisible: new Map(), + }; const candidates: PaintCandidate[] = []; doc.querySelectorAll(selector).forEach((el) => { - const candidate = paintCandidateAt(el, win, x, y); + const candidate = paintCandidateAt(el, x, y, reads); if (candidate) candidates.push(candidate); }); // Smallest area first, deeper element first on an exact tie. Compared exactly rather // than within an epsilon: a "close enough" tie relation is intransitive, which makes // Array.sort's output implementation-defined and the smallest-first guarantee (and the - // lazy single-sample property that rides on it) engine-dependent. + // lazy single-sample property that rides on it) engine-dependent. A single tracked + // minimum cannot replace the sort: when that element is transparent or hidden, finding + // the next candidate without sorting either rescans (quadratic worst case) or samples ink + // eagerly (breaking the lazy-alpha contract). candidates.sort((a, b) => a.area - b.area || b.depth - a.depth); let winner: PaintCandidate | undefined; let winnerInk: InkKind = "none"; for (const candidate of candidates) { - const ink = inkAt(candidate.el, win, { x, y }); + // Visibility is deliberately beside the lazy ink test: resolving every contained + // element's style before sorting dominates this query on large compositions. + if (!isRenderedVisible(candidate.el, win, reads)) continue; + const ink = inkAt(candidate.el, win, { x, y }, reads); if (ink === "none") continue; winner = candidate; winnerInk = ink; @@ -781,7 +890,7 @@ export function compositionPaintsAt( // area, and answering "background" there makes visible artwork unclickable. if (winnerInk === "verified") return true; - const frameArea = compositionFrameArea(doc, x, y); + const frameArea = compositionFrameArea(doc, x, y, reads); return frameArea === Infinity || winner.area < fullBleed * frameArea; }