Skip to content

fix(react-positioning): don't treat static overflow:hidden ancestors as a hide-middleware clipping boundary - #36605

Open
PaulGMardling wants to merge 4 commits into
microsoft:masterfrom
PaulGMardling:fix/36604-tooltip-escaped-hide-boundary
Open

fix(react-positioning): don't treat static overflow:hidden ancestors as a hide-middleware clipping boundary#36605
PaulGMardling wants to merge 4 commits into
microsoft:masterfrom
PaulGMardling:fix/36604-tooltip-escaped-hide-boundary

Conversation

@PaulGMardling

Copy link
Copy Markdown
Contributor

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: hidden container would never show its tooltip at all — even at rest, with no scrolling involved.

Repro:

<div style={{ overflow: 'hidden', display: 'flex' }}>
  <Tooltip content="I never appear" relationship="label">
    <Button>Hover me</Button>
  </Tooltip>
</div>

Root Cause

In react-positioning's usePositioningOptions.ts, the hide middleware was added with no boundary override:

hideMiddleware({ strategy: 'referenceHidden' }),
hideMiddleware({ strategy: 'escaped' }),

This falls back to floating-ui's default boundary of 'clippingAncestors', which walks up from the trigger to any ancestor with overflow: hidden/scroll/auto — not just genuine scroll containers. Unlike hide, the existing shift/flip middleware wrappers are scoped using hasScrollParent, which intentionally only treats auto/scroll/overlay ancestors as real scroll containers (excluding overflow: 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 to position="above", the computed tooltip position extends past that div's clip box on the very first render — no scrolling required. escaped becomes true immediately and stays that way, so the tooltip never shows.

Any trigger inside a tightly-fitted overflow: hidden container — flex toolbars, truncated table/list cells, card layouts, etc. — silently lost its tooltip permanently, regardless of scroll position.

New Behavior

  • Added a hide middleware wrapper in react-positioning (mirroring the existing shift/flip wrappers) that accepts hasScrollableElement.
  • When there's a genuinely scrollable ancestor, behavior is unchanged (boundary: 'clippingAncestors') — preserves the original fix for [Bug]: tooltip appears outside of overflow #32882.
  • When there's no scrollable ancestor, 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.ts now passes the already-computed hasScrollableElement (via hasScrollParent) into both hide middleware calls.
  • Added a Cypress test reproducing the static overflow: hidden case, alongside the existing scroll-based regression test (both now pass).
  • Added a StaticOverflowHidden Storybook story as a visual regression example.

Related Issue(s)

Verification

…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>
@PaulGMardling

Copy link
Copy Markdown
Contributor Author

cc charles izzi (@charles-izzi) — this should fix the regression you found in #36604. Thanks again for the clean repro!

@PaulGMardling
PaulGMardling marked this pull request as draft August 21, 2026 09:11
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-charts
AreaChart
406.892 kB
127.179 kB
407.054 kB
127.21 kB
162 B
31 B
react-charts
DeclarativeChart
758.436 kB
222.685 kB
758.598 kB
222.725 kB
162 B
40 B
react-charts
DonutChart
317.723 kB
97.974 kB
317.885 kB
98.008 kB
162 B
34 B
react-charts
FunnelChart
309.123 kB
94.773 kB
309.285 kB
94.811 kB
162 B
38 B
react-charts
GanttChart
390.022 kB
121.66 kB
390.184 kB
121.698 kB
162 B
38 B
react-charts
GaugeChart
317.073 kB
97.399 kB
317.235 kB
97.429 kB
162 B
30 B
react-charts
GroupedVerticalBarChart
398.105 kB
124.45 kB
398.267 kB
124.489 kB
162 B
39 B
react-charts
HeatMapChart
392.162 kB
122.699 kB
392.324 kB
122.739 kB
162 B
40 B
react-charts
HorizontalBarChart
297.399 kB
90.469 kB
297.561 kB
90.507 kB
162 B
38 B
react-charts
Legends
236.586 kB
71.051 kB
236.748 kB
71.09 kB
162 B
39 B
react-charts
LineChart
418.241 kB
130.311 kB
418.403 kB
130.33 kB
162 B
19 B
react-charts
PolarChart
345.578 kB
108.002 kB
345.74 kB
108.031 kB
162 B
29 B
react-charts
SankeyChart
213.787 kB
68.717 kB
213.949 kB
68.738 kB
162 B
21 B
react-charts
ScatterChart
397.565 kB
124.366 kB
397.727 kB
124.397 kB
162 B
31 B
react-charts
VerticalBarChart
434.556 kB
129.336 kB
434.718 kB
129.375 kB
162 B
39 B
react-charts
VerticalStackedBarChart
404.12 kB
125.842 kB
404.282 kB
125.869 kB
162 B
27 B
react-combobox
Combobox (including child components)
139.308 kB
45.062 kB
139.47 kB
45.112 kB
162 B
50 B
react-combobox
Dropdown (including child components)
139.073 kB
44.814 kB
139.235 kB
44.875 kB
162 B
61 B
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
227.126 kB
68.407 kB
227.288 kB
68.451 kB
162 B
44 B
react-components
react-components: entire library
1.283 MB
322.107 kB
1.283 MB
322.126 kB
162 B
19 B
react-datepicker-compat
DatePicker Compat
217.437 kB
64.403 kB
217.599 kB
64.438 kB
162 B
35 B
react-menu
Menu (including children components)
160.778 kB
51.107 kB
160.94 kB
51.139 kB
162 B
32 B
react-menu
Menu (including selectable components)
163.944 kB
51.736 kB
164.106 kB
51.771 kB
162 B
35 B
react-popover
Popover
126.59 kB
40.869 kB
126.752 kB
40.906 kB
162 B
37 B
react-positioning
usePositioning
28.985 kB
10.198 kB
29.147 kB
10.231 kB
162 B
33 B
react-tag-picker
@fluentui/react-tag-picker - package
174.753 kB
54.556 kB
174.915 kB
54.588 kB
162 B
32 B
react-teaching-popover
TeachingPopover
101.77 kB
32.204 kB
101.932 kB
32.237 kB
162 B
33 B
react-timepicker-compat
TimePicker
142.027 kB
46.435 kB
142.189 kB
46.488 kB
162 B
53 B
react-tooltip
Tooltip
54.69 kB
19.443 kB
54.852 kB
19.473 kB
162 B
30 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-avatar
Avatar
48.382 kB
15.299 kB
react-avatar
AvatarGroup
17.259 kB
6.885 kB
react-avatar
AvatarGroupItem
61.366 kB
19.157 kB
react-breadcrumb
@fluentui/react-breadcrumb - package
103.659 kB
29.076 kB
react-charts
HorizontalBarChartWithAxis
63 B
83 B
react-charts
Sparkline
80.888 kB
26.864 kB
react-checkbox
Checkbox
30.771 kB
10.912 kB
react-components
react-components: Button, FluentProvider & webLightTheme
67.461 kB
19.461 kB
react-components
react-components: FluentProvider & webLightTheme
40.684 kB
13.549 kB
react-dialog
Dialog (including children components)
90.974 kB
28.25 kB
react-field
Field
22.301 kB
8.308 kB
react-headless-components-preview
react-headless-components-preview: entire library
238.059 kB
67.025 kB
react-headless-components-preview
@fluentui/react-headless-components-preview/tag-picker
53.982 kB
17.74 kB
react-headless-components-preview
@fluentui/react-headless-components-preview/teaching-popover
36.041 kB
11.992 kB
react-input
Input
26.23 kB
8.639 kB
react-list
List
75.535 kB
23.403 kB
react-list
ListItem
99.05 kB
30.27 kB
react-overflow
hooks only
10.203 kB
3.694 kB
react-persona
Persona
55.335 kB
17.235 kB
react-portal-compat
PortalCompatProvider
5.341 kB
2.146 kB
react-positioning
useSafeZoneArea
13.398 kB
5.365 kB
react-progress
ProgressBar
20.207 kB
7.832 kB
react-radio
Radio
28.156 kB
9.153 kB
react-radio
RadioGroup
13.921 kB
5.609 kB
react-select
Select
26.099 kB
9.399 kB
react-slider
Slider
33.395 kB
11.506 kB
react-spinbutton
SpinButton
33.651 kB
11.009 kB
react-swatch-picker
@fluentui/react-swatch-picker - package
93.176 kB
27.605 kB
react-switch
Switch
33.392 kB
10.562 kB
react-table
DataGrid
147.799 kB
43.973 kB
react-table
Table (Primitives only)
38.032 kB
12.685 kB
react-table
Table as DataGrid
119.67 kB
33.75 kB
react-table
Table (Selection only)
66.547 kB
18.953 kB
react-table
Table (Sort only)
65.21 kB
18.575 kB
react-tags
InteractionTag
13.619 kB
5.381 kB
react-tags
Tag
29.532 kB
9.334 kB
react-tags
TagGroup
70.796 kB
21.806 kB
react-textarea
Textarea
24.596 kB
8.908 kB
react-tree
FlatTree
136.683 kB
40.816 kB
react-tree
PersonaFlatTree
138.546 kB
41.356 kB
react-tree
PersonaTree
134.627 kB
40.138 kB
react-tree
Tree
132.77 kB
39.634 kB
🤖 This report was generated against b5ec47fc035849b21b35d6f6054d60c0a64ff3db

…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>
@github-actions

Copy link
Copy Markdown

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 @@
{

@github-actions github-actions Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🕵🏾‍♀️ 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.

@PaulGMardling
PaulGMardling marked this pull request as ready for review August 21, 2026 13:31
@PaulGMardling PaulGMardling self-assigned this Aug 21, 2026

@mainframev Victor Genaev (mainframev) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

silently reverts the previous fix (I assume because of the boundary: [] the trigger scrolled out of a scroll container is no longer detected), visible in currently deployed SB:

Screen.Recording.2026-08-22.at.22.16.07.mov

Comment on lines +47 to +48

describe('static non-scrolling overflow:hidden container (regression: #36604)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's not mention issue number in the describe, remove it or add as a comment before

Suggested change
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', () => {

Comment on lines +174 to +175
hideMiddleware({ strategy: 'referenceHidden', hasScrollableElement }),
hideMiddleware({ strategy: 'escaped', hasScrollableElement }),

@mainframev Victor Genaev (mainframev) Aug 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PaulGMardling

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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', () => {

@mainframev Victor Genaev (mainframev) Aug 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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' }),

@mainframev Victor Genaev (mainframev) Aug 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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,
};

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Tooltip permanently hidden inside tightly-fitted overflow:hidden containers (regression in 9.10.4+) [Bug]: tooltip appears outside of overflow

2 participants