Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shield-gated-hit-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-grab": patch
---

Fix hover lag on large pages by hit-testing behind a pointer shield instead of flipping `pointer-events` on the document root, coalescing scroll re-detection into a frame, and caching visual viewport reads.
161 changes: 161 additions & 0 deletions packages/react-grab/e2e/hit-test-shield.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures.js";

interface ShieldPanelRect {
left: number;
top: number;
right: number;
bottom: number;
}

interface ScrollState {
container: number;
window: number;
}

const readShieldPanelRects = (page: Page): Promise<ShieldPanelRect[] | null> =>
page.evaluate(() => {
const container = document.querySelector("[data-react-grab-hit-test-shield]");
if (!container) return null;
return [...container.children].map((panel) => {
const rect = panel.getBoundingClientRect();
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
});
});

const isPointCovered = (panelRects: ShieldPanelRect[], pointX: number, pointY: number): boolean =>
panelRects.some(
(rect) =>
pointX >= rect.left && pointX <= rect.right && pointY >= rect.top && pointY <= rect.bottom,
);

const readScrollState = (page: Page): Promise<ScrollState> =>
page.evaluate(() => ({
container: document.querySelector('[data-testid="scroll-container"]')?.scrollTop ?? -1,
window: window.scrollY,
}));

test.describe("Hit Test Shield", () => {
// The shield covers the viewport, so a wheel event targets the shield and the
// browser would scroll the page instead of the container under the pointer.
test("should scroll the container under the pointer, not the page", async ({
reactGrab,
page,
}) => {
const container = page.getByTestId("scroll-container");
await container.scrollIntoViewIfNeeded();
const containerBox = await container.boundingBox();
expect(containerBox).not.toBeNull();
if (!containerBox) return;

await reactGrab.activate();
await page.mouse.move(
containerBox.x + containerBox.width / 2,
containerBox.y + containerBox.height / 2,
{ steps: 3 },
);

const beforeScroll = await readScrollState(page);
await page.mouse.wheel(0, 300);
await expect
.poll(async () => (await readScrollState(page)).container)
.toBeGreaterThan(beforeScroll.container);
expect((await readScrollState(page)).window).toBe(beforeScroll.window);
});

test("should leave page scrolling native when nothing under the pointer scrolls", async ({
reactGrab,
page,
}) => {
const heading = page.getByRole("heading", { level: 1 }).first();
await heading.scrollIntoViewIfNeeded();
const headingBox = await heading.boundingBox();
expect(headingBox).not.toBeNull();
if (!headingBox) return;

await reactGrab.activate();
await page.mouse.move(
headingBox.x + headingBox.width / 2,
headingBox.y + headingBox.height / 2,
{ steps: 3 },
);

const beforeScroll = await readScrollState(page);
await page.mouse.wheel(0, 300);
await expect
.poll(async () => (await readScrollState(page)).window)
.toBeGreaterThan(beforeScroll.window);
});

test("should cut a hole for a same-origin frame that covers the viewport", async ({
reactGrab,
}) => {
const page = reactGrab.page;
await page.evaluate(async () => {
const iframeElement = document.createElement("iframe");
iframeElement.dataset.testid = "full-viewport-iframe";
iframeElement.srcdoc = `<body style="margin:0;height:400vh">full viewport frame</body>`;
iframeElement.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;border:0";
const didLoad = new Promise<void>((resolve) => {
iframeElement.addEventListener("load", () => resolve(), { once: true });
});
document.body.append(iframeElement);
await didLoad;
});

await page.evaluate(() => window.freezeReactGrab());

const viewportSize = page.viewportSize();
expect(viewportSize).not.toBeNull();
if (!viewportSize) return;

await expect
.poll(async () => {
const panelRects = await readShieldPanelRects(page);
if (!panelRects) return null;
return isPointCovered(panelRects, viewportSize.width / 2, viewportSize.height / 2);
})
.toBe(false);

await page.evaluate(() => {
window.unfreezeReactGrab();
document.querySelector('[data-testid="full-viewport-iframe"]')?.remove();
});
});

test("should cut a hole for a same-origin frame inside a shadow root", async ({ reactGrab }) => {
const page = reactGrab.page;
const frameBox = await page.evaluate(async () => {
const hostElement = document.createElement("div");
const shadowRoot = hostElement.attachShadow({ mode: "open" });
const iframeElement = document.createElement("iframe");
iframeElement.srcdoc = `<body style="margin:0;height:400vh">shadow frame</body>`;
iframeElement.style.cssText =
"position:fixed;left:40px;top:40px;width:200px;height:150px;border:0";
const didLoad = new Promise<void>((resolve) => {
iframeElement.addEventListener("load", () => resolve(), { once: true });
});
shadowRoot.append(iframeElement);
document.body.append(hostElement);
hostElement.dataset.testid = "shadow-frame-host";
await didLoad;
const rect = iframeElement.getBoundingClientRect();
return { centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2 };
});

await page.evaluate(() => window.freezeReactGrab());

await expect
.poll(async () => {
const panelRects = await readShieldPanelRects(page);
if (!panelRects || panelRects.length === 0) return null;
return isPointCovered(panelRects, frameBox.centerX, frameBox.centerY);
})
.toBe(false);

await page.evaluate(() => {
window.unfreezeReactGrab();
document.querySelector('[data-testid="shadow-frame-host"]')?.remove();
});
});
});
94 changes: 94 additions & 0 deletions packages/react-grab/e2e/style-invalidation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Regression guard for whole-document style invalidation during hover.
//
// The pointer-events freeze has to flip between "page frozen" and "hit-testable"
// on every element detection. Doing that by adding/removing a stylesheet (or
// toggling `HTMLStyleElement.disabled`) changes the document's active sheet set,
// which makes Blink re-collect matching rules for EVERY element: profiled at
// ~20-35ms per flip on an 8k-element page, twice per hover interruption, which
// is enough to drop 2-3 frames every time the pointer pauses and moves again.
//
// Counting restyled elements rather than milliseconds keeps this deterministic
// across machines: a scoped flip touches the root and inherits down, so no
// single recalc should approach the size of the document.
import { expect, goToHeavyView, test } from "./perf-fixtures.js";
import { idleFrame } from "./perf-recorder.js";

interface StyleRecalcSample {
elementCount: number;
durationMs: number;
}

const FULL_DOCUMENT_RESTYLE_RATIO = 0.5;

// Playwright types every trace event field as a string, but UpdateLayoutTree
// carries the restyled element count as a number under `args`.
const readRestyledElementCount = (traceEvent: { args?: unknown }): number => {
if (typeof traceEvent.args !== "object" || traceEvent.args === null) return 0;
const elementCount = Reflect.get(traceEvent.args, "elementCount");
return typeof elementCount === "number" ? elementCount : 0;
};

test.describe("style invalidation", () => {
test("hovering and scrolling never restyles the whole document", async ({ reactGrab, page }) => {
await goToHeavyView(page, "all");
const documentElementCount = await page.evaluate(() => document.querySelectorAll("*").length);
expect(documentElementCount).toBeGreaterThan(1000);

const client = await page.context().newCDPSession(page);
const recalcSamples: StyleRecalcSample[] = [];
client.on("Tracing.dataCollected", ({ value }) => {
for (const traceEvent of value) {
if (traceEvent.name !== "UpdateLayoutTree") continue;
recalcSamples.push({
elementCount: readRestyledElementCount(traceEvent),
durationMs: (Number(traceEvent.dur) || 0) / 1000,
});
}
});

await reactGrab.activate();
await page.mouse.move(600, 400, { steps: 2 });
await idleFrame(page, 2);

await client.send("Tracing.start", {
transferMode: "ReportEvents",
traceConfig: { includedCategories: ["devtools.timeline"] },
});

// Bursts with pauses between them: the pause lets the debounced freeze
// resume land, so the next burst has to flip back to hit-test mode. A
// continuous sweep would coalesce the flips and hide the regression.
for (let burstIndex = 0; burstIndex < 6; burstIndex++) {
for (let stepIndex = 0; stepIndex < 8; stepIndex++) {
await page.mouse.move(
500 + ((stepIndex * 37) % 400),
250 + ((burstIndex * 61 + stepIndex * 23) % 400),
{ steps: 1 },
);
await page.mouse.wheel(0, 100);
}
await page.waitForTimeout(220);
}
await idleFrame(page, 2);

const tracingComplete = new Promise<void>((resolve) => {
client.once("Tracing.tracingComplete", () => resolve());
});
await client.send("Tracing.end");
await tracingComplete;
await reactGrab.deactivate();

expect(recalcSamples.length).toBeGreaterThan(0);
const fullDocumentRestyles = recalcSamples.filter(
(sample) => sample.elementCount > documentElementCount * FULL_DOCUMENT_RESTYLE_RATIO,
);
expect(
fullDocumentRestyles,
`${fullDocumentRestyles.length} recalc(s) restyled over ${Math.round(
documentElementCount * FULL_DOCUMENT_RESTYLE_RATIO,
)} of ${documentElementCount} elements: ${fullDocumentRestyles
.map((sample) => `${sample.elementCount} elements/${sample.durationMs.toFixed(1)}ms`)
.join(", ")}`,
).toHaveLength(0);
});
});
19 changes: 18 additions & 1 deletion packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const THREE_PREVIEW_ARRAY_MAX_LENGTH = 4;
export const THREE_SELECTION_FALLBACK_BOUNDS_PX = 16;
export const THREE_DRAG_SELECTION_MAX_INDIVIDUAL_INSTANCES = 512;
export const IFRAME_LAYOUT_METRICS_CACHE_TTL_MS = 16;
export const VISUAL_VIEWPORT_CACHE_TTL_MS = 16;
export const BORDER_RADIUS_CACHE_TTL_MS = 200;
export const BORDER_RADIUS_SCALE_PRECISION_DECIMAL_PLACES = 3;
export const BOUNDS_RECALC_INTERVAL_MS = 100;
Expand All @@ -95,6 +96,22 @@ export const AUTO_SCROLL_SPEED_PX = 10;

export const Z_INDEX_OVERLAY = 2147483647;
export const Z_INDEX_OVERLAY_CANVAS = 2147483645;
// Below react-grab's own overlays so the toolbar stays clickable, above page
// content so the shield absorbs the page's hover, focus, and click. Page content
// stacked inside react-grab's own range (2147483645 and up) paints over the
// shield and keeps receiving hover; staying below the overlays is the tradeoff
// that keeps the toolbar usable.
export const Z_INDEX_HIT_TEST_SHIELD = 2147483644;
// Subtracting N overlapping frame holes can partition the viewport into O(N^2)
// rectangles, so past this count the shield falls back to one hole spanning
// every frame rather than creating unbounded panels on each scroll frame.
export const HIT_TEST_SHIELD_MAX_PANELS = 12;
// deltaMode line/page wheel events (Firefox, some mice) carry notch counts
// instead of pixels; a line is worth roughly one line box.
export const WHEEL_LINE_DELTA_PX = 16;
// Subpixel scroll positions never reach the exact scrollable extent, so scroll
// room is measured with a tolerance before declaring an axis exhausted.
export const SCROLL_ROOM_EPSILON_PX = 1;
export const DOCUMENT_NODE_TYPE = 9;

export const DRAG_LERP_FACTOR = 0.7;
Expand Down Expand Up @@ -180,6 +197,7 @@ export const ARROW_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRi

export const FROZEN_ELEMENT_ATTRIBUTE = "data-react-grab-frozen";
export const SAME_ORIGIN_FRAME_ATTRIBUTE = "data-react-grab-same-origin-frame";
export const HIT_TEST_SHIELD_ATTRIBUTE = "data-react-grab-hit-test-shield";

// Pausing animations individually via WAAPI avoids the full-document style
// recalc that a universal `*` selector forces — profiled at ~62ms on a real
Expand Down Expand Up @@ -267,7 +285,6 @@ export const HIERARCHY_INDENT_PX = 12;

export const ELEMENT_POSITION_CACHE_DISTANCE_THRESHOLD_PX = 2;
export const ELEMENT_POSITION_THROTTLE_MS = 16;
export const POINTER_EVENTS_RESUME_DEBOUNCE_MS = 100;
export const VISIBILITY_CACHE_TTL_MS = 50;

export const ZOOM_DETECTION_THRESHOLD = 0.01;
Expand Down
28 changes: 26 additions & 2 deletions packages/react-grab/src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { getElementsInDrag } from "../utils/get-elements-in-drag.js";
import { getElementAnchorRatio } from "../utils/get-element-anchor-ratio.js";
import { createElementBounds } from "../utils/create-element-bounds.js";
import { invalidateInteractionCaches } from "../utils/invalidate-interaction-caches.js";
import { refreshPointerEventsFreezeShields } from "../utils/pointer-events-freeze.js";
import { normalizeErrorMessage } from "../utils/normalize-error.js";
import {
createBoundsFromDragRect,
Expand Down Expand Up @@ -3278,15 +3279,34 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {

let boundsRecalcIntervalId: number | null = null;
let viewportChangeFrameId: number | null = null;
let scrollChangeFrameId: number | null = null;

const handleViewportChange = () => {
invalidateInteractionCaches();
refreshPointerEventsFreezeShields();
redetectElementUnderPointer();
setScrollVersion((version) => version + 1);
actions.incrementViewportVersion();
actions.updateContextMenuPosition();
};

// A trackpad gesture emits scroll events far faster than the display
// refreshes, and every one of them would otherwise re-run a hit test plus a
// full reactive bounds pass. Coalescing into a frame keeps the overlay in
// step with the scrolled paint (rAF runs before paint) while collapsing the
// burst into a single update.
const scheduleViewportChange = () => {
// Cache invalidation stays synchronous: it is a handful of map clears, and
// deferring it would let a pointer or context-menu hit test in the same
// frame resolve geometry from before the scroll.
invalidateInteractionCaches();
if (scrollChangeFrameId !== null) return;
scrollChangeFrameId = nativeRequestAnimationFrame(() => {
scrollChangeFrameId = null;
handleViewportChange();
});
};

// Unlike scroll, resize can flip visibility synchronously (media and
// container queries), so the visibility cache's TTL is not a safe
// staleness bound here. Resize is rare enough that the extra
Expand All @@ -3296,8 +3316,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
handleViewportChange();
};

eventListenerManager.addWindowListener("scroll", handleViewportChange, {
eventListenerManager.addWindowListener("scroll", scheduleViewportChange, {
capture: true,
passive: true,
});

let previousViewportWidth = window.innerWidth;
Expand Down Expand Up @@ -3333,7 +3354,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
visualViewport.addEventListener("resize", handleViewportResize, {
signal,
});
visualViewport.addEventListener("scroll", handleViewportChange, {
visualViewport.addEventListener("scroll", scheduleViewportChange, {
signal,
});
}
Expand Down Expand Up @@ -3383,6 +3404,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
if (viewportChangeFrameId !== null) {
nativeCancelAnimationFrame(viewportChangeFrameId);
}
if (scrollChangeFrameId !== null) {
nativeCancelAnimationFrame(scrollChangeFrameId);
}
});

eventListenerManager.addDocumentListener(
Expand Down
Loading
Loading