From 4b296386a25e58e51d1c1b6d7402b48e187d06a1 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 19:54:27 -0700 Subject: [PATCH 01/45] fix: resolve element selection getting stuck on busy pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes identified via runtime instrumentation on nisarg.io: 1. Detection scheduling used scheduler.postTask({priority:"background"}) which was starved for 350ms–5000ms on pages with continuous React renders or CSS animations, making hover detection feel frozen. Replaced with requestAnimationFrame (~16ms per frame). 2. Decorative hover overlay divs (absolute-positioned, transparent, empty elements used for hover border/glow effects) were returned by elementFromPoint, blocking detection of actual content underneath. Added isDecorativeOverlay filter to skip these elements. --- .../src/utils/is-valid-grabbable-element.ts | 19 +++++++++ packages/react-grab/src/utils/on-idle.ts | 40 ++++--------------- 2 files changed, 26 insertions(+), 33 deletions(-) diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index f2876803f..14a69162f 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -74,6 +74,20 @@ export const clearVisibilityCache = (): void => { visibilityCache = new WeakMap(); }; +const isDecorativeOverlay = (element: Element, computedStyle: CSSStyleDeclaration): boolean => { + const position = computedStyle.position; + if (position !== "absolute" && position !== "fixed") { + return false; + } + + if (element.childElementCount > 0 || (element.textContent?.trim().length ?? 0) > 0) { + return false; + } + + const backgroundColor = computedStyle.backgroundColor; + return backgroundColor === "transparent" || backgroundColor === "rgba(0, 0, 0, 0)"; +}; + export const isValidGrabbableElement = (element: Element): boolean => { if (isRootElement(element)) { return false; @@ -102,6 +116,11 @@ export const isValidGrabbableElement = (element: Element): boolean => { return false; } + if (isDecorativeOverlay(element, computedStyle)) { + visibilityCache.set(element, { isVisible: false, timestamp: now }); + return false; + } + const couldBeOverlay = element.clientWidth / window.innerWidth >= VIEWPORT_COVERAGE_THRESHOLD && element.clientHeight / window.innerHeight >= VIEWPORT_COVERAGE_THRESHOLD; diff --git a/packages/react-grab/src/utils/on-idle.ts b/packages/react-grab/src/utils/on-idle.ts index 7437b19d9..2a73decfa 100644 --- a/packages/react-grab/src/utils/on-idle.ts +++ b/packages/react-grab/src/utils/on-idle.ts @@ -1,36 +1,10 @@ -interface BackgroundTaskScheduler { - postTask: (callback: () => void, options: { priority: "background" }) => unknown; -} +import { nativeRequestAnimationFrame } from "./native-raf.js"; -declare global { - interface Window { - scheduler?: BackgroundTaskScheduler; - } -} - -const isBackgroundTaskScheduler = (value: unknown): value is BackgroundTaskScheduler => { - if (typeof value !== "object" || value === null) return false; - if (!("postTask" in value)) return false; - return typeof value.postTask === "function"; -}; - -// Defers work via the best available idle-scheduling primitive: the Scheduler -// API with background priority (Chrome 94+), then requestIdleCallback, then a -// setTimeout(0) fallback. This is used for expensive hit-testing work that -// should never block user-visible rendering. +// Defers hit-testing work to the next animation frame so it doesn't block the +// current event handler. requestAnimationFrame fires once per frame (~16ms) at +// rendering priority, which avoids the multi-second starvation seen with +// scheduler.postTask({priority:"background"}) and requestIdleCallback on pages +// with continuous React renders or CSS animations. export const onIdle = (callback: () => void): void => { - if (typeof window !== "undefined") { - const schedulerCandidate = window.scheduler; - if (isBackgroundTaskScheduler(schedulerCandidate)) { - schedulerCandidate.postTask(callback, { - priority: "background", - }); - return; - } - if ("requestIdleCallback" in window) { - requestIdleCallback(callback); - return; - } - } - setTimeout(callback, 0); + nativeRequestAnimationFrame(callback); }; From da29b1e56f69d75d5ec77e473df026b3f39ba06e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 19:54:31 -0700 Subject: [PATCH 02/45] fix: reduce flaky E2E tests - Fix race in rightClickElement by checking overlay state before click - Increase waitForContextMenu/waitForPromptMode timeouts to 5s - Replace fixed post-copy waits with polling for comments button - Convert dismiss assertions from waitForTimeout to expect.poll - Increase freeze cleanup propagation wait in multi-cycle test --- .../e2e/clear-history-prompt.spec.ts | 57 +++++++++---------- packages/react-grab/e2e/context-menu.spec.ts | 10 +--- packages/react-grab/e2e/fixtures.ts | 16 +++--- .../react-grab/e2e/freeze-updates.spec.ts | 2 +- 4 files changed, 40 insertions(+), 45 deletions(-) diff --git a/packages/react-grab/e2e/clear-history-prompt.spec.ts b/packages/react-grab/e2e/clear-history-prompt.spec.ts index 771e6ab59..594ed0934 100644 --- a/packages/react-grab/e2e/clear-history-prompt.spec.ts +++ b/packages/react-grab/e2e/clear-history-prompt.spec.ts @@ -7,8 +7,7 @@ const copyElement = async (reactGrab: ReactGrabPageObject, selector: string) => await reactGrab.typeInInput("comment"); await reactGrab.submitInput(); await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).toBeTruthy(); - // HACK: Wait for copy feedback transition and comments item addition - await reactGrab.page.waitForTimeout(300); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true); }; test.describe("Toolbar Copy All Button", () => { @@ -24,18 +23,18 @@ test.describe("Toolbar Copy All Button", () => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickCommentsButton(); - await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(true); }); test("should hide when comments dropdown is closed", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickCommentsButton(); - await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(true); await reactGrab.clickCommentsButton(); - await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(false); }); }); @@ -60,7 +59,7 @@ test.describe("Toolbar Copy All Button", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); }); }); @@ -74,7 +73,7 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); }); @@ -84,7 +83,7 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickCommentsCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); }); }); @@ -98,13 +97,13 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.confirmClearCommentsPrompt(); await reactGrab.page.waitForTimeout(200); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false); }); test("should clear comments when confirmed via Enter key", async ({ reactGrab }) => { @@ -114,17 +113,17 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.pressEnter(); await reactGrab.page.waitForTimeout(200); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false); }); test("should dismiss the prompt after confirming", async ({ reactGrab }) => { @@ -134,13 +133,13 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.confirmClearCommentsPrompt(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); }); @@ -153,12 +152,12 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.confirmClearCommentsPrompt(); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false); await copyElement(reactGrab, "li:last-child"); @@ -166,10 +165,10 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false); }); }); @@ -181,17 +180,17 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.cancelClearCommentsPrompt(); await reactGrab.page.waitForTimeout(200); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true); }); test("should dismiss prompt when cancelled via Escape key", async ({ reactGrab }) => { @@ -201,17 +200,17 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(200); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); - await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true); }); }); @@ -223,7 +222,7 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.activate(); @@ -232,7 +231,7 @@ test.describe("Clear History Prompt", () => { await reactGrab.rightClickElement("li:first-child"); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); }); @@ -243,14 +242,14 @@ test.describe("Clear History Prompt", () => { await reactGrab.clickToolbarCopyAll(); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(true); await reactGrab.clickToolbarEnabled(); await reactGrab.page.waitForTimeout(200); await expect - .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 }) + .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 }) .toBe(false); }); }); diff --git a/packages/react-grab/e2e/context-menu.spec.ts b/packages/react-grab/e2e/context-menu.spec.ts index 603595d1f..284b93fd4 100644 --- a/packages/react-grab/e2e/context-menu.spec.ts +++ b/packages/react-grab/e2e/context-menu.spec.ts @@ -46,7 +46,7 @@ test.describe("Context Menu", () => { const element = reactGrab.page.locator("li").first(); await element.click({ button: "right", force: true }); - await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 2000 }).toBe(true); + await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(true); await reactGrab.page.keyboard.up("c"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); @@ -118,10 +118,8 @@ test.describe("Context Menu", () => { expect(isVisibleBefore).toBe(true); await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(200); - const isVisibleAfter = await reactGrab.isContextMenuVisible(); - expect(isVisibleAfter).toBe(false); + await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(false); }); test("should dismiss context menu when clicking outside", async ({ reactGrab }) => { @@ -134,10 +132,8 @@ test.describe("Context Menu", () => { expect(isVisibleBefore).toBe(true); await reactGrab.page.mouse.click(10, 10); - await reactGrab.page.waitForTimeout(200); - const isVisibleAfter = await reactGrab.isContextMenuVisible(); - expect(isVisibleAfter).toBe(false); + await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(false); }); test("should dismiss context menu after Copy action", async ({ reactGrab }) => { diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index c11fa5b3f..596bc9228 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -265,7 +265,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const hoverElement = async (selector: string) => { const element = page.locator(selector).first(); await element.hover({ force: true }); - await page.waitForTimeout(250); + await page.waitForTimeout(350); }; const clickElement = async (selector: string) => { @@ -422,23 +422,23 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { return expectedVisible ? menuItem !== null : menuItem === null; }, { attrName: ATTRIBUTE_NAME, expectedVisible: visible }, - { timeout: 2000 }, + { timeout: 5000 }, ); }; const rightClickElement = async (selector: string) => { + const wasActive = await isOverlayVisible(); const element = page.locator(selector).first(); await element.click({ button: "right", force: true }); - const isActive = await isOverlayVisible(); - if (isActive) { + if (wasActive) { await waitForContextMenu(true); } }; const rightClickAtPosition = async (x: number, y: number) => { + const wasActive = await isOverlayVisible(); await page.mouse.click(x, y, { button: "right" }); - const isActive = await isOverlayVisible(); - if (isActive) { + if (wasActive) { await waitForContextMenu(true); } }; @@ -576,7 +576,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { return api?.getState()?.isPromptMode === expected; }, active, - { timeout: 2000 }, + { timeout: 5000 }, ); }; @@ -600,7 +600,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { return state?.isSelectionBoxVisible || state?.targetElement !== null; }, undefined, - { timeout: 2000 }, + { timeout: 5000 }, ) .then(() => true) .catch(() => false); diff --git a/packages/react-grab/e2e/freeze-updates.spec.ts b/packages/react-grab/e2e/freeze-updates.spec.ts index 99a6205dc..3d2fcfa07 100644 --- a/packages/react-grab/e2e/freeze-updates.spec.ts +++ b/packages/react-grab/e2e/freeze-updates.spec.ts @@ -235,7 +235,7 @@ test.describe("Freeze Updates", () => { await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.deactivate(); // HACK: allow freeze cleanup to fully propagate before next iteration - await reactGrab.page.waitForTimeout(300); + await reactGrab.page.waitForTimeout(500); } const getElementCount = async () => { From 64dc9e761d1cf727edd797664f240ff88fcc352d Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 19:57:59 -0700 Subject: [PATCH 03/45] refactor: inline onIdle into nativeRequestAnimationFrame and extract hasTransparentBackground - Delete on-idle.ts; it was a trivial wrapper after the scheduler change. Use nativeRequestAnimationFrame directly in index.tsx (already imported). - Extract hasTransparentBackground helper shared between isDecorativeOverlay and isFullViewportOverlay. --- packages/react-grab/src/core/index.tsx | 3 +-- .../src/utils/is-valid-grabbable-element.ts | 27 +++++++------------ packages/react-grab/src/utils/on-idle.ts | 10 ------- 3 files changed, 11 insertions(+), 29 deletions(-) delete mode 100644 packages/react-grab/src/utils/on-idle.ts diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 504c30579..00eec06de 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -118,7 +118,6 @@ import { createArrowNavigator } from "./arrow-navigation.js"; import { getRequiredModifiers, setupKeyboardEventClaimer } from "./keyboard-handlers.js"; import { createAutoScroller, getAutoScrollDirection } from "./auto-scroll.js"; import { logIntro } from "./log-intro.js"; -import { onIdle } from "../utils/on-idle.js"; import { getScriptOptions } from "../utils/get-script-options.js"; import { isEnterCode } from "../utils/is-enter-code.js"; import { isMac } from "../utils/is-mac.js"; @@ -1624,7 +1623,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ) { elementDetectionState.lastDetectionTimestamp = now; elementDetectionState.pendingDetectionScheduledAt = now; - onIdle(() => { + nativeRequestAnimationFrame(() => { const candidate = getElementAtPosition( elementDetectionState.latestPointerX, elementDetectionState.latestPointerY, diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index 14a69162f..8ead55137 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -34,6 +34,11 @@ const isDevToolsOverlay = (computedStyle: CSSStyleDeclaration): boolean => { ); }; +const hasTransparentBackground = (computedStyle: CSSStyleDeclaration): boolean => { + const backgroundColor = computedStyle.backgroundColor; + return backgroundColor === "transparent" || backgroundColor === "rgba(0, 0, 0, 0)"; +}; + const isFullViewportOverlay = (element: Element, computedStyle: CSSStyleDeclaration): boolean => { const position = computedStyle.position; if (position !== "fixed" && position !== "absolute") { @@ -49,13 +54,7 @@ const isFullViewportOverlay = (element: Element, computedStyle: CSSStyleDeclarat return false; } - const backgroundColor = computedStyle.backgroundColor; - const hasInvisibleBackground = - backgroundColor === "transparent" || - backgroundColor === "rgba(0, 0, 0, 0)" || - parseFloat(computedStyle.opacity) < 0.1; - - if (hasInvisibleBackground) { + if (hasTransparentBackground(computedStyle) || parseFloat(computedStyle.opacity) < 0.1) { return true; } @@ -76,16 +75,10 @@ export const clearVisibilityCache = (): void => { const isDecorativeOverlay = (element: Element, computedStyle: CSSStyleDeclaration): boolean => { const position = computedStyle.position; - if (position !== "absolute" && position !== "fixed") { - return false; - } - - if (element.childElementCount > 0 || (element.textContent?.trim().length ?? 0) > 0) { - return false; - } - - const backgroundColor = computedStyle.backgroundColor; - return backgroundColor === "transparent" || backgroundColor === "rgba(0, 0, 0, 0)"; + if (position !== "absolute" && position !== "fixed") return false; + if (element.childElementCount > 0) return false; + if ((element.textContent?.trim().length ?? 0) > 0) return false; + return hasTransparentBackground(computedStyle); }; export const isValidGrabbableElement = (element: Element): boolean => { diff --git a/packages/react-grab/src/utils/on-idle.ts b/packages/react-grab/src/utils/on-idle.ts deleted file mode 100644 index 2a73decfa..000000000 --- a/packages/react-grab/src/utils/on-idle.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { nativeRequestAnimationFrame } from "./native-raf.js"; - -// Defers hit-testing work to the next animation frame so it doesn't block the -// current event handler. requestAnimationFrame fires once per frame (~16ms) at -// rendering priority, which avoids the multi-second starvation seen with -// scheduler.postTask({priority:"background"}) and requestIdleCallback on pages -// with continuous React renders or CSS animations. -export const onIdle = (callback: () => void): void => { - nativeRequestAnimationFrame(callback); -}; From 8c9e55daa38c5968dc7ae92e817fddbeabbe502b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:04:07 -0700 Subject: [PATCH 04/45] perf: use setTimeout over rAF for element detection scheduling rAF callbacks run inside the rendering pipeline (before paint), so the elementFromPoint style recalc was eating into the frame budget. setTimeout(0) runs as a separate macrotask outside the render pipeline, fires within ~1-4ms (no starvation), and doesn't impact frame budget. The 32ms detection throttle already limits call frequency. --- packages/react-grab/src/core/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 00eec06de..d916608d5 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -1623,7 +1623,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ) { elementDetectionState.lastDetectionTimestamp = now; elementDetectionState.pendingDetectionScheduledAt = now; - nativeRequestAnimationFrame(() => { + setTimeout(() => { const candidate = getElementAtPosition( elementDetectionState.latestPointerX, elementDetectionState.latestPointerY, From 3e6e6608e2425288f5995b1c516d39bea7f09d07 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:11:38 -0700 Subject: [PATCH 05/45] docs: document why setTimeout is used for element detection scheduling --- packages/react-grab/src/core/index.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index d916608d5..2846cbf6b 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -1623,6 +1623,17 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ) { elementDetectionState.lastDetectionTimestamp = now; elementDetectionState.pendingDetectionScheduledAt = now; + // setTimeout defers the elementFromPoint hit-test into its own macrotask + // so the style recalculation it triggers doesn't eat the current frame's + // budget. Alternatives considered: + // - queueMicrotask: runs before paint, blocks the frame (ivi/Preact use + // microtasks for lightweight JS work, but elementFromPoint forces the + // browser's layout engine which is categorically heavier) + // - requestAnimationFrame: runs inside the render pipeline, same problem + // - scheduler.postTask("background"): starved for 350ms–5s on pages with + // continuous React renders or CSS animations + // setTimeout(0) fires in ~1ms as a separate task with no nesting-based + // clamping (each call originates from a distinct pointermove handler). setTimeout(() => { const candidate = getElementAtPosition( elementDetectionState.latestPointerX, From e05242b4a068b22803487437e1d80b8f0f782397 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:18:22 -0700 Subject: [PATCH 06/45] fix: exclude replaced elements from decorative overlay filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced elements (img, video, canvas, svg, iframe, etc.) have no children, no text, and a transparent background-color — matching all isDecorativeOverlay criteria despite being real content. Skip them with a tag name check before the emptiness/transparency heuristics. --- packages/react-grab/src/utils/is-valid-grabbable-element.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index 8ead55137..4db3e0896 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -73,9 +73,15 @@ export const clearVisibilityCache = (): void => { visibilityCache = new WeakMap(); }; +const REPLACED_ELEMENT_TAGS = new Set([ + "IMG", "VIDEO", "CANVAS", "SVG", "IFRAME", "EMBED", "OBJECT", + "INPUT", "TEXTAREA", "SELECT", +]); + const isDecorativeOverlay = (element: Element, computedStyle: CSSStyleDeclaration): boolean => { const position = computedStyle.position; if (position !== "absolute" && position !== "fixed") return false; + if (REPLACED_ELEMENT_TAGS.has(element.tagName)) return false; if (element.childElementCount > 0) return false; if ((element.textContent?.trim().length ?? 0) > 0) return false; return hasTransparentBackground(computedStyle); From 4296c131fa9133d1822df7b44b7018751039d7c1 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:23:32 -0700 Subject: [PATCH 07/45] refactor: allowlist DIV/SPAN instead of blocklisting replaced elements Decorative hover overlays are always
or . An allowlist of 2 tags is simpler, safer against future HTML elements, and more precise than a 10-item blocklist of replaced elements. --- .../src/utils/is-valid-grabbable-element.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index 4db3e0896..ad3e324dd 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -73,18 +73,22 @@ export const clearVisibilityCache = (): void => { visibilityCache = new WeakMap(); }; -const REPLACED_ELEMENT_TAGS = new Set([ - "IMG", "VIDEO", "CANVAS", "SVG", "IFRAME", "EMBED", "OBJECT", - "INPUT", "TEXTAREA", "SELECT", -]); - +// Hover-effect overlays: sites like nisarg.io use empty absolute-positioned +// transparent
s for card border glow / transition effects. These sit on +// top of real content in the z-order, causing elementFromPoint to return them +// instead of the meaningful elements underneath. const isDecorativeOverlay = (element: Element, computedStyle: CSSStyleDeclaration): boolean => { + const tagName = element.tagName; + if (tagName !== "DIV" && tagName !== "SPAN") return false; + const position = computedStyle.position; if (position !== "absolute" && position !== "fixed") return false; - if (REPLACED_ELEMENT_TAGS.has(element.tagName)) return false; - if (element.childElementCount > 0) return false; - if ((element.textContent?.trim().length ?? 0) > 0) return false; - return hasTransparentBackground(computedStyle); + + return ( + element.childElementCount === 0 && + (element.textContent?.trim().length ?? 0) === 0 && + hasTransparentBackground(computedStyle) + ); }; export const isValidGrabbableElement = (element: Element): boolean => { From 4c13a24ee0c52d9ab243f655123e3aa18d44f855 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:26:43 -0700 Subject: [PATCH 08/45] refactor: replace isDecorativeOverlay with hasVisualContent check Remove tag-name allowlists and site-specific heuristics. The principle is general: a positioned element with no visual content (no children, no text, transparent background) exists only in the stacking order and shouldn't be selectable. elementFromPoint falls through to the meaningful element underneath via the elementsFromPoint fallback. --- .../src/utils/is-valid-grabbable-element.ts | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index ad3e324dd..7e6832d0d 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -73,23 +73,10 @@ export const clearVisibilityCache = (): void => { visibilityCache = new WeakMap(); }; -// Hover-effect overlays: sites like nisarg.io use empty absolute-positioned -// transparent
s for card border glow / transition effects. These sit on -// top of real content in the z-order, causing elementFromPoint to return them -// instead of the meaningful elements underneath. -const isDecorativeOverlay = (element: Element, computedStyle: CSSStyleDeclaration): boolean => { - const tagName = element.tagName; - if (tagName !== "DIV" && tagName !== "SPAN") return false; - - const position = computedStyle.position; - if (position !== "absolute" && position !== "fixed") return false; - - return ( - element.childElementCount === 0 && - (element.textContent?.trim().length ?? 0) === 0 && - hasTransparentBackground(computedStyle) - ); -}; +const hasVisualContent = (element: Element, computedStyle: CSSStyleDeclaration): boolean => + element.childElementCount > 0 || + Boolean(element.textContent?.trim()) || + !hasTransparentBackground(computedStyle); export const isValidGrabbableElement = (element: Element): boolean => { if (isRootElement(element)) { @@ -119,7 +106,8 @@ export const isValidGrabbableElement = (element: Element): boolean => { return false; } - if (isDecorativeOverlay(element, computedStyle)) { + const isPositioned = computedStyle.position === "absolute" || computedStyle.position === "fixed"; + if (isPositioned && !hasVisualContent(element, computedStyle)) { visibilityCache.set(element, { isVisible: false, timestamp: now }); return false; } From 4aee8e3288ba89d8c52aae71ae71332f82eacf05 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:28:13 -0700 Subject: [PATCH 09/45] fix: check background-image and box-shadow in hasVisualContent A positioned element with transparent background-color can still render visible content via background-image (hero sections, card images) or box-shadow (card panels). Without these checks, hasVisualContent was too aggressive and would incorrectly skip meaningful elements. --- packages/react-grab/src/utils/is-valid-grabbable-element.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index 7e6832d0d..314739cb1 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -76,7 +76,9 @@ export const clearVisibilityCache = (): void => { const hasVisualContent = (element: Element, computedStyle: CSSStyleDeclaration): boolean => element.childElementCount > 0 || Boolean(element.textContent?.trim()) || - !hasTransparentBackground(computedStyle); + !hasTransparentBackground(computedStyle) || + computedStyle.backgroundImage !== "none" || + computedStyle.boxShadow !== "none"; export const isValidGrabbableElement = (element: Element): boolean => { if (isRootElement(element)) { From f0138bf7d41a0b3ce60df9ab2df7f145227df06c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:30:48 -0700 Subject: [PATCH 10/45] refactor: prefer smallest element instead of content heuristics Replace the hasVisualContent heuristic with a simpler, more principled approach: among all valid elements at a point, select the one with the smallest bounding area. This naturally picks the most specific content element and skips decorative overlays, container divs, and other large elements without needing tag-name checks or CSS property heuristics. --- .../src/utils/get-element-at-position.ts | 26 +++++++++---------- .../src/utils/is-valid-grabbable-element.ts | 13 ---------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/packages/react-grab/src/utils/get-element-at-position.ts b/packages/react-grab/src/utils/get-element-at-position.ts index 0fcb34bf8..0eef8e8a4 100644 --- a/packages/react-grab/src/utils/get-element-at-position.ts +++ b/packages/react-grab/src/utils/get-element-at-position.ts @@ -79,21 +79,21 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element cancelScheduledResume(); suspendPointerEventsFreeze(); + // elementsFromPoint returns the full z-ordered stack. Among valid elements, + // prefer the smallest — this naturally selects the most specific content + // element and skips decorative overlays, container divs, and other large + // elements that happen to sit above the actual content at this coordinate. + const elementsAtPoint = document.elementsFromPoint(clientX, clientY); let result: Element | null = null; + let smallestArea = Infinity; - // elementFromPoint returns the topmost element, but if it's not grabbable - // (e.g. a transparent overlay) we fall back to elementsFromPoint which - // returns the full z-ordered stack at that coordinate. - const topElement = document.elementFromPoint(clientX, clientY); - if (topElement && isValidGrabbableElement(topElement)) { - result = topElement; - } else { - const elementsAtPoint = document.elementsFromPoint(clientX, clientY); - for (const candidateElement of elementsAtPoint) { - if (candidateElement !== topElement && isValidGrabbableElement(candidateElement)) { - result = candidateElement; - break; - } + for (const candidateElement of elementsAtPoint) { + if (!isValidGrabbableElement(candidateElement)) continue; + const { width, height } = candidateElement.getBoundingClientRect(); + const area = width * height; + if (area < smallestArea) { + smallestArea = area; + result = candidateElement; } } diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index 314739cb1..05dc0138c 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -73,13 +73,6 @@ export const clearVisibilityCache = (): void => { visibilityCache = new WeakMap(); }; -const hasVisualContent = (element: Element, computedStyle: CSSStyleDeclaration): boolean => - element.childElementCount > 0 || - Boolean(element.textContent?.trim()) || - !hasTransparentBackground(computedStyle) || - computedStyle.backgroundImage !== "none" || - computedStyle.boxShadow !== "none"; - export const isValidGrabbableElement = (element: Element): boolean => { if (isRootElement(element)) { return false; @@ -108,12 +101,6 @@ export const isValidGrabbableElement = (element: Element): boolean => { return false; } - const isPositioned = computedStyle.position === "absolute" || computedStyle.position === "fixed"; - if (isPositioned && !hasVisualContent(element, computedStyle)) { - visibilityCache.set(element, { isVisible: false, timestamp: now }); - return false; - } - const couldBeOverlay = element.clientWidth / window.innerWidth >= VIEWPORT_COVERAGE_THRESHOLD && element.clientHeight / window.innerHeight >= VIEWPORT_COVERAGE_THRESHOLD; From 9859c2a17756b601268c025d6ff40f41bbbc0521 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:31:03 -0700 Subject: [PATCH 11/45] style: remove em dash from comment --- packages/react-grab/src/utils/get-element-at-position.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/src/utils/get-element-at-position.ts b/packages/react-grab/src/utils/get-element-at-position.ts index 0eef8e8a4..b88dd3704 100644 --- a/packages/react-grab/src/utils/get-element-at-position.ts +++ b/packages/react-grab/src/utils/get-element-at-position.ts @@ -80,7 +80,7 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element suspendPointerEventsFreeze(); // elementsFromPoint returns the full z-ordered stack. Among valid elements, - // prefer the smallest — this naturally selects the most specific content + // prefer the smallest: this naturally selects the most specific content // element and skips decorative overlays, container divs, and other large // elements that happen to sit above the actual content at this coordinate. const elementsAtPoint = document.elementsFromPoint(clientX, clientY); From 7b47026a15aeac1fff64e918f1b99fc5a3a3a9ad Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:35:50 -0700 Subject: [PATCH 12/45] perf: use clientWidth/clientHeight instead of getBoundingClientRect clientWidth * clientHeight reads plain integer properties from the layout cache. getBoundingClientRect constructs a DOMRect object with fractional coordinates we don't need for area comparison. Both read from the same layout (already computed by elementsFromPoint), but the property read avoids object allocation per candidate element. --- packages/react-grab/src/utils/get-element-at-position.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-grab/src/utils/get-element-at-position.ts b/packages/react-grab/src/utils/get-element-at-position.ts index b88dd3704..418cd14f8 100644 --- a/packages/react-grab/src/utils/get-element-at-position.ts +++ b/packages/react-grab/src/utils/get-element-at-position.ts @@ -89,8 +89,7 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element for (const candidateElement of elementsAtPoint) { if (!isValidGrabbableElement(candidateElement)) continue; - const { width, height } = candidateElement.getBoundingClientRect(); - const area = width * height; + const area = candidateElement.clientWidth * candidateElement.clientHeight; if (area < smallestArea) { smallestArea = area; result = candidateElement; From a46c34805c60bc532c7469709af059c9cd333c5e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:38:44 -0700 Subject: [PATCH 13/45] perf: use clientWidth/clientHeight in isFullViewportOverlay Avoids DOMRect construction from getBoundingClientRect. The clientWidth guard on the call site already uses these properties, so the layout values are cached. Consistent with getElementAtPosition's area check. --- packages/react-grab/src/utils/is-valid-grabbable-element.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts index 05dc0138c..780a73b02 100644 --- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts +++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts @@ -45,10 +45,9 @@ const isFullViewportOverlay = (element: Element, computedStyle: CSSStyleDeclarat return false; } - const rect = element.getBoundingClientRect(); const coversViewport = - rect.width / window.innerWidth >= VIEWPORT_COVERAGE_THRESHOLD && - rect.height / window.innerHeight >= VIEWPORT_COVERAGE_THRESHOLD; + element.clientWidth / window.innerWidth >= VIEWPORT_COVERAGE_THRESHOLD && + element.clientHeight / window.innerHeight >= VIEWPORT_COVERAGE_THRESHOLD; if (!coversViewport) { return false; From 7a8ec6edde3cd9d73ec4704ed82588bd5c51e5fd Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 20:44:55 -0700 Subject: [PATCH 14/45] docs: add perf-learnings.md from Inferno/Solid/ivi/pretext audit --- docs/perf-learnings.md | 431 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 docs/perf-learnings.md diff --git a/docs/perf-learnings.md b/docs/perf-learnings.md new file mode 100644 index 000000000..37708bc6a --- /dev/null +++ b/docs/perf-learnings.md @@ -0,0 +1,431 @@ +# Performance Learnings + +Patterns studied from Inferno, SolidJS, ivi, and pretext that inform react-grab's element detection pipeline. + +## Detection Pipeline + +``` +pointermove (every ~8ms, browser-fired) + -> 32ms throttle gate (ELEMENT_DETECTION_THROTTLE_MS) + -> setTimeout(0) macrotask deferral + -> position cache (2px distance + 16ms time gate) + -> suspendPointerEventsFreeze (O(1) stylesheet toggle) + -> elementsFromPoint (forces style recalc -- dominant cost) + -> per-element: visibilityCache check (WeakMap, 50ms TTL) + -> per-element: getComputedStyle (only on cache miss) + -> per-element: clientWidth * clientHeight (area comparison) + -> scheduleResume (100ms debounced pointer-events restore) +``` + +The style recalculation from the pointer-events toggle is 95%+ of detection cost (1-5ms on dense DOMs). Everything after it (per-element checks, area comparison) is <0.1ms. + +## Why setTimeout(0) for Detection Scheduling + +| Primitive | Where it runs | Latency | Frame budget impact | +|---|---|---|---| +| `scheduler.postTask("background")` | Background task | 350ms-5000ms (starved on busy pages) | None | +| `queueMicrotask` | Before paint | ~0ms | Blocks frame | +| `requestAnimationFrame` | Render pipeline | ~16ms | Eats frame budget | +| `setTimeout(0)` | Separate macrotask | ~1ms | None | + +`queueMicrotask` is wrong here despite being the standard choice for UI frameworks. ivi, Preact, and Solid all use it for state batching: + +```javascript +// ivi: microtask for dirty check flush +export const createRoot = defineRoot((root) => { + _queueMicrotask(() => { + _dirtyCheckRoot(root, 0); + }); +}); + +// Preact: microtask for render queue +export function enqueueRender(c) { + // ... + (prevDebounce || queueMicrotask)(process); +} + +// Solid: microtask via runUpdates +function runUpdates(fn, init) { + if (Updates) return fn(); + Updates = []; + ExecCount++; + // ... flush after fn() returns +} +``` + +Those frameworks schedule lightweight JS work (dirty flag walks, VDOM diffs). Our detection forces the browser's layout engine via `elementsFromPoint`, which is categorically heavier and should not block the current frame's paint. + +`scheduler.postTask("background")` was the original implementation. Runtime instrumentation on nisarg.io confirmed multi-second starvation: + +```json +{"message":"onIdle detection","data":{"idleDelayMs":5010}} +{"message":"onIdle detection","data":{"idleDelayMs":350}} +``` + +Only 2 detections in 7 seconds of hovering. The background priority was starved by React renders and CSS animations. + +`setTimeout(0)` fires in ~1ms as a separate macrotask. No nesting-based 4ms clamping applies because each call originates from a distinct pointermove handler: + +```typescript +// handlePointerMove (called from pointermove event handler) +setTimeout(() => { + const candidate = getElementAtPosition( + elementDetectionState.latestPointerX, + elementDetectionState.latestPointerY, + ); + if (candidate !== store.detectedElement) { + actions.setDetectedElement(candidate); + } + elementDetectionState.pendingDetectionScheduledAt = 0; +}); +``` + +After the fix, detection runs at ~26ms intervals (matching the 32ms throttle + macrotask overhead). + +## Techniques Applied + +### Smallest-element selection + +**Inspired by:** pretext's two-phase pattern (prepare once, compute cheaply). + +pretext separates expensive measurement from cheap arithmetic: + +```typescript +// pretext: prepare() measures via canvas, layout() does pure arithmetic +function prepare(text, font) { + // segments text, measures each word via canvas, caches widths + // call once when text first appears +} + +function layout(prepared, maxWidth, lineHeight) { + // walks cached word widths with pure arithmetic + // ~0.0002ms per text block, call on every resize + const lineCount = countPreparedLines(getInternalPrepared(prepared), maxWidth); + return { lineCount, height: lineCount * lineHeight }; +} +``` + +Our detection applies this principle. `elementsFromPoint` is the expensive "prepare" (z-ordered stack with forced style recalc). The smallest-area selection is the cheap "layout" (integer arithmetic on cached values): + +```typescript +// react-grab: elementsFromPoint is "prepare", area comparison is "layout" +const elementsAtPoint = document.elementsFromPoint(clientX, clientY); +let result: Element | null = null; +let smallestArea = Infinity; + +for (const candidateElement of elementsAtPoint) { + if (!isValidGrabbableElement(candidateElement)) continue; + const area = candidateElement.clientWidth * candidateElement.clientHeight; + if (area < smallestArea) { + smallestArea = area; + result = candidateElement; + } +} +``` + +This naturally selects the most specific content element. A decorative overlay div covering an entire card (380x115 = 43,700 area) loses to the `

` inside it (200x48 = 9,600 area). No heuristic filtering needed. + +### Visibility cache with WeakMap + +**Inspired by:** ivi (template descriptor cache), pretext (line text cache). + +ivi caches compiled template descriptors keyed by `TemplateStringsArray` identity: + +```typescript +// ivi: WeakMap keyed by template literal identity +const DESCRIPTORS = new WeakMap VAny>(); + +export const html = (strings: TemplateStringsArray, ...exprs: any[]) => { + let fn = DESCRIPTORS.get(strings); + if (fn === void 0) { + fn = compileTemplate(strings); + DESCRIPTORS.set(strings, fn); + } + return fn(exprs); +}; +``` + +pretext caches grapheme splits keyed by prepared handle, using `WeakMap` so entries are GC'd when the handle is dropped: + +```typescript +// pretext: WeakMap allows GC without explicit invalidation +let sharedLineTextCaches = new WeakMap< + PreparedTextWithSegments, + Map +>(); +``` + +react-grab's `visibilityCache` follows the same pattern. Elements that leave the DOM are GC'd automatically. The 50ms TTL is generous enough that within a single detection pass (~5ms), all cache entries remain valid: + +```typescript +// react-grab: WeakMap + TTL for automatic cleanup +let visibilityCache = new WeakMap(); + +// in isValidGrabbableElement: +const cached = visibilityCache.get(element); +if (cached && now - cached.timestamp < VISIBILITY_CACHE_TTL_MS) { + return cached.isVisible; // skip getComputedStyle + all checks +} +``` + +### clientWidth/clientHeight over getBoundingClientRect + +**Inspired by:** Inferno's `nodeValue` over `textContent` (avoiding unnecessary work for the same result). + +Inferno uses `CharacterData.nodeValue` for text updates instead of `Element.textContent`, because `textContent` goes through a more expensive parser path: + +```typescript +// Inferno: nodeValue is a direct CharacterData mutation +if (nextText !== lastVNode.children) { + (dom as Text).nodeValue = nextText; +} +``` + +Similarly, `clientWidth * clientHeight` reads plain integer properties from the layout cache. `getBoundingClientRect()` constructs a `DOMRect` object with fractional coordinates we don't need for area comparison. Both read from the same layout (already computed by `elementsFromPoint`), but the property read avoids per-element object allocation: + +```typescript +// Allocation: constructs a DOMRect with x, y, width, height, top, right, bottom, left +const { width, height } = element.getBoundingClientRect(); +const area = width * height; + +// No allocation: reads integer properties directly from the layout cache +const area = element.clientWidth * element.clientHeight; +``` + +### O(1) pointer-events toggle via inherited CSS + +**Inspired by:** Inferno's `EMPTY_OBJ` singleton (one object, reference equality for fast skipping). + +The pointer-events freeze applies `pointer-events: none` on `` rather than `*`. Since `pointer-events` is inherited, toggling it on a single root element is O(1) style invalidation instead of O(N) for every DOM node: + +```typescript +// O(N): every node gets its own style invalidation +const POINTER_EVENTS_STYLES = "* { pointer-events: none !important; }"; + +// O(1): one root toggle, inherited by all descendants +const POINTER_EVENTS_STYLES = "html { pointer-events: none !important; }"; +``` + +This was changed after observing visible lag on dense DOMs like GitHub diff viewers with 10k+ nodes. + +### Debounced pointer-events resume + +The pointer-events stylesheet is suspended (disabled) before `elementsFromPoint` for hit-testing. Instead of re-enabling synchronously (which would force another style invalidation), the resume is debounced to 100ms: + +```typescript +const scheduleResume = (): void => { + if (resumeTimerId !== null) { + clearTimeout(resumeTimerId); + } + resumeTimerId = setTimeout(() => { + resumeTimerId = null; + resumePointerEventsFreeze(); + }, POINTER_EVENTS_RESUME_DEBOUNCE_MS); +}; +``` + +On rapid successive detections (every 32ms), `cancelScheduledResume` prevents the resume from firing, so the stylesheet stays disabled. This makes the `suspendPointerEventsFreeze` call a no-op on the next detection (stylesheet is already disabled). The resume only fires after 100ms of no detection activity. + +## Techniques Studied but Not Applied + +### Bitwise flags for type dispatch (Inferno, ivi) + +Inferno encodes vnode type in a single integer. One `&` tests a bit vs multiple property reads: + +```typescript +// Inferno: bitmask dispatch +export const enum VNodeFlags { + HtmlElement = 1, + ComponentClass = 1 << 2, + ComponentFunction = 1 << 3, + Text = 1 << 4, + SvgElement = 1 << 5, + Element = HtmlElement | SvgElement | FormElement, + Component = ComponentFunction | ComponentClass, +} + +// One integer test replaces multiple string comparisons +if (nextFlags & VNodeFlags.Element) { + patchElement(lastVNode, nextVNode, context, isSVG, lifecycle, animations); +} else if (nextFlags & VNodeFlags.ComponentClass) { + // ... +} +``` + +ivi packs type + dirty + update flags in one word: + +```typescript +// ivi: combined type/state flags +export const enum Flags { + Template = 1, + Component = 1 << 1, + Dirty = 1 << 7, + DirtySubtree = 1 << 8, + ForceUpdate = 1 << 9, + TypeMask = (1 << 7) - 1, +} + +// One & extracts type, another checks dirty +const type = flags & Flags.TypeMask; +if (flags & Flags.DirtySubtree) { /* recurse */ } +``` + +**Not applied** because react-grab's `isValidGrabbableElement` checks are attribute-based (`hasAttribute`, `closest`) and CSS-based (`getComputedStyle`), not type-based. The checks don't map to a fixed set of bits. + +### Monotonic generation counter (Solid) + +Solid increments `ExecCount` once per update cycle. A computation with `updatedAt >= ExecCount` is already current and can be skipped: + +```typescript +// Solid: monotonic epoch prevents redundant recomputation +ExecCount++; + +function runTop(node) { + if (node.state === 0) return; // already clean + const ancestors = [node]; + while ( + (node = node.owner) && + (!node.updatedAt || node.updatedAt < ExecCount) + ) { + if (node.state) ancestors.push(node); + } + // run ancestors root-to-leaf + for (let i = ancestors.length - 1; i >= 0; i--) { + updateComputation(ancestors[i]); + } +} +``` + +**Not applied** because react-grab's 50ms visibility cache TTL already provides within-pass deduplication (a detection pass takes <5ms). + +### Dirty flag propagation with early exit (ivi) + +ivi's `invalidate` stops walking ancestors when one already has `DirtySubtree`: + +```typescript +// ivi: O(depth) invalidation with early exit +export const invalidate = (c: Component): void => { + if (!(c.f & Flags.Dirty)) { + c.f |= Flags.Dirty; + let parent = c.p; + while (parent !== null) { + if (parent.f & Flags.DirtySubtree) return; // already marked + parent.f |= Flags.DirtySubtree; + parent = parent.p; + } + } +}; +``` + +**Not applied** because react-grab doesn't maintain a component tree. Detection is a flat iteration over `elementsFromPoint` results. + +### Swap-remove for O(1) array unlinking (Solid) + +Solid's `cleanNode` avoids O(n) array splice by popping from the end and swapping into the removed slot: + +```typescript +// Solid: O(1) observer removal +function cleanNode(node) { + while (node.sources.length) { + const source = node.sources.pop(); + const index = node.sourceSlots.pop(); + const obs = source.observers; + if (obs && obs.length) { + const n = obs.pop(); // take last + const s = source.observerSlots.pop(); + if (index < obs.length) { + n.sourceSlots[s] = index; // update back-pointer + obs[index] = n; // swap into removed slot + source.observerSlots[index] = s; + } + } + } +} +``` + +**Not applied** because react-grab doesn't maintain observer/subscription arrays. + +### Node.prototype patching for hidden class uniformity (Inferno) + +Inferno adds properties to `Node.prototype` to prevent ad-hoc expandos from creating divergent hidden maps: + +```typescript +// Inferno: V8 hidden class fix +if (window.Node) { + // Defining $EV and $V properties on Node.prototype + // fixes v8 "wrong map" de-optimization + (Node.prototype as any).$EV = null; + (Node.prototype as any).$V = null; +} +``` + +**Not applied** because react-grab doesn't add expando properties to DOM nodes. + +### Object.seal for JIT map elimination (ivi) + +ivi seals hot-path context objects so V8 can eliminate shape checks: + +```typescript +// ivi: sealed object for JIT optimization +const RENDER_CONTEXT = { p: null, n: null, e: null }; +Object.seal(RENDER_CONTEXT); +// "JIT can eliminate object map checks" +``` + +**Not applied** because react-grab's hot-path objects are already small and stable-shaped. + +### Local const aliases of globals (ivi) + +ivi caches global functions as module-level `const` to help the JIT inline and eliminate override checks: + +```typescript +// ivi: local aliases for JIT inlining +const _queueMicrotask = queueMicrotask; +const _requestAnimationFrame = requestAnimationFrame; +const _requestIdleCallback = requestIdleCallback; +const _isArray = Array.isArray; +``` + +react-grab already does this for `requestAnimationFrame` (via `native-raf.ts` which reads from `Window.prototype` to bypass GSAP freeze wrappers), but doesn't apply the pattern broadly since the detection hot path makes few global calls. + +### IntersectionObserver for reflow-free measurement + +IO entries include `boundingClientRect` without triggering synchronous reflow. However: + +- IO is async (1-frame latency), detection needs sync results +- IO doesn't provide z-order information (which elements are on TOP at a point) +- After `elementsFromPoint` forces layout, `clientWidth`/`clientHeight` reads are already free (layout is cached from the forced recalc) + +The IO pattern is powerful for different use cases (virtual scrolling, lazy loading, resize-responsive layouts) but does not help with point-based hit-testing. + +### Int32Array for keyed reconciliation (Inferno, ivi) + +Both Inferno and ivi use `Int32Array` for LIS (longest increasing subsequence) in keyed list reconciliation: + +```typescript +// Inferno: typed array reuse across reconciliations +let result: Int32Array; +let p: Int32Array; +let maxLen = 0; + +function lisAlgorithm(arr: Int32Array): Int32Array { + const len = arr.length; + if (len > maxLen) { + maxLen = len; + result = new Int32Array(len); + p = new Int32Array(len); + } + // ...binary search with >> 1 midpoint... +} +``` + +**Not applied** because react-grab doesn't reconcile lists. The detection loop iterates `elementsFromPoint` results linearly. + +## Reference Codebases + +| Project | Key insight | Scheduling primitive | +|---|---|---| +| [Inferno](https://github.com/infernojs/inferno) | Bitwise flags for O(1) type dispatch, `Int32Array` buffer reuse in LIS, prototype patching for V8 hidden class stability | `Promise.resolve().then` microtask | +| [SolidJS](https://github.com/solidjs/solid) | Monotonic `ExecCount` prevents redundant recomputation, three-state machine (clean/STALE/PENDING) defers work until dependencies resolve | `queueMicrotask` for signals, `rAF` for effects | +| [ivi](https://github.com/localvoid/ivi) | `DirtySubtree` flag propagation with early exit, sealed render context for JIT, local const aliases of globals, right-to-left iteration for stable `insertBefore` cursors | `queueMicrotask` for state, `rAF` for visual, `rIC` for idle | +| [pretext](https://github.com/chenglou/pretext) | Two-phase measurement (prepare once / layout many), canvas `measureText` avoids DOM reflow, `WeakMap` for GC-friendly caching, `simpleLineWalkFastPath` flag skips complex line-break logic | Fully synchronous (no scheduling needed) | From 55c4f03325f92d76928e9562d9feada1c86c3a6a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 8 Apr 2026 21:07:01 -0700 Subject: [PATCH 15/45] docs: add prehit.md design for pre-indexed element hit testing Describes an alternative to elementsFromPoint that uses IO for zero-cost rect collection, Flatbush R-tree for O(log n) spatial queries, and DOM tree order for z-order resolution. Leverages the existing freeze system to guarantee index validity for the entire selection session. Includes plans to vendor TypeScript ports of flatbush (462 LOC) and stacking-order (111 LOC) rather than adding npm dependencies. --- docs/prehit.md | 246 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/prehit.md diff --git a/docs/prehit.md b/docs/prehit.md new file mode 100644 index 000000000..6fd893f61 --- /dev/null +++ b/docs/prehit.md @@ -0,0 +1,246 @@ +# Prehit: Pre-indexed Element Hit Testing + +An alternative to `elementsFromPoint` that eliminates per-hover style recalculations by pre-indexing element bounding rects into a spatial index at activation time. + +## Problem + +Every hover detection currently costs 1-5ms on dense DOMs because `elementsFromPoint` forces a synchronous style recalculation. This recalc is triggered by the `pointer-events: none` stylesheet toggle needed for hit-testing. The cost scales with DOM size and CSS complexity. + +``` +Current hot path (per hover, 1-5ms): + suspendPointerEventsFreeze() ← toggles