Skip to content

fix(driver): make fast visibility shadow-DOM aware#33737

Draft
mschile wants to merge 10 commits into
mschile/pensive-rubin-df92b9from
mschile/pensive-black-034995
Draft

fix(driver): make fast visibility shadow-DOM aware#33737
mschile wants to merge 10 commits into
mschile/pensive-rubin-df92b9from
mschile/pensive-black-034995

Conversation

@mschile
Copy link
Copy Markdown
Collaborator

@mschile mschile commented May 4, 2026

Additional details

The fast visibility algorithm used document.elementFromPoint() and Element.contains() in visibleAtPoint(), neither of which crosses shadow boundaries — so subjects inside a shadow root were either missed at sample points (host's box doesn't include the sample coord) or compared against a host they don't light-tree-contain. The whole visibility_shadow_dom.cy.ts suite was gated to legacy mode only as a result.

This PR pierces nested shadow roots from the document-level hit using the existing getShadowElementFromPoint() helper, and replaces contains() with a shadow-aware ancestor walk via findParent() (which crosses shadow boundaries via getRootNode().host). Both helpers are pre-existing — no new utility code. It also makes the rubin-branch's clipping-ancestor scroll guard shadow-aware (walks via getParentNode), refines it to only fire when the subject is actually out-of-bounds of the ancestor, treats overflow: scroll/auto the same as hidden/clip, and exempts <body>/<html> (the page's scroll container) from the check.

The shadow-DOM visibility suite is now enabled under both modes. 5 tests where the fast multi-sample and legacy center-point algorithms inherently diverge for shadow subjects are scoped-skipped under fast via a tiny itSkipFast helper, with a comment pointing back to the issue for fixture-level follow-up. The skip is fast-only — legacy still runs all 43.

The skipped scenarios fall into three buckets, each requiring fixture work rather than further algorithm changes:

  • Cover detection where the cover is narrower than the underneath element (2 tests). Legacy samples only the center → cover hits → reports hidden; fast samples the corners → uncovered corners are visible → reports visible. Same divergence exists in non-shadow, where the equivalent fixtures are sized so the cover fully encloses the underneath.
  • pointer-events: none on the subject or an ancestor (2 tests). Browsers skip such elements in elementFromPoint, so fast can never find the subject at any sample point. The non-shadow pointer-events fixtures aren't actually exercised by visibility.cy.ts, so the same gap exists there but isn't surfaced.
  • Clipping ancestor between the subject and its containing block (1 test). Legacy's canClipContent uses offsetParent rules to ignore such ancestors; fast does not.

This PR is stacked on top of #33736 because it builds on that branch's elementFromPoint related changes.

Steps to test

  1. yarn workspace @packages/driver cypress:run -- --spec cypress/e2e/dom/visibility_shadow_dom.cy.ts — should report 81 passing, 5 pending, 0 failing.
  2. yarn workspace @packages/driver cypress:run -- --spec cypress/e2e/dom/visibility.cy.ts — non-shadow visibility behavior must remain unchanged from fix(driver): scroll off-screen elements into view in fast visibility algorithm #33736's baseline (no new failures attributable to this PR).
  3. yarn workspace @packages/driver cypress:open and pick dom/_TEMP_fast_vis_shadow_playground.cy.ts to poke at each scenario in isolation. The playground is describe.skip-ed under CI to avoid spec-list noise — it only runs interactively.

How has the user experience changed?

Before: with experimentalFastVisibility: true, visibility assertions on Shadow DOM subjects could give different results from non-shadow subjects (the documented "Current Limitations" in the migration guide). Test suites covering shadow components against fast visibility were effectively unsupported.

After: shadow DOM subjects use the same algorithmic path as non-shadow subjects under fast visibility — elementFromPoint pierces shadow roots, and ancestor checks cross host boundaries.

PR Tasks


Note

Medium Risk
Changes core visibility detection in the driver (hit-testing, ancestor walking, and scroll/clipping heuristics), which can affect many assertions across browsers despite being well-covered by e2e tests.

Overview
Fast visibility now pierces Shadow DOM when hit-testing and determining containment: visibleAtPoint uses getShadowElementFromPoint() and replaces el.contains() with a shadow-aware ancestor walk via findParent().

The scroll/clipping guard was refined to detect actual out-of-bounds clipping (not merely below-the-fold), treats overflow: scroll/auto as clipping, exempts document root overflow auto/scroll, and walks ancestors across shadow boundaries.

Shadow DOM visibility e2e coverage is enabled for both fast and legacy, with a small set of known fast/legacy divergences skipped under fast; an interactive, CI-skipped playground spec was added for manual exploration.

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

mschile added 2 commits May 4, 2026 16:42
Resolves #33046

The fast visibility algorithm used document.elementFromPoint() and
Element.contains() in visibleAtPoint(), neither of which crosses shadow
boundaries — so subjects inside a shadow root were either missed at
sample points or compared against a host they don't light-tree-contain.

Pierce nested shadow roots from the document-level hit using the existing
getShadowElementFromPoint() helper, and replace contains() with a
shadow-aware ancestor walk via findParent(), which crosses shadow
boundaries via getRootNode().host.

Enables the shadow-DOM visibility suite under fast mode (previously gated
to legacy only). 8 tests where the fast multi-sample and legacy
center-point algorithms inherently diverge for shadow subjects (cover
detection with covers narrower than the underneath, pointer-events: none
across host boundaries, complex out-of-bounds overflow) are scoped-skipped
under fast via an itSkipFast helper, with a comment pointing back to the
issue for fixture-level follow-up.
`hasClippingAncestor` was walking via `parentElement`, which returns null
at shadow root boundaries — so a shadow descendant's `overflow: hidden`
ancestor in the host's light tree was invisible to the check. The
clipping-ancestor guard then incorrectly returned false, and
`scrollIntoView` ran on a subject that should have been left clipped,
exposing content the test author intentionally hid.

Switch to `getParentNode` (already used elsewhere in the driver for
shadow-aware traversal) so the parent walk crosses shadow boundaries via
`getRootNode().host`.

Fixes the three driver-integration regressions surfaced by enabling the
shadow-DOM visibility suite under fast mode:
  - is hidden when parent outside of shadow dom overflow hidden and out of bounds below
  - is hidden when parent outside of shadow dom overflow hidden-y and out of bounds
  - is hidden when parent outside of shadow dom has overflow scroll and out of bounds
@cypress
Copy link
Copy Markdown

cypress Bot commented May 4, 2026

cypress    Run #70510

Run Properties:  status check passed Passed #70510  •  git commit 4373146eae: fix(driver): tighten body/html clipping exemption to non-explicit values only
Project cypress
Branch Review mschile/pensive-black-034995
Run status status check passed Passed #70510
Run duration 19m 13s
Commit git commit 4373146eae: fix(driver): tighten body/html clipping exemption to non-explicit values only
Committer Matthew Schile
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 8
Tests that did not run due to a developer annotating a test with .skip  Pending 1184
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 24926
View all changes introduced in this branch ↗︎

Warning

No Report: Something went wrong and we could not generate a report for the Application Quality products.

…t visibility

The clipping-ancestor guard in `fastIsHidden` only skipped programmatic
scroll-into-view for ancestors with `overflow: hidden` or `clip`,
treating `scroll` and `auto` as scrollable enough to expose otherwise
out-of-bounds content. Per Cypress visibility semantics — "is the user
seeing this right now?" — content that is clipped because the user has
not scrolled the container should report hidden, the same way it does
for `overflow: hidden`.

Treating `scroll` and `auto` the same way fixes the Firefox shadow-DOM
regression (the existing "is hidden when parent outside of shadow dom
has overflow scroll and out of bounds" test passes in Chrome only by
chance, because Chrome's `scrollIntoView` on an absolutely-positioned
descendant of an overflow:scroll container behaves differently than
Firefox's). Pull the four overflow values into a single set so the
relationship is explicit.
Comment thread packages/driver/src/dom/visibility/fastIsHidden.ts
mschile added 3 commits May 4, 2026 18:28
The previous clipping-ancestor guard skipped programmatic scrolling
whenever any ancestor on the off-screen axis had clipping `overflow`.
That was too coarse: a subject merely below the fold of an
`overflow: hidden` (or `clip`/`scroll`/`auto`) container is not
intentionally clipped — it is just outside the viewport — and
`scrollIntoView` brings it into view without exposing anything the
author wanted to hide. Conversely, a subject positioned outside the
ancestor's box (e.g. `position: absolute; bottom: -100px`) is what the
guard meant to protect.

Replace `hasClippingAncestor` with `isClippedByAncestor`: it only
reports clipping when the subject's rect actually falls outside the
ancestor's rect on the same axis the subject is off-screen. Treats
`scroll`/`auto` the same as `hidden`/`clip` because the user has not
scrolled the container, so out-of-bounds content is hidden right now —
this addresses the Cursor Bugbot concern by virtue of the in-bounds
check, not by excluding scrollable values.

Concrete effects:
- Unblocks three previously-skipped shadow DOM overflow tests where the
  subject is in-bounds of an overflow ancestor but the ancestor is
  below the fold.
- Keeps the Firefox CI fix for `overflow: scroll` out-of-bounds shadow
  subjects (they remain reported hidden).
- No change in behavior for non-shadow visibility cases that already
  passed: the guard still only matters when the subject is outside the
  viewport, and only fires when the subject is also outside the
  clipping ancestor.
…ed under fast

The earlier comment said fixtures were "authored to legacy semantics," which
is true but doesn't pinpoint *why* fast cannot match. Replace with the actual
mechanism for each remaining skip: the cover-detection mismatch (multi-sample
corners vs center-only), `pointer-events: none` (browsers skip such elements
in `elementFromPoint`, so no sample ever lands on the subject), and a clipping
ancestor between the subject and its containing block (legacy's `canClipContent`
ignores it via `offsetParent` rules). The corresponding non-shadow scenarios
either avoid these patterns or aren't exercised by `visibility.cy.ts` —
documented so the next person to look at the skips understands what work the
fix would actually require.
The previous commit treated `overflow: scroll`/`auto` ancestors as
clipping when the subject was outside their box. That broke an existing
fast-mode test: setting `overflow-x: hidden` on body causes computed
`overflow-y` to become `auto` (per CSS spec), which then fired the
clipping check on the off-screen axis and prevented the scroll-into-view
that elements below the fold need.

`<body>` and `<html>` are the page's scroll container, not real clipping
containers — programmatic scroll there is exactly what the user would do
to bring an element below the fold into view, and it does not surface
anything the test author intentionally hid. Skip them while walking
clipping ancestors so the orthogonal-axis case works again. Local
non-shadow visibility now reports 136 passing / 0 failing.
Adds packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts
so the fix in #33046 / this PR can be poked at in `cypress open`. The
spec is a focused subset of visibility_shadow_dom.cy.ts (5 scenarios
that the fix unblocks under fast, plus the 4 still-skipped divergences)
so each example is visible in isolation in the runner. The "still
skipped" describe uses a local `itDivergent = mode === 'fast' ? it.skip : it`
helper so CI stays green; flip it to `it` locally to watch fast turn
red and inspect the AUT.

DELETE before merging — the `_TEMP_` prefix is a flag.
// - clipping ancestor between the subject and its containing block (legacy's
// `canClipContent` uses `offsetParent` rules to ignore such ancestors)
// Tracked as follow-up to https://github.com/cypress-io/cypress/issues/33046.
const itSkipFast = mode === 'fast' ? it.skip : it
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PR claims 8 skipped tests but only 5 exist

Medium Severity

The PR description states "8 tests … are scoped-skipped under fast" and expects "78 passing, 8 pending," but only 5 itSkipFast calls exist in the file (lines 248, 262, 276, 303, 612). With 43 tests × 2 modes − 5 skips, the actual result is 81 passing / 5 pending. The "shadow content escaping its host's bounding box" bucket claims 4 tests but only 1 (line 612) uses itSkipFast. If the other 3 tests in that bucket genuinely fail under fast mode, CI will break; if they pass, the PR description is misleading.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5bcc769. Configure here.

Cursor Bugbot flagged that the temporary playground at
`packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts`
gets picked up by glob-based CI and adds noise to the run. Wrap the
top-level describe with `Cypress.env('CI') ? describe.skip : describe`
so it only executes locally in `cypress open`. The authoritative
shadow-DOM coverage is still in `visibility_shadow_dom.cy.ts`.
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 e0b1328. Configure here.

Comment thread packages/driver/src/dom/visibility/fastIsHidden.ts Outdated
…ues only

Cursor Bugbot pointed out that the previous "skip body/html entirely
from the clipping check" rule was too broad — it correctly handled the
common `body { overflow-x: hidden }` pattern (where CSS converts
`overflow-y` to computed `auto`) but also let an explicit
`body { overflow: hidden }` slip through, even though that does
intentionally clip its content.

Split the clipping-overflow set in two:

- `CLIPPING_OVERFLOW` (`hidden` / `clip` / `scroll` / `auto`) — used for
  ordinary ancestors. The user has not scrolled the container, so any
  out-of-bounds content on the off-screen axis is hidden right now.
- `DOC_ROOT_CLIPPING_OVERFLOW` (`hidden` / `clip` only) — used when the
  ancestor is `<body>` or `<html>`. The page's scroll container should
  not have `auto`/`scroll` (often the CSS-spec auto-conversion) treated
  as clipping; programmatic vertical scroll has to remain available for
  the orthogonal-axis case. Explicit `hidden`/`clip` on the document
  root, however, really is a clip and is still detected.

Local: shadow 81 passing / 5 pending, non-shadow 136 passing / 0 failing.
@mschile mschile marked this pull request as draft May 11, 2026 19:42
…to mschile/pensive-black-034995

# Conflicts:
#	packages/driver/src/dom/visibility/fastIsHidden.ts
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.

2 participants