From c4d500439026dbbbb5bfa8a4a2d60c8a6e6a7cda Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Mon, 4 May 2026 16:42:43 -0600 Subject: [PATCH 1/9] fix(driver): make fast visibility shadow-DOM aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../e2e/dom/visibility_shadow_dom.cy.ts | 24 +++++++++++-------- .../driver/src/dom/visibility/fastIsHidden.ts | 16 +++++++++++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts b/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts index aa2d2d23af4..789ca5a4196 100644 --- a/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts +++ b/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts @@ -5,13 +5,17 @@ const { $ } = Cypress describe('src/cypress/dom/visibility - shadow dom', () => { let add: (el: string, shadowEl: string, rootIdentifier: string) => JQuery - // #TODO: support shadow dom in fast visibility algorithm: https://github.com/cypress-io/cypress/issues/33046 - const modes = ['legacy'] + const modes = ['fast', 'legacy'] for (const mode of modes) { describe(`${mode}`, { experimentalFastVisibility: mode === 'fast', }, () => { + // Tests where the fast (multi-sample) and legacy (center-point) algorithms + // diverge for shadow DOM subjects. Fixtures here were authored to legacy + // semantics; tracked as follow-up to https://github.com/cypress-io/cypress/issues/33046. + const itSkipFast = mode === 'fast' ? it.skip : it + beforeEach(() => { cy.visit('/fixtures/empty.html').then((win) => { win.customElements.define('shadow-root', class extends win.HTMLElement { @@ -233,7 +237,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($descendentPosFixedInside).find('button', { includeShadowDom: true }).should('not.be.hidden') }) - it('is hidden if position: fixed and covered by element outside of shadow dom', () => { + itSkipFast('is hidden if position: fixed and covered by element outside of shadow dom', () => { const $coveredUpByOutsidePosFixed = add( `
@@ -247,7 +251,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($coveredUpByOutsidePosFixed).find('#inside-underneath', { includeShadowDom: true }).should('not.be.visible') }) - it('is hidden if outside of shadow dom with position: fixed and covered by element inside of shadow dom', () => { + itSkipFast('is hidden if outside of shadow dom with position: fixed and covered by element inside of shadow dom', () => { const $coveredUpByShadowPosFixed = add( `
underneath
@@ -261,7 +265,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($coveredUpByShadowPosFixed).find('#outside-underneath', { includeShadowDom: true }).should('not.be.visible') }) - it('is visible if position: fixed and parent outside shadow dom has pointer-events: none', () => { + itSkipFast('is visible if position: fixed and parent outside shadow dom has pointer-events: none', () => { const $parentPointerEventsNone = add( `
@@ -288,7 +292,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($parentPointerEventsNoneCovered).find('span', { includeShadowDom: true }).should('not.be.visible') }) - it('is visible if pointer-events: none and parent outside shadow dom has position: fixed', () => { + itSkipFast('is visible if pointer-events: none and parent outside shadow dom has position: fixed', () => { const $childPointerEventsNone = add( `
@@ -456,7 +460,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($elOutOfPosAbsParentBounds).find('span', { includeShadowDom: true }).should('not.be.visible') }) - it('is visible when parent absolutely positioned and overflow hidden and not out of bounds', () => { + itSkipFast('is visible when parent absolutely positioned and overflow hidden and not out of bounds', () => { const $elInPosAbsParentsBounds = add( `
@@ -546,7 +550,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($elIsRelativeAndOutOfBoundsOfAncestorButAncestorShowsOverflow).find('span', { includeShadowDom: true }).should('not.be.hidden') }) - it('is visible when parent inside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => { + itSkipFast('is visible when parent inside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => { const $insideParentOutOfBoundsButElInBounds = add( `
@@ -563,7 +567,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($insideParentOutOfBoundsButElInBounds).find('span', { includeShadowDom: true }).should('not.be.hidden') }) - it('is visible when parent outside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => { + itSkipFast('is visible when parent outside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => { const $outsideParentOutOfBoundsButElInBounds = add( `
@@ -597,7 +601,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap(el).find('#visible-button', { includeShadowDom: true }).should('not.be.hidden') }) - it('is visible when element is relatively positioned and parent element is absolutely positioned and ancestor has overflow auto', function () { + itSkipFast('is visible when element is relatively positioned and parent element is absolutely positioned and ancestor has overflow auto', function () { const el = add( `
diff --git a/packages/driver/src/dom/visibility/fastIsHidden.ts b/packages/driver/src/dom/visibility/fastIsHidden.ts index 62059003f1a..a96b5ed3aec 100644 --- a/packages/driver/src/dom/visibility/fastIsHidden.ts +++ b/packages/driver/src/dom/visibility/fastIsHidden.ts @@ -2,6 +2,8 @@ import $elements from '../elements' import { memoize } from './memoize' import { unwrap, wrap, isJquery } from '../jquery' import { scrollBehaviorOptionsMap } from '../../util/scrollBehavior' +import { getShadowElementFromPoint } from '../elements/shadow' +import { findParent } from '../elements/find' import Debug from 'debug' const debug = Debug('cypress:driver:dom:visibility:fastIsHidden') @@ -11,11 +13,21 @@ const { isOption, isOptgroup, isBody, isHTML } = $elements const getBoundingClientRect = memoize((el: HTMLElement) => el.getBoundingClientRect()) const visibleAtPoint = memoize(function (el: HTMLElement, x: number, y: number): boolean { - const elAtPoint = el.ownerDocument.elementFromPoint(x, y) + const lightElAtPoint = el.ownerDocument.elementFromPoint(x, y) + + if (!lightElAtPoint) return false + + // Pierce nested shadow roots so the comparison reflects what the user actually sees. + const elAtPoint = getShadowElementFromPoint(lightElAtPoint, x, y) debug('visibleAtPoint', el, elAtPoint) - return Boolean(elAtPoint) && (elAtPoint === el || el.contains(elAtPoint)) + if (!elAtPoint) return false + + if (elAtPoint === el) return true + + // Shadow-aware ancestor walk: findParent crosses shadow boundaries via getRootNode().host. + return findParent(elAtPoint, (parent: HTMLElement) => parent === el ? parent : null) === el }) export function fastIsHidden (subject: JQuery | HTMLElement, options: { checkOpacity: boolean } = { checkOpacity: true }): boolean { From 2d7cb8d36012699bafcb295fa57d1b84f7c22c62 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Mon, 4 May 2026 17:09:30 -0600 Subject: [PATCH 2/9] fix(driver): walk shadow boundaries when checking for clipping ancestor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- packages/driver/src/dom/visibility/fastIsHidden.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/driver/src/dom/visibility/fastIsHidden.ts b/packages/driver/src/dom/visibility/fastIsHidden.ts index a96b5ed3aec..dbae9cecc22 100644 --- a/packages/driver/src/dom/visibility/fastIsHidden.ts +++ b/packages/driver/src/dom/visibility/fastIsHidden.ts @@ -3,7 +3,7 @@ import { memoize } from './memoize' import { unwrap, wrap, isJquery } from '../jquery' import { scrollBehaviorOptionsMap } from '../../util/scrollBehavior' import { getShadowElementFromPoint } from '../elements/shadow' -import { findParent } from '../elements/find' +import { findParent, getParentNode } from '../elements/find' import Debug from 'debug' const debug = Debug('cypress:driver:dom:visibility:fastIsHidden') @@ -148,7 +148,9 @@ function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean { const offscreenX = rect.right <= 0 || rect.left >= win.innerWidth const offscreenY = rect.bottom <= 0 || rect.top >= win.innerHeight - let current: HTMLElement | null = el.parentElement + // Walk via getParentNode so we cross shadow root boundaries — a shadow + // descendant's clipping ancestor often lives in the host's light tree. + let current: HTMLElement | null = getParentNode(el) while (current) { const { overflowX, overflowY } = win.getComputedStyle(current) @@ -161,7 +163,7 @@ function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean { return true } - current = current.parentElement + current = getParentNode(current) } return false From 8a3dea967a343af209a85b6dee3598a62e53b4e9 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Mon, 4 May 2026 17:33:35 -0600 Subject: [PATCH 3/9] fix(driver): treat overflow scroll/auto ancestors as clipping for fast visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/driver/src/dom/visibility/fastIsHidden.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/driver/src/dom/visibility/fastIsHidden.ts b/packages/driver/src/dom/visibility/fastIsHidden.ts index dbae9cecc22..9571c28c5b9 100644 --- a/packages/driver/src/dom/visibility/fastIsHidden.ts +++ b/packages/driver/src/dom/visibility/fastIsHidden.ts @@ -137,6 +137,8 @@ function isOutsideViewport (el: HTMLElement, rect: DOMRect): boolean { ) } +const CLIPPING_OVERFLOW = new Set(['hidden', 'clip', 'scroll', 'auto']) + function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean { const win = el.ownerDocument.defaultView @@ -144,7 +146,9 @@ function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean { // Only ancestors clipping on the off-screen axis matter — e.g. `body { overflow-x: hidden }` // (a common pattern to suppress horizontal scrollbars) must not block vertical scrolling - // for elements below the fold. + // for elements below the fold. Treat `scroll` and `auto` as clipping too: the user has + // not scrolled, so out-of-bounds content is not visible right now and we should not + // surface it programmatically. const offscreenX = rect.right <= 0 || rect.left >= win.innerWidth const offscreenY = rect.bottom <= 0 || rect.top >= win.innerHeight @@ -155,11 +159,11 @@ function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean { while (current) { const { overflowX, overflowY } = win.getComputedStyle(current) - if (offscreenX && (overflowX === 'hidden' || overflowX === 'clip')) { + if (offscreenX && CLIPPING_OVERFLOW.has(overflowX)) { return true } - if (offscreenY && (overflowY === 'hidden' || overflowY === 'clip')) { + if (offscreenY && CLIPPING_OVERFLOW.has(overflowY)) { return true } From 87a25f2c2b1ae82c18e3009f59cfae0aaa28d10f Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Mon, 4 May 2026 18:28:31 -0600 Subject: [PATCH 4/9] fix(driver): scroll in-bounds clipped subjects below the fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../e2e/dom/visibility_shadow_dom.cy.ts | 6 +-- .../driver/src/dom/visibility/fastIsHidden.ts | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts b/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts index 789ca5a4196..47474af48cd 100644 --- a/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts +++ b/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts @@ -460,7 +460,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($elOutOfPosAbsParentBounds).find('span', { includeShadowDom: true }).should('not.be.visible') }) - itSkipFast('is visible when parent absolutely positioned and overflow hidden and not out of bounds', () => { + it('is visible when parent absolutely positioned and overflow hidden and not out of bounds', () => { const $elInPosAbsParentsBounds = add( `
@@ -550,7 +550,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($elIsRelativeAndOutOfBoundsOfAncestorButAncestorShowsOverflow).find('span', { includeShadowDom: true }).should('not.be.hidden') }) - itSkipFast('is visible when parent inside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => { + it('is visible when parent inside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => { const $insideParentOutOfBoundsButElInBounds = add( `
@@ -567,7 +567,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => { cy.wrap($insideParentOutOfBoundsButElInBounds).find('span', { includeShadowDom: true }).should('not.be.hidden') }) - itSkipFast('is visible when parent outside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => { + it('is visible when parent outside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => { const $outsideParentOutOfBoundsButElInBounds = add( `
diff --git a/packages/driver/src/dom/visibility/fastIsHidden.ts b/packages/driver/src/dom/visibility/fastIsHidden.ts index 9571c28c5b9..d1c1efe925c 100644 --- a/packages/driver/src/dom/visibility/fastIsHidden.ts +++ b/packages/driver/src/dom/visibility/fastIsHidden.ts @@ -70,11 +70,12 @@ export function fastIsHidden (subject: JQuery | HTMLElement, option let boundingRect = getBoundingClientRect(subject) - // Don't scroll if any ancestor clips the subject in the direction it is - // off-screen — `scrollIntoView` would scroll the clipping container (it's - // programmatically scrollable even though it's not user-scrollable) and - // expose content the test author intentionally clipped. - if (isOutsideViewport(subject, boundingRect) && !hasClippingAncestor(subject, boundingRect)) { + // Don't scroll if the subject is out-of-bounds of a clipping ancestor on the + // off-screen axis — `scrollIntoView` would programmatically scroll the + // clipping container and surface content the test author intentionally + // clipped. When the subject is *in-bounds* of its clipping ancestor (just + // below the fold), scrolling is safe and necessary to bring it into view. + if (isOutsideViewport(subject, boundingRect) && !isClippedByAncestor(subject, boundingRect)) { const scrollBehavior = Cypress.config('scrollBehavior') if (scrollBehavior !== false) { @@ -139,32 +140,40 @@ function isOutsideViewport (el: HTMLElement, rect: DOMRect): boolean { const CLIPPING_OVERFLOW = new Set(['hidden', 'clip', 'scroll', 'auto']) -function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean { +// True iff some ancestor with clipping `overflow` on the same axis the subject is +// off-screen has the subject *out-of-bounds* — i.e., the subject is intentionally +// clipped from the user's view. Subjects merely below the fold of an in-bounds +// clipping ancestor are not "clipped"; they're just scrolled away. +// +// Walk via getParentNode so the search crosses shadow root boundaries — a shadow +// descendant's clipping ancestor often lives in the host's light tree. Treat +// `scroll` and `auto` as clipping too: the user has not scrolled, so any content +// outside the visible region is hidden right now and should not be surfaced +// programmatically. +function isClippedByAncestor (el: HTMLElement, rect: DOMRect): boolean { const win = el.ownerDocument.defaultView if (!win) return false - // Only ancestors clipping on the off-screen axis matter — e.g. `body { overflow-x: hidden }` - // (a common pattern to suppress horizontal scrollbars) must not block vertical scrolling - // for elements below the fold. Treat `scroll` and `auto` as clipping too: the user has - // not scrolled, so out-of-bounds content is not visible right now and we should not - // surface it programmatically. const offscreenX = rect.right <= 0 || rect.left >= win.innerWidth const offscreenY = rect.bottom <= 0 || rect.top >= win.innerHeight - // Walk via getParentNode so we cross shadow root boundaries — a shadow - // descendant's clipping ancestor often lives in the host's light tree. let current: HTMLElement | null = getParentNode(el) while (current) { const { overflowX, overflowY } = win.getComputedStyle(current) + const ancestorRect = current.getBoundingClientRect() if (offscreenX && CLIPPING_OVERFLOW.has(overflowX)) { - return true + if (rect.left < ancestorRect.left || rect.right > ancestorRect.right) { + return true + } } if (offscreenY && CLIPPING_OVERFLOW.has(overflowY)) { - return true + if (rect.top < ancestorRect.top || rect.bottom > ancestorRect.bottom) { + return true + } } current = getParentNode(current) From c569c1832a08cca02ccbe7360b26dcedb9f25944 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Mon, 4 May 2026 18:52:16 -0600 Subject: [PATCH 5/9] docs(driver): explain why specific shadow visibility tests stay skipped under fast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../cypress/e2e/dom/visibility_shadow_dom.cy.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts b/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts index 47474af48cd..08a9ca88540 100644 --- a/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts +++ b/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts @@ -11,9 +11,17 @@ describe('src/cypress/dom/visibility - shadow dom', () => { describe(`${mode}`, { experimentalFastVisibility: mode === 'fast', }, () => { - // Tests where the fast (multi-sample) and legacy (center-point) algorithms - // diverge for shadow DOM subjects. Fixtures here were authored to legacy - // semantics; tracked as follow-up to https://github.com/cypress-io/cypress/issues/33046. + // Tests scoped-skipped under fast where fast and legacy fundamentally differ + // because of fast's reliance on `elementFromPoint`. The corresponding non-shadow + // fixtures either avoid these edge cases or aren't exercised by visibility.cy.ts: + // - cover detection where the cover is narrower than the underneath element + // (fast samples four corners; legacy only the center) + // - `pointer-events: none` on the subject or an ancestor (browsers skip such + // elements in `elementFromPoint`, so fast can never find the subject at any + // sample point — even though the element is rendered) + // - 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 beforeEach(() => { From f5a46c4214e7571554ee119c905a972e8fd751b3 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Mon, 4 May 2026 19:12:01 -0600 Subject: [PATCH 6/9] fix(driver): exempt the document root from the clipping-ancestor check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. `` and `` 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. --- packages/driver/src/dom/visibility/fastIsHidden.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/driver/src/dom/visibility/fastIsHidden.ts b/packages/driver/src/dom/visibility/fastIsHidden.ts index d1c1efe925c..2eab4192136 100644 --- a/packages/driver/src/dom/visibility/fastIsHidden.ts +++ b/packages/driver/src/dom/visibility/fastIsHidden.ts @@ -151,7 +151,8 @@ const CLIPPING_OVERFLOW = new Set(['hidden', 'clip', 'scroll', 'auto']) // outside the visible region is hidden right now and should not be surfaced // programmatically. function isClippedByAncestor (el: HTMLElement, rect: DOMRect): boolean { - const win = el.ownerDocument.defaultView + const doc = el.ownerDocument + const win = doc.defaultView if (!win) return false @@ -161,6 +162,16 @@ function isClippedByAncestor (el: HTMLElement, rect: DOMRect): boolean { let current: HTMLElement | null = getParentNode(el) while (current) { + // Skip the document's root and body — they are the page's scroll container, + // not real clipping containers. Setting `overflow-x: hidden` on body, for + // example, makes computed `overflow-y` become `auto` per CSS, which would + // otherwise spuriously trip the clipping check on the off-screen axis and + // block legitimate vertical scrolling. + if (current === doc.body || current === doc.documentElement) { + current = getParentNode(current) + continue + } + const { overflowX, overflowY } = win.getComputedStyle(current) const ancestorRect = current.getBoundingClientRect() From 5bcc7693c588d46b8d044fbcba5aa4194796af0c Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Wed, 6 May 2026 23:08:05 -0600 Subject: [PATCH 7/9] chore(driver): TEMPORARY playground spec for fast-visibility shadow DOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../_TEMP_fast_vis_shadow_playground.cy.ts | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts diff --git a/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts b/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts new file mode 100644 index 00000000000..3521e55ab9a --- /dev/null +++ b/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts @@ -0,0 +1,194 @@ +// TEMPORARY playground for poking at experimentalFastVisibility + Shadow DOM. +// Pairs with cypress-io/cypress#33046 / #33737. Delete when no longer needed. +// +// Run in `cypress open` and pick this spec. Each example renders its own DOM +// in the AUT and asserts visibility under both `fast` and `legacy` so you can +// flip between the two and see the difference. The assertions match what +// visibility_shadow_dom.cy.ts asserts; the fixtures are duplicated here so the +// runner shows them in isolation rather than scrolled past in a 60+ test list. + +export {} // make typescript see this as a module + +const { $ } = Cypress + +const buildAdd = (win: Window) => { + if (!(win.customElements as any).get('playground-host')) { + win.customElements.define('playground-host', class extends win.HTMLElement { + constructor () { + super() + this.attachShadow({ mode: 'open' }) + this.style.display = 'block' + } + }) + } + + return (lightHTML: string, shadowHTML: string, hostSelector: string) => { + const $light = $(lightHTML).appendTo(cy.$$('body')) + + $(shadowHTML).appendTo(cy.$$(hostSelector)[0].shadowRoot!) + + return $light + } +} + +describe('fast visibility + Shadow DOM playground', () => { + let add: (lightHTML: string, shadowHTML: string, hostSelector: string) => JQuery + + for (const mode of ['fast', 'legacy']) { + describe(`${mode} mode`, { + experimentalFastVisibility: mode === 'fast', + }, () => { + beforeEach(() => { + cy.visit('/fixtures/empty.html').then((win) => { + add = buildAdd(win) + + // Match visibility_shadow_dom.cy.ts: ensure body is scrollable and + // already scrolled, so layout/scroll quirks surface the way they do + // in real apps. + const $sentinel = $(`
Should be in view
`).appendTo(cy.$$('body')) + + $sentinel.get(1).scrollIntoView() + }) + }) + + describe('✅ now works under fast (regressions fixed by #33737)', () => { + it('hides shadow content when its host is `visibility: hidden`', () => { + const $host = add( + ``, + ``, + '#host-vis-hidden', + ) + + cy.wrap($host).find('button', { includeShadowDom: true }).should('be.hidden') + }) + + it('shows shadow span at abs(50,50) inside an overflow:hidden 200×200 box (in-bounds, below the fold)', () => { + // This was test 5 — un-skipped by the in-bounds smart-clipping change. + const $light = add( + `
+
+ +
+
`, + `in bounds`, + '#host-in-bounds-below-fold', + ) + + cy.wrap($light).find('span', { includeShadowDom: true }).should('be.visible') + }) + + it('hides shadow content positioned out-of-bounds of an overflow:hidden parent', () => { + // One of the three CI regressions fixed by walking shadow boundaries + // in hasClippingAncestor. + const $light = add( + `
+ +
`, + `clipped, you should not see me`, + '#host-out-of-bounds-below', + ) + + cy.wrap($light).find('span', { includeShadowDom: true }).should('be.hidden') + }) + + it('hides shadow content out-of-bounds of an overflow:scroll parent', () => { + // The Firefox-specific CI regression — fixed by including scroll/auto + // in CLIPPING_OVERFLOW. + const $light = add( + `
+ +
`, + `I am off-screen of an overflow:scroll container`, + '#host-overflow-scroll-out-of-bounds', + ) + + cy.wrap($light).find('span', { includeShadowDom: true }).should('be.hidden') + }) + + it('finds shadow content via the shadow-aware ancestor walk (was: el.contains)', () => { + // visibleAtPoint's contains check now uses findParent which crosses + // shadow boundaries. Without this change, fast would have reported + // the button as hidden even when document.elementFromPoint correctly + // returned the host. + const $light = add( + ``, + ``, + '#host-shadow-aware-contains', + ) + + cy.wrap($light).find('button', { includeShadowDom: true }).should('be.visible') + }) + }) + + describe('⚠️ still skipped under fast (documented divergences)', () => { + // Each of these PASSES under legacy and FAILS under fast for the + // reasons explained in the comment in visibility_shadow_dom.cy.ts. + // Skipped under fast so CI stays green. To watch fast turn red live in + // cypress open, flip `itDivergent` to `it` below for the run. + const itDivergent = mode === 'fast' ? it.skip : it + + itDivergent('test 1: shadow underneath wider than its outside cover (cover-width mismatch)', () => { + const $light = add( + `
+ +
on top
+
`, + `
underneath
`, + '#host-narrow-cover', + ) + + cy.wrap($light).find('#inside-underneath', { includeShadowDom: true }).should('be.hidden') + }) + + itDivergent('test 3: shadow span with `position: fixed` whose host parent has `pointer-events: none`', () => { + const $light = add( + `
+ +
`, + `I am rendered but elementFromPoint skips me`, + '#host-pe-none-parent', + ) + + cy.wrap($light).find('span', { includeShadowDom: true }).should('be.visible') + }) + + itDivergent('test 4: shadow span with `pointer-events: none` whose host parent is fixed', () => { + const $light = add( + `
+ +
`, + `I am rendered but elementFromPoint skips me`, + '#host-pe-none-span', + ) + + cy.wrap($light).find('span', { includeShadowDom: true }).should('be.visible') + }) + + itDivergent('test 8: clipping ancestor sits between the subject and its containing block', () => { + // outer (relative) is the abs button's containing block. The + // overflow:auto breaking-container in between would only clip the + // button per a strict descendant-clips reading; legacy's + // canClipContent uses `offsetParent` rules to ignore it. + const $light = add( + `
+
+
+
+

Example

+ +
+
+
+
`, + `
+ +
`, + '#host-clipper-between-cb', + ) + + cy.wrap($light).find('#visible-button', { includeShadowDom: true }).should('be.visible') + }) + }) + }) + } +}) From e0b1328e27693ccbcfe9697aacd6f27487ecef62 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Wed, 6 May 2026 23:22:46 -0600 Subject: [PATCH 8/9] chore(driver): skip the fast-vis playground spec under CI 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`. --- .../e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts b/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts index 3521e55ab9a..63b074dbbe3 100644 --- a/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts +++ b/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts @@ -6,11 +6,17 @@ // flip between the two and see the difference. The assertions match what // visibility_shadow_dom.cy.ts asserts; the fixtures are duplicated here so the // runner shows them in isolation rather than scrolled past in a 60+ test list. +// +// Skipped under CI (`describeIfLocal`) so this exploratory spec doesn't add +// noise to the suite — `visibility_shadow_dom.cy.ts` already provides the +// authoritative coverage. export {} // make typescript see this as a module const { $ } = Cypress +const describeIfLocal = Cypress.env('CI') ? describe.skip : describe + const buildAdd = (win: Window) => { if (!(win.customElements as any).get('playground-host')) { win.customElements.define('playground-host', class extends win.HTMLElement { @@ -31,7 +37,7 @@ const buildAdd = (win: Window) => { } } -describe('fast visibility + Shadow DOM playground', () => { +describeIfLocal('fast visibility + Shadow DOM playground', () => { let add: (lightHTML: string, shadowHTML: string, hostSelector: string) => JQuery for (const mode of ['fast', 'legacy']) { From 4373146eae945688f6d0d1bdb265cec593447c3e Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Wed, 6 May 2026 23:41:52 -0600 Subject: [PATCH 9/9] fix(driver): tighten body/html clipping exemption to non-explicit values only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` or ``. 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. --- .../driver/src/dom/visibility/fastIsHidden.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/driver/src/dom/visibility/fastIsHidden.ts b/packages/driver/src/dom/visibility/fastIsHidden.ts index 2eab4192136..500fadea1ff 100644 --- a/packages/driver/src/dom/visibility/fastIsHidden.ts +++ b/packages/driver/src/dom/visibility/fastIsHidden.ts @@ -139,6 +139,13 @@ function isOutsideViewport (el: HTMLElement, rect: DOMRect): boolean { } const CLIPPING_OVERFLOW = new Set(['hidden', 'clip', 'scroll', 'auto']) +// On the document root (``/``), only treat overflow as clipping when +// it is *explicitly* `hidden` or `clip`. `scroll` and `auto` here are usually +// the page's scroll container — and per the CSS spec, setting one axis to a +// non-`visible` value (e.g. `body { overflow-x: hidden }`) computes the other +// axis to `auto`. Treating that auto-converted value as clipping would block +// programmatic vertical scroll on every page that hides horizontal scrollbars. +const DOC_ROOT_CLIPPING_OVERFLOW = new Set(['hidden', 'clip']) // True iff some ancestor with clipping `overflow` on the same axis the subject is // off-screen has the subject *out-of-bounds* — i.e., the subject is intentionally @@ -149,7 +156,8 @@ const CLIPPING_OVERFLOW = new Set(['hidden', 'clip', 'scroll', 'auto']) // descendant's clipping ancestor often lives in the host's light tree. Treat // `scroll` and `auto` as clipping too: the user has not scrolled, so any content // outside the visible region is hidden right now and should not be surfaced -// programmatically. +// programmatically. The exception is ``/`` (see +// `DOC_ROOT_CLIPPING_OVERFLOW`). function isClippedByAncestor (el: HTMLElement, rect: DOMRect): boolean { const doc = el.ownerDocument const win = doc.defaultView @@ -162,26 +170,18 @@ function isClippedByAncestor (el: HTMLElement, rect: DOMRect): boolean { let current: HTMLElement | null = getParentNode(el) while (current) { - // Skip the document's root and body — they are the page's scroll container, - // not real clipping containers. Setting `overflow-x: hidden` on body, for - // example, makes computed `overflow-y` become `auto` per CSS, which would - // otherwise spuriously trip the clipping check on the off-screen axis and - // block legitimate vertical scrolling. - if (current === doc.body || current === doc.documentElement) { - current = getParentNode(current) - continue - } - + const isDocRoot = current === doc.body || current === doc.documentElement + const allowed = isDocRoot ? DOC_ROOT_CLIPPING_OVERFLOW : CLIPPING_OVERFLOW const { overflowX, overflowY } = win.getComputedStyle(current) const ancestorRect = current.getBoundingClientRect() - if (offscreenX && CLIPPING_OVERFLOW.has(overflowX)) { + if (offscreenX && allowed.has(overflowX)) { if (rect.left < ancestorRect.left || rect.right > ancestorRect.right) { return true } } - if (offscreenY && CLIPPING_OVERFLOW.has(overflowY)) { + if (offscreenY && allowed.has(overflowY)) { if (rect.top < ancestorRect.top || rect.bottom > ancestorRect.bottom) { return true }