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 9 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 9 commits into
microsoft:masterfrom
PaulGMardling:fix/36604-tooltip-escaped-hide-boundary

Conversation

@PaulGMardling

@PaulGMardling PaulGMardling commented Aug 21, 2026

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 #32882 fix added a boundary to the hide middleware based on hasScrollableElement directly in shared code used by every usePositioning consumer (Combobox, Menu, Popover, TagPicker, DatePicker, Tooltip, ...):

hideMiddleware({ strategy: 'referenceHidden', boundary: hasScrollableElement ? 'clippingAncestors' : [] }),
hideMiddleware({ strategy: 'escaped', boundary: hasScrollableElement ? 'clippingAncestors' : [] }),

hasScrollableElement (via hasScrollParent) only treats auto/scroll/overlay ancestors as real scroll containers, so it correctly distinguishes "real scroll parent" from "static clipping ancestor." However, once a scroll parent did exist anywhere up the tree, the boundary fell back to floating-ui's 'clippingAncestors', which walks up from the trigger through any ancestor with overflow: hidden/scroll/auto — including tightly-fitted, non-scrolling overflow: hidden wrappers that happen to sit between the trigger and that scroll parent.

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

Per review feedback, this fix intentionally avoids changing shared hide middleware behavior for all usePositioning consumers (too large a blast radius for Combobox/Menu/Popover/TagPicker/DatePicker). Instead:

  • Added a new public hideBoundary?: PositioningBoundary | null option to react-positioning (PositioningOptions / PositioningProps), letting the boundary used for referenceHidden/escaped detection be configured independently of overflowBoundary/flipBoundary.
  • hideBoundary is resolved via the existing getBoundary() helper, which already has first-class 'scrollParent' handling: it walks up to the nearest genuine scroll parent (falling back to the document element if none exists), rather than floating-ui's default 'clippingAncestors' (which clips against any overflow ancestor, scrolling or not).
  • Tooltip now defaults hideBoundary to 'scrollParent', opting in to this corrected behavior. It's the only consumer affected — every other usePositioning consumer keeps its original, unmodified behavior.
  • Consumers can still override hideBoundary explicitly (e.g. Tooltip's own positioning prop passthrough).
  • Fixed a wiring bug found during implementation: usePositioningConfigFn forwards a fixed allow-list of option fields into the positioning config pipeline, and hideBoundary was initially missing from that list — silently dropping the option before it ever reached the hide middleware, regardless of what was set. This is now included.
  • Removed the custom hide middleware wrapper (middleware/hide.ts + its test) that an earlier iteration introduced; the fix now calls floating-ui's hide middleware directly with a single, reused hideBoundaryOptions object, avoiding a duplicated nested-ternary boundary computation.
  • Added a StaticOverflowHidden Storybook story as a visual example, alongside the existing OverflowHidden scroll-regression story.

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.022 kB
127.218 kB
130 B
39 B
react-charts
DeclarativeChart
758.436 kB
222.685 kB
758.566 kB
222.726 kB
130 B
41 B
react-charts
DonutChart
317.723 kB
97.974 kB
317.853 kB
98.013 kB
130 B
39 B
react-charts
FunnelChart
309.123 kB
94.773 kB
309.253 kB
94.82 kB
130 B
47 B
react-charts
GanttChart
390.022 kB
121.66 kB
390.152 kB
121.706 kB
130 B
46 B
react-charts
GaugeChart
317.073 kB
97.399 kB
317.203 kB
97.439 kB
130 B
40 B
react-charts
GroupedVerticalBarChart
398.105 kB
124.45 kB
398.235 kB
124.493 kB
130 B
43 B
react-charts
HeatMapChart
392.162 kB
122.699 kB
392.292 kB
122.741 kB
130 B
42 B
react-charts
HorizontalBarChart
297.399 kB
90.469 kB
297.529 kB
90.512 kB
130 B
43 B
react-charts
Legends
236.586 kB
71.051 kB
236.688 kB
71.091 kB
102 B
40 B
react-charts
LineChart
418.241 kB
130.311 kB
418.371 kB
130.355 kB
130 B
44 B
react-charts
PolarChart
345.578 kB
108.002 kB
345.68 kB
108.031 kB
102 B
29 B
react-charts
SankeyChart
213.787 kB
68.717 kB
213.917 kB
68.768 kB
130 B
51 B
react-charts
ScatterChart
397.565 kB
124.366 kB
397.695 kB
124.411 kB
130 B
45 B
react-charts
VerticalBarChart
434.556 kB
129.336 kB
434.686 kB
129.387 kB
130 B
51 B
react-charts
VerticalStackedBarChart
404.12 kB
125.842 kB
404.25 kB
125.885 kB
130 B
43 B
react-combobox
Combobox (including child components)
139.308 kB
45.062 kB
139.41 kB
45.118 kB
102 B
56 B
react-combobox
Dropdown (including child components)
139.073 kB
44.814 kB
139.175 kB
44.872 kB
102 B
58 B
react-components
react-components: all base hooks
217.385 kB
68.173 kB
217.515 kB
68.218 kB
130 B
45 B
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
227.126 kB
68.407 kB
227.228 kB
68.451 kB
102 B
44 B
react-components
react-components: entire library
1.283 MB
322.107 kB
1.283 MB
322.165 kB
130 B
58 B
react-datepicker-compat
DatePicker Compat
217.437 kB
64.403 kB
217.539 kB
64.436 kB
102 B
33 B
react-menu
Menu (including children components)
160.778 kB
51.107 kB
160.88 kB
51.141 kB
102 B
34 B
react-menu
Menu (including selectable components)
163.944 kB
51.736 kB
164.046 kB
51.77 kB
102 B
34 B
react-popover
Popover
126.59 kB
40.869 kB
126.692 kB
40.905 kB
102 B
36 B
react-positioning
usePositioning
28.985 kB
10.198 kB
29.087 kB
10.233 kB
102 B
35 B
react-tag-picker
@fluentui/react-tag-picker - package
174.753 kB
54.556 kB
174.855 kB
54.595 kB
102 B
39 B
react-teaching-popover
TeachingPopover
101.77 kB
32.204 kB
101.872 kB
32.239 kB
102 B
35 B
react-timepicker-compat
TimePicker
142.027 kB
46.435 kB
142.129 kB
46.493 kB
102 B
58 B
react-tooltip
Tooltip
54.69 kB
19.443 kB
54.82 kB
19.481 kB
130 B
38 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 c1f8330299439d342c6aa5a5d2b6e4db08aa5451

…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>
@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 thread packages/react-components/react-positioning/library/src/usePositioningOptions.ts Outdated
Comment thread packages/react-components/react-positioning/library/src/middleware/hide.ts Outdated
PaulGMardling and others added 4 commits August 24, 2026 11:49
Adds a public hideBoundary positioning option (react-positioning) so
consumers can control the boundary used for referenceHidden/escaped
detection. Tooltip now defaults hideBoundary to 'scrollParent', fixing
a regression where tooltips inside a tightly-fitted, non-scrolling
overflow:hidden container were incorrectly hidden, while preserving
the existing scroll-based hide behavior (microsoft#32882).

Also fixes hideBoundary not being forwarded through
usePositioningConfigFn, which silently dropped the option before it
ever reached the hide middleware, and simplifies boundary resolution
to reuse getBoundary's existing 'scrollParent' handling instead of a
duplicated nested-ternary implementation.

Fixes microsoft#36604

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PaulGMardling

Copy link
Copy Markdown
Contributor Author

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

Fixed

Screen.Recording.2026-08-24.at.12.40.51.mov
Screen.Recording.2026-08-24.at.12.42.15.mov

…oll parent

Adds a Cypress regression case verifying a non-scrolling
overflow:hidden wrapper nested inside a real scroll parent doesn't
interfere with either the microsoft#36604 static-container fix or the microsoft#32882
scroll-hide behavior, closing a gap where only the two top-level
scenarios were covered individually.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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