Skip to content

fix(react-overflow,priority-overflow): correct overflow snapshot on first paint#36264

Draft
bsunderhus wants to merge 11 commits into
microsoft:masterfrom
bsunderhus:react-overflow/pr3-first-paint
Draft

fix(react-overflow,priority-overflow): correct overflow snapshot on first paint#36264
bsunderhus wants to merge 11 commits into
microsoft:masterfrom
bsunderhus:react-overflow/pr3-first-paint

Conversation

@bsunderhus
Copy link
Copy Markdown
Contributor

@bsunderhus bsunderhus commented May 28, 2026

Summary

PR 3 of 3. Depends on PR 1 (#36262, react-overflow/pr1-strict-mode-lifecycle) and PR 2 (#36263, react-overflow/pr2-subscribe-model). Do not merge until PR 2 lands — until then the "Files changed" view shows cumulative diffs.

Fixes a one-frame flash where overflow items rendered without accounting for the overflow menu, by forcing a synchronous overflow computation at the right lifecycle moments.

Stack

  1. react-overflow/pr1-strict-mode-lifecycle (refactor(react-overflow,priority-overflow): pure manager + strict-mode-safe lifecycle #36262) — strict-mode-safe lifecycle.
  2. react-overflow/pr2-subscribe-model (refactor(react-overflow,priority-overflow): subscribe model removes intermediate state from <Overflow> #36263) — subscribe model.
  3. This PR — first-paint correctness.

priority-overflow

  • addOverflowMenu and removeOverflowMenu schedule a forced dispatch when observing, so attaching the menu element does not leave the snapshot stale.

react-overflow

  • The observe layout effect in useOverflowContainer calls manager.forceUpdate right after observation starts, before React commits the next paint.
  • useOverflowContainer returns a new forceUpdateOverflow, plumbed through OverflowContextValue.
  • useOverflowMenu calls forceUpdateOverflow immediately after the menu element is registered (and again on unregister), collapsing what used to be two separate layout effects.
  • New Overflow.paint-probe.cy.tsx captures the DOM at the layout effect / passive effect / first requestAnimationFrame boundaries and asserts the overflow state is already final at the first paint.

Test plan

  • yarn nx run priority-overflow:test / react-overflow:test
  • yarn nx run priority-overflow:lint / react-overflow:lint
  • yarn nx run react-overflow:type-check
  • CI green (incl. the new Cypress paint-probe spec)
  • After PR 2 merges: mark this PR ready for review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 28, 2026

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
priority-overflow
createOverflowManager
4.988 kB
1.998 kB
5.237 kB
2.075 kB
249 B
77 B
react-charts
AreaChart
402.425 kB
125.477 kB
403.501 kB
125.729 kB
1.076 kB
252 B
react-charts
DeclarativeChart
753.371 kB
219.875 kB
754.447 kB
220.062 kB
1.076 kB
187 B
react-charts
DonutChart
312.833 kB
96.255 kB
313.909 kB
96.464 kB
1.076 kB
209 B
react-charts
FunnelChart
304.387 kB
93.104 kB
305.463 kB
93.297 kB
1.076 kB
193 B
react-charts
GanttChart
385.514 kB
119.91 kB
386.597 kB
120.128 kB
1.083 kB
218 B
react-charts
GaugeChart
312.266 kB
95.651 kB
313.342 kB
95.862 kB
1.076 kB
211 B
react-charts
GroupedVerticalBarChart
393.384 kB
122.619 kB
394.466 kB
122.836 kB
1.082 kB
217 B
react-charts
HeatMapChart
387.601 kB
120.863 kB
388.677 kB
121.117 kB
1.076 kB
254 B
react-charts
HorizontalBarChart
292.56 kB
88.697 kB
293.636 kB
88.929 kB
1.076 kB
232 B
react-charts
Legends
232.229 kB
69.577 kB
233.312 kB
69.839 kB
1.083 kB
262 B
react-charts
LineChart
413.743 kB
128.465 kB
414.825 kB
128.704 kB
1.082 kB
239 B
react-charts
PolarChart
341.227 kB
106.142 kB
342.309 kB
106.384 kB
1.082 kB
242 B
react-charts
ScatterChart
393.126 kB
122.588 kB
394.208 kB
122.824 kB
1.082 kB
236 B
react-charts
VerticalBarChart
429.87 kB
127.494 kB
430.946 kB
127.718 kB
1.076 kB
224 B
react-charts
VerticalStackedBarChart
399.588 kB
124.051 kB
400.67 kB
124.299 kB
1.082 kB
248 B
react-components
react-components: entire library
1.294 MB
324.918 kB
1.295 MB
325.183 kB
1.149 kB
265 B
react-overflow
hooks only
12.519 kB
4.642 kB
9.103 kB
3.161 kB
-3.416 kB
-1.481 kB
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-breadcrumb
@fluentui/react-breadcrumb - package
102.926 kB
28.76 kB
react-charts
HorizontalBarChartWithAxis
63 B
83 B
react-charts
SankeyChart
211.914 kB
67.836 kB
react-charts
Sparkline
80.503 kB
26.644 kB
react-components
react-components: Button, FluentProvider & webLightTheme
66.328 kB
19.02 kB
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
226.19 kB
67.909 kB
react-components
react-components: FluentProvider & webLightTheme
39.525 kB
13.113 kB
react-headless-components-preview
react-headless-components-preview: entire library
198.183 kB
56.549 kB
react-portal-compat
PortalCompatProvider
5.567 kB
2.237 kB
react-timepicker-compat
TimePicker
104.049 kB
34.748 kB
🤖 This report was generated against 5b60f2059ee6acf41e71ab11a30c0ecd9bb1a6cb

@github-actions
Copy link
Copy Markdown

Pull request demo site: URL

@@ -0,0 +1,7 @@
{
Copy link
Copy Markdown

@github-actions github-actions Bot May 28, 2026

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/CalendarCompat 4 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/CalendarCompat.multiDayView - Dark Mode.default.chromium.png 1094 Changed
vr-tests-react-components/CalendarCompat.multiDayView - RTL.default.chromium.png 480 Changed
vr-tests-react-components/CalendarCompat.multiDayView.default.chromium_1.png 478 Changed
vr-tests-react-components/CalendarCompat.multiDayView - High Contrast.default.chromium.png 1192 Changed
vr-tests-react-components/Charts-DonutChart 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic - Dark Mode.default.chromium.png 826951 Changed
vr-tests-react-components/Charts-DonutChart.Dynamic - RTL.default.chromium.png 782688 Changed
vr-tests-react-components/Charts-DonutChart.Dynamic.default.chromium.png 782678 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 623 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 734 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 65 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 78 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 37 Changed
vr-tests-react-components/TagPicker 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - High Contrast.chromium.png 1319 Changed

There were 2 duplicate changes discarded. Check the build logs for more information.

@bsunderhus bsunderhus force-pushed the react-overflow/pr3-first-paint branch 4 times, most recently from df518d2 to 651da97 Compare May 28, 2026 17:46
bsunderhus and others added 5 commits May 29, 2026 12:57
…ntermediate state from Overflow container

The Overflow container previously held the overflow snapshot in component state
and re-rendered every consumer through context selector when it changed. This
forced consumers to opt out via memoization and made first-paint sequencing
fragile. Move the snapshot into the manager and let each hook subscribe
directly.

priority-overflow:
- the manager caches the latest snapshot and exposes getSnapshot/subscribe
- dispatchOverflowUpdate goes through takeSnapshot, which fans out to listeners
- the observe cleanup resets the snapshot so subscribers see an empty state

react-overflow:
- OverflowContextValue exposes getSnapshot/subscribe directly from the manager
- the context drops react-context-selector and uses plain React.createContext;
  useOverflowContext keeps the selector overload for backward compatibility
- the existing itemVisibility, groupVisibility and hasOverflow fields on the
  context are kept but marked deprecated — they are now always empty
- new useOverflowSnapshot hook subscribes via useState + useIsomorphicLayoutEffect
- useOverflowCount, useIsOverflowItemVisible, useIsOverflowGroupVisible and
  useOverflowVisibility are rewritten on top of useOverflowSnapshot
- Overflow.tsx no longer keeps a useState; onOverflowChange callers receive the
  same OnOverflowChangeData shape derived from the snapshot
- drop the @fluentui/react-context-selector dependency from the package

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- overflowContext.ts: replace the inline `() => () => null` / `() => null`
  default no-ops on the context value with references to a local `noop` const
  (block-form body).
- useOverflowContainer.ts: `defaultSubscribe` now returns the same shared
  `noop` rather than re-allocating a fresh placeholder per call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pshot before the first paint

Mounting the Overflow container previously produced a one-frame flash where
items rendered without accounting for the overflow menu. Force a synchronous
overflow computation at the right moments so the first painted frame already
reflects the final visible/invisible split.

priority-overflow:
- addOverflowMenu and removeOverflowMenu now schedule a forced dispatch when
  observing, so attaching the menu element does not leave the snapshot stale

react-overflow:
- the observe layout effect in useOverflowContainer calls manager.forceUpdate
  right after observation starts, before React commits the next paint
- useOverflowContainer also returns forceUpdateOverflow, which is plumbed
  through OverflowContextValue
- useOverflowMenu calls forceUpdateOverflow immediately after the menu element
  is registered, collapsing what used to be two separate layout effects
- add an Overflow.paint-probe.cy.tsx test that captures the DOM at the layout
  effect / passive effect / raf boundaries and asserts the overflow state is
  already final at the first paint

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bsunderhus bsunderhus force-pushed the react-overflow/pr3-first-paint branch from 651da97 to 08b2439 Compare May 29, 2026 11:05
bsunderhus and others added 6 commits May 29, 2026 16:02
…ntainer

The synchronous first-paint forceUpdate (in useOverflowContainer's observe
layout effect) must only run once the container has actually been laid out.
Before layout (SSR / jsdom / display:none) sizes read as 0, which the manager
treats as negative available space and collapses every item — a degenerate
first paint and the source of the react-charts snapshot/a11y fallout.

Gate that one call on the container being measured. The guard intentionally
lives at the call site rather than in the manager: forceUpdate is shared by
addItem / addOverflowMenu / setOptions / the ResizeObserver, all pre-existing
paths whose behavior (and downstream snapshots) must not change. Only the
first-paint call is new, so only it is conditioned here. Moving the guard into
the manager is left as a follow-up.

Adds Overflow.firstPaint.test.tsx (localized red→green coverage) and splits the
container force test into measured / unmeasured cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a deterministic jsdom regression test using the real Overflow,
OverflowItem and useOverflowMenu. An unconditionally mounted menu (as in
the breadcrumb composition) attaches its ref on the first commit, so the
menu's child layout effect runs forceUpdateOverflow() before the parent
container's onUpdateOverflow useEventCallback assignment effect commits.
The manager then dispatches through the not-yet-assigned callback, which
throws "Cannot call an event handler while rendering" - the same failure
that hangs the breadcrumb SSR hydration test.

The test fails on the current branch by design: it documents the
unfixed first-paint regression and will pass once the hook no longer
dispatches synchronously before the container callback is ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The overflow update callback was wrapped in useEventCallback, whose real
implementation is assigned in a layout effect. React commits layout
effects child-first, so an unconditionally mounted overflow menu (e.g.
breadcrumb) ran its forceUpdateOverflow() on the first commit, before the
parent container's useEventCallback assignment effect. The manager then
dispatched through the not-yet-assigned callback, whose default ref threw
"Cannot call an event handler while rendering", aborting hydration and
hanging the breadcrumb SSR test.

Pass the (now useCallback-stable) update directly as onUpdateOverflow so
it is a real function at manager-creation time, removing the assignment
window while keeping the synchronous first-paint force intact. setOptions
keeps the manager's callback current when onOverflowChange changes.

Also makes the first-paint measurement guard axis-aware so a degenerate
zero-size on the overflow axis (e.g. clientHeight === 0 for vertical
overflow) is not forced against, and unskips the paint-probe e2e that
asserts first-paint correctness directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rgence

The uneven-width paint-probe case used text-content widths, which depend
on the host's installed fonts. On CI (fewer fonts) the text rendered
narrower, so all items fit at 355px and nothing overflowed — failing the
hardcoded expectation that worked locally. Replace the text widths with
explicit uneven pixel widths so the overflow boundary is identical across
environments.

Also add a second-rAF snapshot and assert raf1 === raf2, so each case
proves the first-paint value is the settled one (no drift on the next
frame) rather than only matching a hardcoded number at a single instant.
The layout/effect phases are intentionally not asserted equal to raf1:
the overflow menu's own width reservation resolves on a second pass that
only settles by the first rAF, so those phases legitimately lag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…OverflowMenu

`isOverflowing` is in the effect deps as a stand-in for "ref.current
(re)attached" (consumers gate the menu element on it; refs can't be
effect deps), not because the effect reads its value. Document this so it
is not mistaken for a stray dependency and removed, which would break
registering the menu on mount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.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.

1 participant