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 aa2d2d23af..08a9ca8854 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,25 @@ 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 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(() => { cy.visit('/fixtures/empty.html').then((win) => { win.customElements.define('shadow-root', class extends win.HTMLElement { @@ -233,7 +245,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 +259,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 +273,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 +300,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( `
@@ -597,7 +609,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 62059003f1..2eab419213 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, getParentNode } 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 { @@ -58,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) { @@ -125,31 +138,56 @@ function isOutsideViewport (el: HTMLElement, rect: DOMRect): boolean { ) } -function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean { - const win = el.ownerDocument.defaultView +const CLIPPING_OVERFLOW = new Set(['hidden', 'clip', 'scroll', 'auto']) + +// 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 doc = el.ownerDocument + const win = doc.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. const offscreenX = rect.right <= 0 || rect.left >= win.innerWidth const offscreenY = rect.bottom <= 0 || rect.top >= win.innerHeight - let current: HTMLElement | null = el.parentElement + 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() - if (offscreenX && (overflowX === 'hidden' || overflowX === 'clip')) { - return true + if (offscreenX && CLIPPING_OVERFLOW.has(overflowX)) { + if (rect.left < ancestorRect.left || rect.right > ancestorRect.right) { + return true + } } - if (offscreenY && (overflowY === 'hidden' || overflowY === 'clip')) { - return true + if (offscreenY && CLIPPING_OVERFLOW.has(overflowY)) { + if (rect.top < ancestorRect.top || rect.bottom > ancestorRect.bottom) { + return true + } } - current = current.parentElement + current = getParentNode(current) } return false