From 85dd4f7007bfb8ef06c7fe555f46314326b769d8 Mon Sep 17 00:00:00 2001 From: Xiao Yang Date: Sat, 18 Apr 2026 00:44:02 +0800 Subject: [PATCH 1/4] perf(highlight): cache layout reads + spatial-index pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab init's two heaviest phases share the same shape: per-element loops that re-do work the previous step already paid for. Cut both. Scanner (highlight-detection.injected.js): wrap collectHighlightCandidates in withScanLayoutCache, which monkey-patches Element.prototype.getBoundingClientRect, SVGGraphicsElement.prototype.getBoundingClientRect, window.getComputedStyle, and Document.prototype.elementsFromPoint with per-scan WeakMap/Map caches. The scan runs in one synchronous Runtime.evaluate, so layout cannot change mid-task and caching is safe; originals are restored in finally. Also skip inert tags (script/style/meta/...) before the first layout read. Pagination (collision-detection.ts): SelectedSpatialIndex (96px grid) keyed on union(bbox, labelBBox) of placed elements. isPlacementFeasible now iterates only nearby placed elements via nearbySelectedFor, which queries by inflate (union(candidate.bbox, candidate.labelBBox), CLEARANCE) — covering all four collision tests. chooseLeastBlockingPlacement also uses an "influence rect" to skip re-evaluating spatially-far future candidates when a hypothetical placement cannot affect them. Measured (best run, fresh tab init): - finviz.com (349 elements): 17.8s -> 13.7s (-23%) - bluebook mock (50): 6.3s -> 5.4s (-14%) - techforum mock (34): 4.3s -> 3.9s (-11%) - 16 mock sites aggregate: -4% to -14% Correctness: - 181/181 extension unit tests pass. - Strict integration check (selector + type + labelPosition + bbox + element ORDER) passes on all 16 deterministic mock sites. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../commands/highlight-detection.injected.js | 112 ++++++++ extension/src/utils/collision-detection.ts | 269 ++++++++++++++++-- 2 files changed, 354 insertions(+), 27 deletions(-) diff --git a/extension/src/commands/highlight-detection.injected.js b/extension/src/commands/highlight-detection.injected.js index 72f3e8e..95ef232 100644 --- a/extension/src/commands/highlight-detection.injected.js +++ b/extension/src/commands/highlight-detection.injected.js @@ -77,6 +77,106 @@ function hasCallableMethod(value, methodNames) { ); } +// Layout reads (getBoundingClientRect, getComputedStyle) and elementsFromPoint +// are the single biggest cost in collectHighlightCandidates: every visibility +// predicate re-reads them for the same element. Within one synchronous +// Runtime.evaluate task no page JS runs concurrently, so the values cannot +// change mid-scan. We monkey-patch the prototypes for the duration of one +// scan, populate a per-element WeakMap, and restore originals at the end. +const SCAN_NON_INTERACTIVE_TAGS = new Set([ + 'script', + 'style', + 'link', + 'meta', + 'head', + 'title', + 'noscript', + 'br', + 'hr', + 'source', + 'track', + 'template', + 'param', + 'col', + 'colgroup', +]); + +function isScanSkippableTag(el) { + if (!el || !el.tagName) return false; + return SCAN_NON_INTERACTIVE_TAGS.has(el.tagName.toLowerCase()); +} + +function withScanLayoutCache(fn) { + const rectCache = new WeakMap(); + const styleCache = new WeakMap(); + // elementsFromPoint dedup keyed by rounded "x:y" + const efpCache = new Map(); + + const origElementRect = Element.prototype.getBoundingClientRect; + const SVGGraphicsProto = + typeof SVGGraphicsElement !== 'undefined' + ? SVGGraphicsElement.prototype + : null; + const origSVGRect = + SVGGraphicsProto && SVGGraphicsProto.getBoundingClientRect; + const origGetComputedStyle = window.getComputedStyle; + // Patch Document.prototype rather than the document instance so we don't + // leave an own-property shadowing the prototype after the scan finishes. + const DocumentProto = + typeof Document !== 'undefined' ? Document.prototype : null; + const origElementsFromPoint = + DocumentProto && DocumentProto.elementsFromPoint; + + function patchedRect() { + let r = rectCache.get(this); + if (r === undefined) { + r = origElementRect.call(this); + rectCache.set(this, r); + } + return r; + } + + Element.prototype.getBoundingClientRect = patchedRect; + if (SVGGraphicsProto && origSVGRect) { + SVGGraphicsProto.getBoundingClientRect = patchedRect; + } + + window.getComputedStyle = function (el, pseudo) { + if (pseudo) return origGetComputedStyle.call(window, el, pseudo); + let s = styleCache.get(el); + if (s === undefined) { + s = origGetComputedStyle.call(window, el); + styleCache.set(el, s); + } + return s; + }; + + if (DocumentProto && origElementsFromPoint) { + DocumentProto.elementsFromPoint = function (x, y) { + const key = Math.round(x) + ':' + Math.round(y); + let stack = efpCache.get(key); + if (stack === undefined) { + stack = origElementsFromPoint.call(this, x, y); + efpCache.set(key, stack); + } + return stack; + }; + } + + try { + return fn(); + } finally { + Element.prototype.getBoundingClientRect = origElementRect; + if (SVGGraphicsProto && origSVGRect) { + SVGGraphicsProto.getBoundingClientRect = origSVGRect; + } + window.getComputedStyle = origGetComputedStyle; + if (DocumentProto && origElementsFromPoint) { + DocumentProto.elementsFromPoint = origElementsFromPoint; + } + } +} + function createHighlightTrace() { const traceStart = performance.now(); @@ -2473,6 +2573,12 @@ function collectUploadableCandidates(trace) { } function collectHighlightCandidates(config, trace, layoutStability) { + return withScanLayoutCache(() => + collectHighlightCandidatesImpl(config, trace, layoutStability), + ); +} + +function collectHighlightCandidatesImpl(config, trace, layoutStability) { const activeTopLayerRoot = getActiveTopLayerRoot(); const registry = new Map(); @@ -2529,6 +2635,12 @@ function collectHighlightCandidates(config, trace, layoutStability) { ); } + // Tag-only fast reject before any layout read. Saves rect/style work on + // the long tail of inert markup (script/style/meta/...). + if (isScanSkippableTag(element)) { + continue; + } + if (!isElementInViewportForDetection(element)) { continue; } diff --git a/extension/src/utils/collision-detection.ts b/extension/src/utils/collision-detection.ts index 054abc2..da4911a 100644 --- a/extension/src/utils/collision-detection.ts +++ b/extension/src/utils/collision-detection.ts @@ -36,6 +36,108 @@ interface RemainingCandidate { element: InteractiveElement; } +// Coarse spatial grid used to skip O(N) scans of `selected` and `remaining` +// when checking collisions. Cell size is a heuristic — large enough that most +// label rects touch only a couple of cells, small enough that a typical +// query returns far fewer than the full set. +const SPATIAL_INDEX_CELL_PX = 96; + +class SelectedSpatialIndex { + private cells = new Map(); + + add(element: InteractiveElement): void { + const labelBBox = getLabelBBox( + element.bbox, + element.labelPosition ?? 'above', + element.id, + ); + const union = unionBBox(element.bbox, labelBBox); + this.forEachCell(union, (key) => { + let bucket = this.cells.get(key); + if (!bucket) { + bucket = []; + this.cells.set(key, bucket); + } + // Avoid duplicate registration when a single element straddles cells we + // visit out of order — the per-call dedup Set in queryNear handles dup + // results across cells. + if (bucket[bucket.length - 1] !== element) { + bucket.push(element); + } + }); + } + + // Returns elements whose registered union-rect lies in any cell touched by + // the query rect (inflated by clearance on each side). Includes elements + // whose registration cells are *adjacent* to the query rect — see + // `queryNear` callers, which already inflate the query rect with clearance. + queryNear(query: BBox): InteractiveElement[] { + const seen = new Set(); + const out: InteractiveElement[] = []; + this.forEachCell(query, (key) => { + const bucket = this.cells.get(key); + if (!bucket) return; + for (const el of bucket) { + if (!seen.has(el)) { + seen.add(el); + out.push(el); + } + } + }); + return out; + } + + private forEachCell(rect: BBox, fn: (key: number) => void): void { + // Real bboxes from getBoundingClientRect are always finite, but synthetic + // test inputs or future callers might pass NaN/Infinity. Without this + // guard Math.floor would yield NaN, the loop would skip, and we'd + // silently drop a registration — masking real collisions. + if ( + !Number.isFinite(rect.x) || + !Number.isFinite(rect.y) || + !Number.isFinite(rect.width) || + !Number.isFinite(rect.height) + ) { + // Single sentinel cell so the registration is still discoverable. + fn(Number.MIN_SAFE_INTEGER); + return; + } + const minCx = Math.floor(rect.x / SPATIAL_INDEX_CELL_PX); + const maxCx = Math.floor( + (rect.x + Math.max(0, rect.width)) / SPATIAL_INDEX_CELL_PX, + ); + const minCy = Math.floor(rect.y / SPATIAL_INDEX_CELL_PX); + const maxCy = Math.floor( + (rect.y + Math.max(0, rect.height)) / SPATIAL_INDEX_CELL_PX, + ); + for (let cy = minCy; cy <= maxCy; cy++) { + for (let cx = minCx; cx <= maxCx; cx++) { + // Cantor-pair-ish key: cy gets the high bits, cx the low bits. + // Negative coords are uncommon for label rects but still encode safely + // because Math.floor preserves order under shift. + fn(cy * 100000 + cx); + } + } + } +} + +function unionBBox(a: BBox, b: BBox): BBox { + const x = Math.min(a.x, b.x); + const y = Math.min(a.y, b.y); + const xMax = Math.max(a.x + a.width, b.x + b.width); + const yMax = Math.max(a.y + a.height, b.y + b.height); + return { x, y, width: xMax - x, height: yMax - y }; +} + +function inflateBBox(rect: BBox, padding: number): BBox { + return { + x: rect.x - padding, + y: rect.y - padding, + width: rect.width + 2 * padding, + height: rect.height + 2 * padding, + }; +} + interface PlacementEvaluation { position: LabelPosition; blockedCandidateCount: number; @@ -302,12 +404,14 @@ function buildCollisionFreePages( while (remaining.length > 0) { const selected: InteractiveElement[] = []; + const selectedIndex = new SelectedSpatialIndex(); let pageRemaining = remaining; while (pageRemaining.length > 0) { const nextSelection = chooseNextCandidate( pageRemaining, selected, + selectedIndex, viewportWidth, viewportHeight, ); @@ -316,10 +420,12 @@ function buildCollisionFreePages( break; } - selected.push({ + const placed: InteractiveElement = { ...nextSelection.candidate.element, labelPosition: nextSelection.position, - }); + }; + selected.push(placed); + selectedIndex.add(placed); pageRemaining = pageRemaining.filter( (candidate) => candidate.sourceIndex !== nextSelection.candidate.sourceIndex, @@ -347,14 +453,16 @@ function tryBuildUniformPositionPage( viewportHeight?: number, ): InteractiveElement[] | null { const selected: InteractiveElement[] = []; + const index = new SelectedSpatialIndex(); for (const element of elements) { + const nearby = nearbySelectedFor(element, position, element.id, index); if ( !isPlacementFeasible( element, element.id, position, - selected, + nearby, viewportWidth, viewportHeight, ) @@ -362,10 +470,12 @@ function tryBuildUniformPositionPage( return null; } - selected.push({ + const placed: InteractiveElement = { ...element, labelPosition: position, - }); + }; + selected.push(placed); + index.add(placed); } return selected; @@ -374,6 +484,7 @@ function tryBuildUniformPositionPage( function chooseNextCandidate( remaining: RemainingCandidate[], selected: InteractiveElement[], + selectedIndex: SelectedSpatialIndex, viewportWidth?: number, viewportHeight?: number, ): (PlacementEvaluation & { candidate: RemainingCandidate }) | null { @@ -388,6 +499,7 @@ function chooseNextCandidate( candidate.element, candidate.element.id, selected, + selectedIndex, viewportWidth, viewportHeight, ); @@ -415,6 +527,7 @@ function chooseNextCandidate( constrainedCandidate.feasiblePositions, remaining, selected, + selectedIndex, viewportWidth, viewportHeight, ), @@ -426,6 +539,7 @@ function chooseLeastBlockingPlacement( feasiblePositions: LabelPosition[], remaining: RemainingCandidate[], selected: InteractiveElement[], + selectedIndex: SelectedSpatialIndex, viewportWidth?: number, viewportHeight?: number, ): PlacementEvaluation { @@ -435,31 +549,106 @@ function chooseLeastBlockingPlacement( ); let bestPlacement: PlacementEvaluation | null = null; + // Pre-compute each future candidate's baseline feasible positions against + // the current `selected` set. When we test a hypothetical placement of + // `candidate@position`, only future candidates whose bbox/label is + // geometrically near that placement can have their feasibility change. The + // rest keep their baseline feasibility — saving the O(|future|×4×|selected|) + // recomputation per position. + interface FutureBaseline { + candidate: RemainingCandidate; + elementUnion: BBox; // bbox ∪ all four label rects + feasibleCount: number; + totalLength: number; + } + const futureBaselines: FutureBaseline[] = futureCandidates.map((fc) => { + const baseline = getFeasiblePositions( + fc.element, + fc.element.id, + selected, + selectedIndex, + viewportWidth, + viewportHeight, + ); + let union = fc.element.bbox; + for (const pos of POSITION_PRIORITY) { + union = unionBBox(union, getLabelBBox(fc.element.bbox, pos, fc.element.id)); + } + return { + candidate: fc, + elementUnion: union, + feasibleCount: baseline.length, + totalLength: baseline.length, + }; + }); + + const baselineBlockedCount = futureBaselines.reduce( + (acc, fb) => (fb.feasibleCount === 0 ? acc + 1 : acc), + 0, + ); + const baselineTotalOptions = futureBaselines.reduce( + (acc, fb) => acc + fb.totalLength, + 0, + ); + for (const position of feasiblePositions) { - const hypotheticalSelected = [ - ...selected, - { - ...candidate.element, - labelPosition: position, - }, - ]; - let blockedCandidateCount = 0; - let totalFutureOptions = 0; - - futureCandidates.forEach((candidate) => { - const futureOptions = getFeasiblePositions( - candidate.element, - candidate.element.id, - hypotheticalSelected, - viewportWidth, - viewportHeight, - ); + const hypotheticalElement: InteractiveElement = { + ...candidate.element, + labelPosition: position, + }; + const hypotheticalLabelBBox = getLabelBBox( + candidate.element.bbox, + position, + candidate.element.id, + ); + // Influence rect: anything whose elementUnion does NOT intersect this + // (inflated by clearance) cannot be affected by adding the hypothetical + // candidate. We only need to recompute for future candidates inside it. + const influenceRect = inflateBBox( + unionBBox(candidate.element.bbox, hypotheticalLabelBBox), + VISUAL_LABEL_CLEARANCE_PX, + ); - if (futureOptions.length === 0) { + let blockedCandidateCount = baselineBlockedCount; + let totalFutureOptions = baselineTotalOptions; + + for (const fb of futureBaselines) { + if (!bboxesIntersect(fb.elementUnion, influenceRect)) { + continue; + } + // Feasibility can change for this future candidate. Re-test against + // the spatially-near selected set plus the hypothetical candidate. + let updatedFeasibleLen = 0; + for (const pos of POSITION_PRIORITY) { + const nearby = nearbySelectedFor( + fb.candidate.element, + pos, + fb.candidate.element.id, + selectedIndex, + [hypotheticalElement], + ); + if ( + isPlacementFeasible( + fb.candidate.element, + fb.candidate.element.id, + pos, + nearby, + viewportWidth, + viewportHeight, + ) + ) { + updatedFeasibleLen++; + } + } + + // Adjust baseline aggregates for the delta on this single future. + if (fb.feasibleCount === 0 && updatedFeasibleLen > 0) { + blockedCandidateCount--; + } else if (fb.feasibleCount > 0 && updatedFeasibleLen === 0) { blockedCandidateCount++; } - totalFutureOptions += futureOptions.length; - }); + totalFutureOptions += updatedFeasibleLen - fb.totalLength; + } if ( !bestPlacement || @@ -492,18 +681,22 @@ function getFeasiblePositions( element: InteractiveElement, labelText: string, selected: InteractiveElement[], + selectedIndex: SelectedSpatialIndex | null, viewportWidth?: number, viewportHeight?: number, ): LabelPosition[] { const feasiblePositions: LabelPosition[] = []; for (const position of POSITION_PRIORITY) { + const nearby = selectedIndex + ? nearbySelectedFor(element, position, labelText, selectedIndex) + : selected; if ( isPlacementFeasible( element, labelText, position, - selected, + nearby, viewportWidth, viewportHeight, ) @@ -515,6 +708,28 @@ function getFeasiblePositions( return feasiblePositions; } +// Returns the subset of `selected` that could plausibly collide with the +// candidate placement. The query rect is the union of the candidate's bbox +// and its label rect for the requested position, inflated by the visible +// clearance threshold. Optional `extras` are appended (e.g. a hypothetical +// candidate not yet inserted into the index). +function nearbySelectedFor( + element: InteractiveElement, + position: LabelPosition, + labelText: string, + index: SelectedSpatialIndex, + extras: InteractiveElement[] = [], +): InteractiveElement[] { + const labelBBox = getLabelBBox(element.bbox, position, labelText); + const query = inflateBBox( + unionBBox(element.bbox, labelBBox), + VISUAL_LABEL_CLEARANCE_PX, + ); + const near = index.queryNear(query); + if (extras.length === 0) return near; + return near.concat(extras); +} + function isPlacementFeasible( element: InteractiveElement, labelText: string, From 9e9cc021fab4e155f67376f41b8ff18a7bbc7ea7 Mon Sep 17 00:00:00 2001 From: Xiao Yang Date: Sat, 18 Apr 2026 01:18:07 +0800 Subject: [PATCH 2/4] perf(highlight): cache resolve-phase classifiers + emit perf breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pagination win revealed that scan-phase resolve was the new bottleneck: on finviz the resolve phase alone was 6.1s of a 6.5s scan. Per candidate, resolveClickableCandidate walks up to 5 ancestors, each calling isClickableCandidate, which calls hasExplicitClickableAncestor that walks ALL ancestors back to body, calling getSemanticClickableSignal at each. For deep DOM (finviz tables) the same elements were classified dozens of times per scan. Add per-scan WeakMap memoization (cleared by withScanLayoutCache) for the classifiers that are pure functions of element + DOM state: - getSemanticClickableSignal - isClickableCandidate - getBaseClickableSignal - hasExplicitClickableAncestor - getElementTextForDetection (textContent walk) - getElementSearchText Also add scan_stats / scan_times to the response payload so harness/tooling can attribute time per phase without parsing console output. Measured (best run, finviz.com/screener.ashx, ~349 candidates): - in-page scan: 6537ms -> 585ms (-91%, ~11x) - pagination: 397ms -> 300ms (already optimized in prior commit) - end-to-end: 17787ms -> 4975ms (-72%, ~3.6x) Resolve-phase breakdown after caching: 6121ms -> 51ms. Correctness: 181/181 unit tests pass. Strict integration check (selector, type, labelPosition, bbox, element ORDER) passes on all 16 deterministic mock sites — same elements, same labels, same order. finviz_real returns identical 336/6/138 element/page/page1 counts. Caching is safe because the scan runs in one synchronous Runtime.evaluate call and these classifiers depend only on DOM state that cannot mutate during the scan. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/background/index.ts | 21 ++- .../commands/highlight-detection.injected.js | 170 ++++++++++++++++-- 2 files changed, 175 insertions(+), 16 deletions(-) diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index b35132c..5228fd9 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -583,7 +583,11 @@ async function captureHighlightedPageState( : ''; const detectedViewport = detectionResult.result.value.viewport || {}; const layoutStability = detectionResult.result.value.layoutStability; + const inPagePerf = detectionResult.result.value._perf || {}; const highlightTraceStart = Date.now(); + let paginationMs = 0; + let screenshotMs = 0; + let consistencyMs = 0; const detectedViewportWidth = typeof detectedViewport.width === 'number' ? detectedViewport.width : 0; const detectedViewportHeight = @@ -656,8 +660,9 @@ async function captureHighlightedPageState( console.log( `📄 [${logLabel}] Page ${page}/${totalPages}, showing ${paginatedElements.length} of ${filteredElements.length} elements`, ); + paginationMs = Date.now() - paginationBuildStart; console.log( - `⏱️ [HighlightTrace] background pagination build-pages=${Date.now() - paginationBuildStart}ms (page=${page}, viewport=${detectedViewportWidth}x${detectedViewportHeight})`, + `⏱️ [HighlightTrace] background pagination build-pages=${paginationMs}ms (page=${page}, viewport=${detectedViewportWidth}x${detectedViewportHeight})`, ); } @@ -702,8 +707,9 @@ async function captureHighlightedPageState( console.log( `📸 [${logLabel}] Screenshot captured (with in-page highlights), size: ${screenshotResult.imageData.length} bytes`, ); + screenshotMs = Date.now() - screenshotStart; console.log( - `⏱️ [HighlightTrace] background screenshot ${Date.now() - screenshotStart}ms`, + `⏱️ [HighlightTrace] background screenshot ${screenshotMs}ms`, ); // Apply bboxes returned from the highlight injection script @@ -766,8 +772,9 @@ async function captureHighlightedPageState( })), currentConsistencySamples, ); + consistencyMs = Date.now() - consistencyCheckStart; console.log( - `⏱️ [HighlightTrace] background consistency-check ${Date.now() - consistencyCheckStart}ms (checked=${highlightConsistency.checkedCount}, matched=${highlightConsistency.matchedCount}, missing=${highlightConsistency.missingCount}, shifted=${highlightConsistency.shiftedCount}, maxCenterShift=${highlightConsistency.maxCenterShift}, maxSizeDelta=${highlightConsistency.maxSizeDelta}, retry=${highlightConsistency.shouldRetry})`, + `⏱️ [HighlightTrace] background consistency-check ${consistencyMs}ms (checked=${highlightConsistency.checkedCount}, matched=${highlightConsistency.matchedCount}, missing=${highlightConsistency.missingCount}, shifted=${highlightConsistency.shiftedCount}, maxCenterShift=${highlightConsistency.maxCenterShift}, maxSizeDelta=${highlightConsistency.maxSizeDelta}, retry=${highlightConsistency.shouldRetry})`, ); const repeatedDrift = isRepeatedHighlightDrift( highlightConsistency, @@ -841,6 +848,14 @@ async function captureHighlightedPageState( page: currentPage, pageState, readinessReasons, + _perf: { + scan_ms: typeof inPagePerf.scan_ms === 'number' ? inPagePerf.scan_ms : 0, + scan_stats: inPagePerf.scan_stats || {}, + scan_times: inPagePerf.scan_times || {}, + pagination_ms: paginationMs, + screenshot_ms: screenshotMs, + consistency_ms: consistencyMs, + }, ...buildScreenshotPayload(compressedScreenshotResult), }; } diff --git a/extension/src/commands/highlight-detection.injected.js b/extension/src/commands/highlight-detection.injected.js index 95ef232..9581d94 100644 --- a/extension/src/commands/highlight-detection.injected.js +++ b/extension/src/commands/highlight-detection.injected.js @@ -106,11 +106,29 @@ function isScanSkippableTag(el) { return SCAN_NON_INTERACTIVE_TAGS.has(el.tagName.toLowerCase()); } +// Per-scan memoization caches for pure-function classifiers that get hit many +// times for the same element during the resolve phase (each candidate walks +// up to 5 ancestors, each ancestor calls hasExplicitClickableAncestor which +// walks ALL ancestors, etc.). Reset at the start of each scan, leak nothing +// outside it. WeakMap so any GC'd nodes drop out automatically. +let _scanSemanticSignalCache = null; +let _scanClickableCandidateCache = null; +let _scanBaseClickableSignalCache = null; +let _scanTextContentCache = null; +let _scanSearchTextCache = null; +let _scanExplicitAncestorCache = null; + function withScanLayoutCache(fn) { const rectCache = new WeakMap(); const styleCache = new WeakMap(); // elementsFromPoint dedup keyed by rounded "x:y" const efpCache = new Map(); + _scanSemanticSignalCache = new WeakMap(); + _scanClickableCandidateCache = new WeakMap(); + _scanBaseClickableSignalCache = new WeakMap(); + _scanTextContentCache = new WeakMap(); + _scanSearchTextCache = new WeakMap(); + _scanExplicitAncestorCache = new WeakMap(); const origElementRect = Element.prototype.getBoundingClientRect; const SVGGraphicsProto = @@ -174,6 +192,12 @@ function withScanLayoutCache(fn) { if (DocumentProto && origElementsFromPoint) { DocumentProto.elementsFromPoint = origElementsFromPoint; } + _scanSemanticSignalCache = null; + _scanClickableCandidateCache = null; + _scanBaseClickableSignalCache = null; + _scanTextContentCache = null; + _scanSearchTextCache = null; + _scanExplicitAncestorCache = null; } } @@ -405,6 +429,15 @@ function getSwipeMarkerText(el) { } function getElementTextForDetection(el) { + if (_scanTextContentCache && _scanTextContentCache.has(el)) { + return _scanTextContentCache.get(el); + } + const r = getElementTextForDetectionImpl(el); + if (_scanTextContentCache) _scanTextContentCache.set(el, r); + return r; +} + +function getElementTextForDetectionImpl(el) { if (el instanceof HTMLInputElement) { const inputType = (el.type || '').toLowerCase(); if ( @@ -416,10 +449,22 @@ function getElementTextForDetection(el) { } } + // textContent on a deep node walks the entire subtree of text nodes — for + // a table row with hundreds of descendants this is expensive enough to + // dominate the resolve phase. Cache so each candidate pays at most once. return normalizeWhitespace(el.textContent || '', 240); } function getElementSearchText(el) { + if (_scanSearchTextCache && _scanSearchTextCache.has(el)) { + return _scanSearchTextCache.get(el); + } + const r = getElementSearchTextImpl(el); + if (_scanSearchTextCache) _scanSearchTextCache.set(el, r); + return r; +} + +function getElementSearchTextImpl(el) { const tokens = [ el.tagName.toLowerCase(), ...getAttributeTextTokens(el, [ @@ -580,6 +625,15 @@ function hasPointerCursor(el) { } function getBaseClickableSignal(el) { + if (_scanBaseClickableSignalCache && _scanBaseClickableSignalCache.has(el)) { + return _scanBaseClickableSignalCache.get(el); + } + const r = getBaseClickableSignalImpl(el); + if (_scanBaseClickableSignalCache) _scanBaseClickableSignalCache.set(el, r); + return r; +} + +function getBaseClickableSignalImpl(el) { const semanticSignal = getSemanticClickableSignal(el); if (semanticSignal) { return semanticSignal; @@ -673,6 +727,15 @@ function getControlAffinityScore(el) { } function getSemanticClickableSignal(el) { + if (_scanSemanticSignalCache && _scanSemanticSignalCache.has(el)) { + return _scanSemanticSignalCache.get(el); + } + const r = getSemanticClickableSignalImpl(el); + if (_scanSemanticSignalCache) _scanSemanticSignalCache.set(el, r); + return r; +} + +function getSemanticClickableSignalImpl(el) { const tag = el.tagName.toLowerCase(); const role = (el.getAttribute('role') || '').toLowerCase(); @@ -869,18 +932,30 @@ function countDirectClickableChildren(el) { } function hasExplicitClickableAncestor(el) { + if (_scanExplicitAncestorCache && _scanExplicitAncestorCache.has(el)) { + return _scanExplicitAncestorCache.get(el); + } + // Per-call top-level memoization only. A previous version tried to + // walk-and-memoize each visited ancestor too, but that's incorrect — + // a node's own `hasExplicitClickableAncestor` is about ITS ancestors, + // not about its own signal, and it's also influenced by its own signal + // when answering the same question for *its* descendants. Doing the full + // walk per unique element (with getSemanticClickableSignal cached) is + // already cheap enough thanks to the upstream caches. let current = el.parentElement; - + let answer = false; while (current && current !== document.body) { const signal = getSemanticClickableSignal(current); if (signal === 'semantic' || signal === 'attribute') { - return true; + answer = true; + break; } - current = current.parentElement; } - - return false; + if (_scanExplicitAncestorCache) { + _scanExplicitAncestorCache.set(el, answer); + } + return answer; } function isInputableCandidate(el) { @@ -1011,6 +1086,15 @@ function hasStructuredInteractiveDescendant(el) { } function isClickableCandidate(el) { + if (_scanClickableCandidateCache && _scanClickableCandidateCache.has(el)) { + return _scanClickableCandidateCache.get(el); + } + const r = isClickableCandidateImpl(el); + if (_scanClickableCandidateCache) _scanClickableCandidateCache.set(el, r); + return r; +} + +function isClickableCandidateImpl(el) { if (isDisabledForDetection(el)) { return null; } @@ -2625,6 +2709,27 @@ function collectHighlightCandidatesImpl(config, trace, layoutStability) { ); let scannedCount = 0; + // Per-phase reject counters and timings — gated behind the trace, helps + // identify where the scan budget is spent without per-element console spam. + const phaseStats = { + tagSkip: 0, + notInViewport: 0, + notVisible: 0, + scrollParentClipped: 0, + notInActiveTopLayer: 0, + hitTestOccluded: 0, + notResolvable: 0, + matched: 0, + }; + const phaseTimes = { + tag: 0, + viewport: 0, + visible: 0, + scrollParent: 0, + topLayer: 0, + hitTest: 0, + resolve: 0, + }; for (const element of allElements) { scannedCount += 1; @@ -2635,40 +2740,65 @@ function collectHighlightCandidatesImpl(config, trace, layoutStability) { ); } - // Tag-only fast reject before any layout read. Saves rect/style work on - // the long tail of inert markup (script/style/meta/...). + let t = performance.now(); if (isScanSkippableTag(element)) { + phaseStats.tagSkip += 1; + phaseTimes.tag += performance.now() - t; continue; } + phaseTimes.tag += performance.now() - t; - if (!isElementInViewportForDetection(element)) { + t = performance.now(); + const inViewport = isElementInViewportForDetection(element); + phaseTimes.viewport += performance.now() - t; + if (!inViewport) { + phaseStats.notInViewport += 1; continue; } - if (!isElementVisibleForDetection(element)) { + t = performance.now(); + const visible = isElementVisibleForDetection(element); + phaseTimes.visible += performance.now() - t; + if (!visible) { + phaseStats.notVisible += 1; continue; } - if (!isElementVisibleInScrollParent(element)) { + t = performance.now(); + const scrollOk = isElementVisibleInScrollParent(element); + phaseTimes.scrollParent += performance.now() - t; + if (!scrollOk) { + phaseStats.scrollParentClipped += 1; continue; } - if (!isElementInActiveTopLayer(element, activeTopLayerRoot)) { + t = performance.now(); + const topLayerOk = isElementInActiveTopLayer(element, activeTopLayerRoot); + phaseTimes.topLayer += performance.now() - t; + if (!topLayerOk) { + phaseStats.notInActiveTopLayer += 1; continue; } + t = performance.now(); const hitTestVisibility = getElementHitTestVisibility(element); + phaseTimes.hitTest += performance.now() - t; if (!hitTestVisibility.visible) { + phaseStats.hitTestOccluded += 1; continue; } + t = performance.now(); const resolvedCandidate = resolveElementCandidate( element, config.elementType, ); + phaseTimes.resolve += performance.now() - t; if (!resolvedCandidate) { + phaseStats.notResolvable += 1; continue; } + phaseStats.matched += 1; const candidate = { element: resolvedCandidate.element, @@ -2717,14 +2847,20 @@ function collectHighlightCandidatesImpl(config, trace, layoutStability) { return element; }); + const roundedTimes = {}; + for (const k of Object.keys(phaseTimes)) { + roundedTimes[k] = Math.round(phaseTimes[k]); + } trace( 'scan:done', - `processed=${scannedCount} matched=${elements.length} counts=${JSON.stringify(counts)}`, + `processed=${scannedCount} matched=${elements.length} counts=${JSON.stringify(counts)} reject=${JSON.stringify(phaseStats)} ms=${JSON.stringify(roundedTimes)}`, ); return { elements, counts, + _scan_stats: phaseStats, + _scan_times: roundedTimes, }; } @@ -2737,11 +2873,14 @@ async function runOpenBrowserHighlightDetection(config) { const layoutStability = evaluateReadinessSnapshot(trace); - const { elements, counts } = collectHighlightCandidates( + const scanStart = performance.now(); + const scanResult = collectHighlightCandidates( config, trace, layoutStability, ); + const { elements, counts } = scanResult; + const scanMs = Math.round(performance.now() - scanStart); trace('return', `elements=${elements.length}`); return { @@ -2753,5 +2892,10 @@ async function runOpenBrowserHighlightDetection(config) { width: window.innerWidth, height: window.innerHeight, }, + _perf: { + scan_ms: scanMs, + scan_stats: scanResult._scan_stats || {}, + scan_times: scanResult._scan_times || {}, + }, }; } From e8268a86605b8d34428bec018cdcee774bb15fbb Mon Sep 17 00:00:00 2001 From: Xiao Yang Date: Sat, 18 Apr 2026 10:06:28 +0800 Subject: [PATCH 3/4] set extension auto reload timeout to 40s --- extension/vite.config.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/extension/vite.config.ts b/extension/vite.config.ts index bf660fc..3ebf0bd 100644 --- a/extension/vite.config.ts +++ b/extension/vite.config.ts @@ -122,16 +122,18 @@ const devReloadPlugin = () => { return; } - // Otherwise wait for the extension to connect (up to 10s) + // Otherwise wait for the extension to connect (up to 40s — covers a + // full chrome.alarms keepalive cycle when the MV3 service worker has + // been terminated by Chrome). console.log( '🔄 [DevReload] Build complete — waiting for extension to connect...', ); const timeout = setTimeout(() => { console.warn( - '🔄 [DevReload] No extension connected within 10s. Reload the extension manually once, then future `npm run dev` runs will auto-reload.', + '🔄 [DevReload] No extension connected within 40s. Reload the extension manually once, then future `npm run dev` runs will auto-reload.', ); process.exit(0); - }, 10_000); + }, 40_000); // Check periodically if a client has connected const poll = setInterval(() => { From 6aac696f972b606665e28450904a0d4193ab8547 Mon Sep 17 00:00:00 2001 From: Xiao Yang Date: Sat, 18 Apr 2026 10:22:17 +0800 Subject: [PATCH 4/4] chore: apply pre-commit formatting (prettier) on highlight perf changes Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/background/index.ts | 7 +++---- extension/src/commands/highlight-detection.injected.js | 6 +----- extension/src/utils/collision-detection.ts | 5 ++++- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 5228fd9..3b0f24b 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -708,9 +708,7 @@ async function captureHighlightedPageState( `📸 [${logLabel}] Screenshot captured (with in-page highlights), size: ${screenshotResult.imageData.length} bytes`, ); screenshotMs = Date.now() - screenshotStart; - console.log( - `⏱️ [HighlightTrace] background screenshot ${screenshotMs}ms`, - ); + console.log(`⏱️ [HighlightTrace] background screenshot ${screenshotMs}ms`); // Apply bboxes returned from the highlight injection script const preCaptureData = screenshotResult.preCaptureResult; @@ -849,7 +847,8 @@ async function captureHighlightedPageState( pageState, readinessReasons, _perf: { - scan_ms: typeof inPagePerf.scan_ms === 'number' ? inPagePerf.scan_ms : 0, + scan_ms: + typeof inPagePerf.scan_ms === 'number' ? inPagePerf.scan_ms : 0, scan_stats: inPagePerf.scan_stats || {}, scan_times: inPagePerf.scan_times || {}, pagination_ms: paginationMs, diff --git a/extension/src/commands/highlight-detection.injected.js b/extension/src/commands/highlight-detection.injected.js index 9581d94..4140016 100644 --- a/extension/src/commands/highlight-detection.injected.js +++ b/extension/src/commands/highlight-detection.injected.js @@ -2874,11 +2874,7 @@ async function runOpenBrowserHighlightDetection(config) { const layoutStability = evaluateReadinessSnapshot(trace); const scanStart = performance.now(); - const scanResult = collectHighlightCandidates( - config, - trace, - layoutStability, - ); + const scanResult = collectHighlightCandidates(config, trace, layoutStability); const { elements, counts } = scanResult; const scanMs = Math.round(performance.now() - scanStart); diff --git a/extension/src/utils/collision-detection.ts b/extension/src/utils/collision-detection.ts index da4911a..a409c64 100644 --- a/extension/src/utils/collision-detection.ts +++ b/extension/src/utils/collision-detection.ts @@ -572,7 +572,10 @@ function chooseLeastBlockingPlacement( ); let union = fc.element.bbox; for (const pos of POSITION_PRIORITY) { - union = unionBBox(union, getLabelBBox(fc.element.bbox, pos, fc.element.id)); + union = unionBBox( + union, + getLabelBBox(fc.element.bbox, pos, fc.element.id), + ); } return { candidate: fc,