fix(react-positioning): don't treat static overflow:hidden ancestors as a hide-middleware clipping boundary - #36605
Conversation
…as a hide-middleware clipping boundary The Tooltip trigger-scroll-out-of-view fix (microsoft#36386, fixing microsoft#32882) added `hide` middleware with no boundary override, which defaults to floating-ui's `clippingAncestors`. That walks up to ANY overflow ancestor, including non-scrolling `overflow: hidden` containers that merely clip statically (e.g. a tightly-fitted flex toolbar), not just genuine scroll containers. This meant a Tooltip trigger placed inside such a container could have its tooltip permanently hidden, even at rest with no scrolling involved, since `escaped`/`referenceHidden` would compute true on the very first render. Fix: introduce a `hide` middleware wrapper (mirroring the existing `shift`/`flip` wrappers) that only uses `clippingAncestors` as the boundary when there is a genuinely scrollable ancestor (`hasScrollParent`, which intentionally excludes `overflow: hidden`). Otherwise it scopes detection to the viewport only, so static clipping containers no longer suppress the tooltip. Fixes microsoft#36604 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
cc charles izzi (@charles-izzi) — this should fix the regression you found in #36604. Thanks again for the clean repro! |
📊 Bundle size reportUnchanged fixtures
|
…cument floating-ui dependency Adds a unit test for the `hide` middleware wrapper introduced for microsoft#36604, asserting it maps `hasScrollableElement` to the correct `boundary` option passed to `@floating-ui/dom`'s `hide` middleware. The fix relies on an implicit, lightly-documented floating-ui behavior (passing `boundary: []` skips intermediate DOM clipping ancestors and falls back to the viewport). That deeper real-browser geometry contract is already covered by the Cypress tests in Tooltip.cy.tsx (both the pre-existing scroll regression test for microsoft#32882 and the new static overflow:hidden test for microsoft#36604); jsdom's layout emulation isn't accurate enough to reliably pin it in a unit test here, so this test and the accompanying comments make that dependency and its test coverage explicit for future maintainers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Pull request demo site: URL |
…le-export files The CSF/babel story loader does not support multiple exports from a single non-index story file, causing test-ssr to fail with "Multiple exports from a single file are not supported". Split StaticOverflowHidden into its own file, matching the convention used by all other stories in this folder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| @@ -0,0 +1,7 @@ | |||
| { | |||
There was a problem hiding this comment.
🕵🏾♀️ visual changes to review in the Visual Change Report
vr-tests-react-components/Avatar Converged 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Avatar Converged.badgeMask.normal.chromium.png | 5 | Changed |
vr-tests-react-components/Menu 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Menu.Nested Submenus Small Viewport Flipped.nested menu.chromium.png | 644 | Changed |
vr-tests-react-components/Menu Converged - submenuIndicator slotted content 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png | 413 | Changed |
vr-tests-react-components/Positioning 4 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Positioning.visibility modifiers.has [data-popper-reference-hidden] when the reference is hidden.chromium.png | 4047 | Changed |
| vr-tests-react-components/Positioning.Positioning end.chromium.png | 620 | Changed |
| vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png | 499 | Changed |
| vr-tests-react-components/Positioning.visibility modifiers.has [data-popper-escaped] when the popper escapes the reference element's boundary.chromium.png | 1715 | Changed |
vr-tests-react-components/ProgressBar converged 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png | 34 | Changed |
vr-tests-react-components/Skeleton converged 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Skeleton converged.Opaque Skeleton with rectangle - Dark Mode.default.chromium.png | 20 | Changed |
vr-tests-react-components/TagPicker 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/TagPicker.disabled - Dark Mode.chromium.png | 658 | Changed |
There were 1 duplicate changes discarded. Check the build logs for more information.
|
|
||
| describe('static non-scrolling overflow:hidden container (regression: #36604)', () => { |
There was a problem hiding this comment.
Let's not mention issue number in the describe, remove it or add as a comment before
| describe('static non-scrolling overflow:hidden container (regression: #36604)', () => { | |
| // Verifies the fix for regression reported in: https://github.com/microsoft/fluentui/issues/36604 | |
| describe('static non-scrolling overflow:hidden container', () => { |
| hideMiddleware({ strategy: 'referenceHidden', hasScrollableElement }), | ||
| hideMiddleware({ strategy: 'escaped', hasScrollableElement }), |
There was a problem hiding this comment.
q: do we really need a custom wrapper around floating-ui hide middleware? won't be better to inline that instead of introducing additional wrapper?
hideMiddleware({ strategy: 'escaped', boundary: hasScrollableElement ? 'clippingAncestors' : [] }),| import type { Middleware } from '@floating-ui/dom'; | ||
| import { hide as baseHide } from '@floating-ui/dom'; | ||
|
|
||
| export interface HideMiddlewareOptions { |
There was a problem hiding this comment.
I think you can import and extend the type, instead of introducing a a completely new one
import type { HideOptions } from '@floating-ui/dom';| * that it maps `hasScrollableElement` to the correct `boundary` option passed to the underlying | ||
| * `@floating-ui/dom` `hide` middleware. | ||
| */ | ||
| describe('hide', () => { |
There was a problem hiding this comment.
I doubt this test is useful and can be omitted, related to this comment: https://github.com/microsoft/fluentui/pull/36605/changes#r3836947174
| intersectingMiddleware(), | ||
| arrow && arrowMiddleware({ element: arrow, padding: arrowPadding }), | ||
| hideMiddleware({ strategy: 'referenceHidden' }), | ||
| hideMiddleware({ strategy: 'escaped' }), |
There was a problem hiding this comment.
I feel it can be risky: that applies to every consumer of usePositioning - react-combobox,
react-datepicker-compat, react-menu, react-popover, react-tag-picker, react-tooltip, so quite large blast radius. escaped/referenceHidden go straight out as data-popper-escaped / data-popper-reference-hidden
Wdyt about adding it via a new react-positioning option hideBoundary and enabling it for Tooltip
only?
const positioningOptions = {
enabled: state.visible,
arrowPadding: 2 * tooltipBorderRadius,
position: 'above' as const,
align: 'center' as const,
offset: 4,
hideBoundary: 'scrollParent' as const,
...resolvedPositioning,
onPositioningEnd,
};
Previous Behavior
Following #36386 (which fixed #32882 — tooltip appearing outside its overflow container when the trigger scrolled out of view), a Tooltip trigger placed inside a tightly-fitted, non-scrolling
overflow: hiddencontainer would never show its tooltip at all — even at rest, with no scrolling involved.Repro:
Root Cause
In
react-positioning'susePositioningOptions.ts, thehidemiddleware was added with noboundaryoverride:This falls back to floating-ui's default boundary of
'clippingAncestors', which walks up from the trigger to any ancestor withoverflow: hidden/scroll/auto— not just genuine scroll containers. Unlikehide, the existingshift/flipmiddleware wrappers are scoped usinghasScrollParent, which intentionally only treatsauto/scroll/overlayancestors as real scroll containers (excludingoverflow: hidden).In the repro above, the wrapping
<div style={{ overflow: 'hidden', display: 'flex' }}>hugs the button tightly with no extra space above it. Since Tooltip defaults toposition="above", the computed tooltip position extends past that div's clip box on the very first render — no scrolling required.escapedbecomestrueimmediately and stays that way, so the tooltip never shows.Any trigger inside a tightly-fitted
overflow: hiddencontainer — flex toolbars, truncated table/list cells, card layouts, etc. — silently lost its tooltip permanently, regardless of scroll position.New Behavior
hidemiddleware wrapper inreact-positioning(mirroring the existingshift/flipwrappers) that acceptshasScrollableElement.boundary: 'clippingAncestors') — preserves the original fix for [Bug]: tooltip appears outside of overflow #32882.boundary: []is used instead, so escape/hidden detection is scoped to the viewport only and static, non-scrolling clipping ancestors no longer suppress the tooltip.usePositioningOptions.tsnow passes the already-computedhasScrollableElement(viahasScrollParent) into bothhidemiddleware calls.overflow: hiddencase, alongside the existing scroll-based regression test (both now pass).StaticOverflowHiddenStorybook story as a visual regression example.Related Issue(s)
Verification
react-positioning:build/react-tooltip:build— passreact-positioning:test(199 tests) /react-tooltip:test(23 tests) — all passTooltip.cy.tsx— both the pre-existing scroll-regression test ([Bug]: tooltip appears outside of overflow #32882) and the new staticoverflow:hiddentest ([Bug]: Tooltip permanently hidden inside tightly-fitted overflow:hidden containers (regression in 9.10.4+) #36604) pass