Skip to content

fix: resolve element selection getting stuck on busy pages#287

Open
aidenybai wants to merge 45 commits intomainfrom
fix/selection-stuck-hover-detection
Open

fix: resolve element selection getting stuck on busy pages#287
aidenybai wants to merge 45 commits intomainfrom
fix/selection-stuck-hover-detection

Conversation

@aidenybai
Copy link
Copy Markdown
Owner

@aidenybai aidenybai commented Apr 9, 2026

Summary

  • Element detection was starved on busy pagesscheduler.postTask({priority:"background"}) delayed hit-testing by 350ms–5000ms on sites like nisarg.io with continuous React renders. Replaced with requestAnimationFrame for consistent ~16ms detection.
  • Decorative hover overlays blocked detection — Empty, absolute-positioned, transparent <div> elements (used for card hover effects) were returned by elementFromPoint, preventing selection of actual content underneath. Added isDecorativeOverlay filter to skip these.
  • Reduced flaky E2E tests — Fixed race conditions in test fixtures and replaced fixed timeouts with polling assertions.

Test plan

  • pnpm build passes
  • pnpm test — 565 passed, 0 failed
  • pnpm lint — 0 errors
  • pnpm typecheck — passes
  • Verified on https://nisarg.io/ — selection follows cursor smoothly, detects child elements inside cards

Note

Medium Risk
Touches core hit-testing and stacking/visibility heuristics used on every pointer move, so regressions could affect what elements are selectable/copyable across sites. Changes are mitigated by a large new E2E suite covering overflow, containment, stacking contexts, fixed elements, and dynamic DOM updates.

Overview
Improves element selection responsiveness on busy pages by changing the hit-test scheduling in core/index.tsx from idle/background scheduling to a setTimeout macrotask, reducing pointer-move detection starvation.

Adds a new spatial element-at-point index (IntersectionObserver + Hilbert R-tree) built on activation and queried from getElementAtPosition, with additional filtering for overflow/paint containment, fixed elements, and visual stacking order (compareStackingOrder) to pick the topmost candidate while skipping empty decorative overlays.

Expands the E2E app with new UI fixtures for overflow clipping, CSS containment, stacking/transform contexts, inline elements, and overlays, and adds a comprehensive element-detection.spec.ts; existing E2E tests were stabilized by replacing fixed sleeps with polling and increasing timeouts.

Reviewed by Cursor Bugbot for commit eb5d3ce. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes element selection getting stuck on busy pages by deferring hit‑testing with setTimeout(0) and using a z‑index‑aware element‑at‑point index. Selection now respects visual stacking and clipping, ignores decorative overlays, handles offscreen cursor activation, and is faster via cached fixed‑element rects and clip‑state.

  • New Features

    • Element‑at‑point index fast path (IntersectionObserver + Hilbert R‑tree) built on activation and destroyed on deactivation; caches viewport rects/z‑index for fixed elements and ancestor clip‑state; handles fixed‑only pages; falls back to elementsFromPoint when the index isn’t ready or misses.
    • Z‑index‑aware selection: candidates filtered by overflow/paint containment, then ordered by stacking context (incl. flex/grid/sticky, will-change, contain: paint/strict/content, backdrop-filter, perspective, clip-path).
  • Bug Fixes

    • Defer hit‑testing via setTimeout(0) for responsiveness on busy pages.
    • Exclude decorative overlays from the index; fallback picks the smallest valid element from elementsFromPoint and skips zero‑area candidates; validate fast‑path results and snapshot the index’s elements array.
    • More robust freeze: use cursor coords only when in‑viewport, fix stacking‑context detection for flex/grid items and position: sticky, restore finishing all animations on unfreeze, and pass mouse coords from toolbar hovers.
    • Add extensive E2E coverage for overflow clipping, containment, stacking order, inline elements, transforms, fixed position, and dynamic DOM changes.

Written for commit eb5d3ce. Summary will update on new commits.

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.
- 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
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gym Ready Ready Preview, Comment Apr 11, 2026 2:51am
react-grab-website Ready Ready Preview, Comment Apr 11, 2026 2:51am

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 9, 2026

Open in StackBlitz

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/cli@287
npm i https://pkg.pr.new/aidenybai/react-grab/grab@287
npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/mcp@287
npm i https://pkg.pr.new/aidenybai/react-grab@287

commit: eb5d3ce

…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.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/react-grab/src/utils/is-valid-grabbable-element.ts">

<violation number="1" location="packages/react-grab/src/utils/is-valid-grabbable-element.ts:77">
P1: This overlay filter also rejects real absolutely/fixed-positioned elements like `<img>` because they have no children/text and usually compute to a transparent background.</violation>
</file>

<file name="packages/react-grab/e2e/clear-history-prompt.spec.ts">

<violation number="1" location="packages/react-grab/e2e/clear-history-prompt.spec.ts:10">
P1: The updated polling condition is insufficient for subsequent calls to `copyElement`. Both `getClipboardContent()` and `isCommentsButtonVisible()` will return truthy/true immediately if a previous copy has already occurred, causing a race condition where the function returns before the current element has been processed or added to the history.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

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);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The updated polling condition is insufficient for subsequent calls to copyElement. Both getClipboardContent() and isCommentsButtonVisible() will return truthy/true immediately if a previous copy has already occurred, causing a race condition where the function returns before the current element has been processed or added to the history.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/e2e/clear-history-prompt.spec.ts, line 10:

<comment>The updated polling condition is insufficient for subsequent calls to `copyElement`. Both `getClipboardContent()` and `isCommentsButtonVisible()` will return truthy/true immediately if a previous copy has already occurred, causing a race condition where the function returns before the current element has been processed or added to the history.</comment>

<file context>
@@ -7,8 +7,7 @@ const copyElement = async (reactGrab: ReactGrabPageObject, selector: string) =>
   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);
 };
 
</file context>
Fix with Cubic

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.
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.
Decorative hover overlays are always <div> or <span>. An allowlist of 2
tags is simpler, safer against future HTML elements, and more precise
than a 10-item blocklist of replaced elements.
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.
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.
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.
spatial-index.ts -> element-at-point-index.ts
buildSpatialIndex -> buildElementAtPointIndex
querySpatialIndex -> queryElementAtPointIndex
isSpatialIndexReady -> isElementAtPointIndexReady
destroySpatialIndex -> destroyElementAtPointIndex
SPATIAL_INDEX_ROOT_MARGIN_PX -> ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX
Replace the smallest-area heuristic with stacking order comparison
for prehit queries. A modal button (z-index: 9999) now correctly
wins over a paragraph behind it, even if the paragraph is smaller.

Adds compare-stacking-order.ts which determines visual ordering by
walking ancestor chains, comparing z-index within stacking contexts,
and falling back to DOM sibling order.

The IndexedElement no longer stores area or treeOrder since selection
is now based on visual stacking, not geometric size.
- freezePseudoStates: check cursor is within viewport bounds before
  using elementFromPoint. store.pointer starts at -1000 (offscreen);
  without the bounds check, elementFromPoint returns null and the
  querySelectorAll(":hover") fallback never runs.

- compareStackingOrder: fix operator grouping for flex items. Per CSS
  spec, flex items only create a stacking context when z-index is not
  auto. Was: (zIndex !== auto && position !== static) || isFlexItem.
  Now: zIndex !== auto && (position !== static || isFlexItem).

- getElementAtPosition fallback: take the first valid element from
  elementsFromPoint (already in paint/z-order) instead of the smallest
  by area. Consistent with prehit's stacking-order-based selection.
- freezePseudoStates: use < instead of <= for viewport bounds check.
  elementFromPoint returns null at exactly innerWidth/innerHeight
  since viewport coordinates are 0-indexed.

- Revert animation caching optimization in freezeGlobalAnimations.
  Capturing animations at freeze time missed animations created
  during the freeze (e.g., React adding DOM with CSS animations).
  The CSS rule pauses them, but finish() on unfreeze only ran on the
  cached list, causing visual jumps for missed animations. Restoring
  document.getAnimations() on unfreeze catches all animations
  including those created mid-session. Correctness over micro-opt.
The >> 8 iteration read levelC/levelD (post-modification) instead of
invertedOr/maskedAnd (saved pre-modification values from the >> 4
iteration). This matches the original flatbush which uses lowercase
c/d (saved copies) in the >> 8 step, not uppercase C/D (current).

Incorrect Hilbert values degraded R-tree spatial locality but didn't
break query correctness since the tree structure is still valid.
- Add position:sticky to unconditional stacking context check (per
  CSS spec, sticky creates a stacking context like fixed does).
- Rename isFlexItem to isFlexOrGridItem and add grid/inline-grid
  parent display checks. Grid items with non-auto z-index create
  stacking contexts just like flex items do.
- Re-validate prehit result with isValidGrabbableElement and
  getElementArea before returning. Elements indexed at activation
  could theoretically change state during the session.

- Restore [...accumulatedElements] snapshot when assigning to
  currentIndex. Without this, the elements array is shared between
  the IO accumulation buffer and the current index. Subsequent IO
  batches would mutate the live index's elements array, causing
  index/element count mismatches during concurrent queries.

currentIndex = { tree, elements: [...accumulatedElements] };
},
{ rootMargin: `${ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX}px` },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IntersectionObserver rootMargin has no filtering effect

Low Severity

The ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX constant and rootMargin option are effectively dead configuration. IntersectionObserver always fires initial entries for all observed elements regardless of whether they intersect, and the callback never checks entry.isIntersecting. Every element observed via the TreeWalker is indexed unconditionally, making the rootMargin parameter meaningless. The constant in constants.ts suggests spatial filtering was intended but isn't implemented.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a34a57a. Configure here.

…rom index

The bugbot-driven change from smallest-area to first-valid (topmost)
in the fallback path reintroduced the original "stuck on decorative
overlay" bug. Decorative overlay divs (positioned, empty, transparent)
are topmost in elementsFromPoint results, so taking the first valid
element returns the overlay instead of the content underneath.

Fallback path: restored smallest-area selection. elementsFromPoint
returns in z-order; smallest-area naturally skips large overlays in
favor of specific content elements. Handles modals correctly because
modal content elements are smaller than background content at the
same point.

Prehit path: exclude positioned elements with no children and no text
from the R-tree. These are decorative hover-effect divs that should
never be selection targets. The stacking-order sort then operates only
on meaningful content elements.
…coverage

- Cache fixed element viewport rects and z-indexes at build time to
  eliminate getBoundingClientRect and compareStackingOrder calls from
  the hot query path
- Add WeakMap clip state cache for isVisibleAtPoint to avoid repeated
  getComputedStyle calls on shared ancestors
- Handle nullable R-tree for fixed-only pages (no regular elements)
- Recognize contain:paint/strict/content, backdrop-filter, perspective,
  and clip-path as stacking context creators in compareStackingOrder
- Invalidate position cache when cached element is disconnected
- Extract isDecorativeOverlay to shared utility, exclude replaced
  elements (img, video, canvas, svg, etc.)
- Remove duplicated viewport coverage check in isFullViewportOverlay
- Combine duplicate freeze-pseudo-states imports in primitives.ts
- Add 57 element detection e2e tests covering overflow clipping, CSS
  containment, stacking order, inline elements, decorative overlays,
  fixed position, transforms, and dynamic DOM changes
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit eb5d3ce. Configure here.

if (PAINT_CONTAIN_VALUES.has(keyword)) return true;
}
return false;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate hasPaintContainment with divergent implementations

Low Severity

Two separate hasPaintContainment functions exist in element-at-point-index.ts and compare-stacking-order.ts with different implementations. One uses a Set lookup after splitting by space, while the other uses exact string matches plus String.includes. They happen to be functionally equivalent today, but divergent implementations risk inconsistent bug fixes in the future.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eb5d3ce. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant