From 778641d90c4720037ff0e18bebc4b37cc288bddb Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 3 Jun 2026 18:25:46 +0200 Subject: [PATCH 1/6] feat(react-overflow): allow opting out of first-paint correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-paint correctness is now *requested* by the default item/menu hooks: useOverflowItem and useOverflowMenu call forceUpdateOverflow on registration, which the container honors by resolving overflow synchronously in its observe layout effect (deferring the request until it observes, via child-before-parent effect ordering). A hot-path consumer that builds a custom item hook on top of useOverflowContext and omits the forceUpdateOverflow call opts the container out of the synchronous first-paint pass entirely — the ResizeObserver then drives the first (async) overflow pass instead. No new Overflow prop or public export; the opt-out is intentionally low-profile. Co-Authored-By: Claude Opus 4.8 --- .../@fluentui-react-overflow-pr4-opt-out.json | 7 ++ .../library/src/Overflow.paint-probe.cy.tsx | 86 ++++++++++++++++++- .../library/src/useOverflowContainer.ts | 27 +++++- .../library/src/useOverflowItem.test.tsx | 1 + .../library/src/useOverflowItem.ts | 10 ++- 5 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 change/@fluentui-react-overflow-pr4-opt-out.json diff --git a/change/@fluentui-react-overflow-pr4-opt-out.json b/change/@fluentui-react-overflow-pr4-opt-out.json new file mode 100644 index 0000000000000..be4af46b431d1 --- /dev/null +++ b/change/@fluentui-react-overflow-pr4-opt-out.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: allow opting out of first-paint overflow correctness for hot-path consumers", + "packageName": "@fluentui/react-overflow", + "email": "bsunderhus@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx index 8a313a063af82..d8ea0251cbf2a 100644 --- a/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx +++ b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx @@ -4,10 +4,12 @@ import { Overflow, OverflowItem, useOverflowMenu, + useOverflowContext, + useOverflowCount, type OverflowProps, type OverflowItemProps, } from '@fluentui/react-overflow'; -import type { DistributiveOmit } from '@fluentui/react-utilities'; +import { useIsomorphicLayoutEffect, type DistributiveOmit } from '@fluentui/react-utilities'; // Disable StrictMode so the probe measures a single mount/commit path. const mount = (element: React.ReactElement) => mountBase(element, { strict: false }); @@ -113,6 +115,54 @@ const Menu = () => { ); }; +// Opt-out hooks: equivalent to useOverflowItem / useOverflowMenu but WITHOUT requesting first-paint +// correctness (no forceUpdateOverflow on registration). This is how a hot-path consumer opts the +// container out of the synchronous first-paint pass — no Overflow prop, just a custom item/menu hook. +const useOptOutOverflowItem = (id: string): React.RefObject => { + const ref = React.useRef(null); + const registerItem = useOverflowContext(v => v.registerItem); + useIsomorphicLayoutEffect(() => { + if (ref.current) { + return registerItem({ element: ref.current, id, priority: 0 }); + } + }, [id, registerItem]); + return ref; +}; + +const useOptOutOverflowMenu = () => { + const ref = React.useRef(null); + const overflowCount = useOverflowCount(); + const registerOverflowMenu = useOverflowContext(v => v.registerOverflowMenu); + const isOverflowing = overflowCount > 0; + useIsomorphicLayoutEffect(() => { + if (ref.current) { + return registerOverflowMenu(ref.current); + } + }, [registerOverflowMenu, isOverflowing]); + return { ref, overflowCount, isOverflowing }; +}; + +const OptOutItem = ({ children, width, id }: { children?: React.ReactNode; width?: number; id: string }) => { + const ref = useOptOutOverflowItem(id); + return ( + + ); +}; + +const OptOutMenu = () => { + const { isOverflowing, ref, overflowCount } = useOptOutOverflowMenu(); + if (!isOverflowing) { + return null; + } + return ( + + ); +}; + const PaintPhaseProbe: React.FC<{ name: string }> = ({ name }) => { // The probe deliberately distinguishes the layout phase from the passive effect phase, // so it must use the non-isomorphic variant. @@ -284,4 +334,38 @@ describe('Overflow paint probe', () => { overflowingItemIds: [], }); }); + + it('defers overflow past first paint when items and menu opt out of first-paint correctness', { retries: 0 }, () => { + const mapHelper = new Array(10).fill(0).map((_, i) => i); + + mount( + + + {mapHelper.map(i => ( + + {i} + + ))} + + + , + ); + + // The opt-out hooks never call forceUpdateOverflow, so nothing requests the synchronous + // first-paint pass. At the synchronous commit (layout phase) overflow is therefore unresolved — + // the eager cases above collapse items here instead. The ResizeObserver resolves it afterwards. + cy.get(`[${selectors.probe}="opt-out"] [${selectors.probePhase}="raf2"]`).should($node => { + expect($node.text(), 'probe snapshots written').not.to.equal(''); + }); + cy.get(`[${selectors.probe}="opt-out"]`).then($probe => { + const layout = JSON.parse($probe.find(`[${selectors.probePhase}="layout"]`).text()) as PaintPhaseSnapshot; + expect(layout, 'first paint (layout) is unresolved when opting out of first-paint correctness').to.deep.equal({ + menuText: null, + overflowingItemIds: [], + }); + }); + + // It still resolves eventually — the ResizeObserver drives the deferred overflow pass. + cy.get(`[${selectors.menu}]`).should('exist'); + }); }); diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 77166cb4af8ee..2f8f4e89e0d2d 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -56,15 +56,27 @@ export const useOverflowContainer = ( const managerRef = React.useRef(null); + // Whether the container's observe effect has run. Item/menu hooks request first-paint correctness + // via `forceUpdateOverflow`; before the container observes there is nothing to compute yet, so the + // request is recorded here and honored when the observe effect runs. + const hasObservedRef = React.useRef(false); + // Set when a descendant requests first-paint correctness before the container observes. The default + // item/menu hooks make this request; a hook that omits it opts the container out of the synchronous + // first-paint pass (the hot path), letting the ResizeObserver drive the first overflow pass instead. + const pendingForceUpdateRef = React.useRef(false); + if (managerRef.current === null) { managerRef.current = canUseDOM() ? createOverflowManager(observeOptions) : null; } useIsomorphicLayoutEffect(() => { if (managerRef.current && containerRef.current) { - // forceUpdate resolves overflow synchronously for a correct first paint; the manager guards it - // on the container being measured. - managerRef.current.observe(containerRef.current, { forceUpdate: true }); + // Child item/menu effects already ran (child-before-parent), so `pendingForceUpdateRef` + // reflects whether any descendant requested first-paint correctness. When requested, resolve + // overflow synchronously so the first paint is correct; the manager guards the force on the + // container being measured. + managerRef.current.observe(containerRef.current, { forceUpdate: pendingForceUpdateRef.current }); + hasObservedRef.current = true; return () => managerRef.current?.disconnect(); } }, []); @@ -120,7 +132,14 @@ export const useOverflowContainer = ( ); const forceUpdateOverflow = React.useCallback(() => { - managerRef.current?.forceUpdate(); + // Before the container observes, a force can't compute anything (it isn't observing yet), so + // record the request and let the observe effect honor it (first-paint correctness). After + // observing, force directly. + if (hasObservedRef.current) { + managerRef.current?.forceUpdate(); + } else { + pendingForceUpdateRef.current = true; + } }, []); return { diff --git a/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx index 435ecdd1c4eaa..54af20a22e0ee 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx @@ -11,6 +11,7 @@ const mockContextValue = (options: Partial = {}) => itemVisibility: {}, registerItem: jest.fn(), updateOverflow: jest.fn(), + forceUpdateOverflow: jest.fn(), ...options, } as OverflowContextValue); diff --git a/packages/react-components/react-overflow/library/src/useOverflowItem.ts b/packages/react-components/react-overflow/library/src/useOverflowItem.ts index 587196f754e6f..011f4b2fdba1f 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowItem.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowItem.ts @@ -22,6 +22,7 @@ export function useOverflowItem( ): React.RefObject { const ref = React.useRef(null); const registerItem = useOverflowContext(v => v.registerItem); + const forceUpdateOverflow = useOverflowContext(v => v.forceUpdateOverflow); useIsomorphicLayoutEffect(() => { if (process.env.NODE_ENV !== 'production') { @@ -35,15 +36,20 @@ export function useOverflowItem( } if (ref.current) { - return registerItem({ + const unregister = registerItem({ element: ref.current, id, priority: priority ?? 0, groupId, pinned, }); + // Request first-paint correctness. Before the container observes this defers a synchronous + // force to the container's observe effect; after, it recomputes for the (re)registered item. + // An item hook that omits this call opts the container out of the synchronous first-paint pass. + forceUpdateOverflow(); + return unregister; } - }, [id, priority, registerItem, groupId, pinned]); + }, [id, priority, registerItem, forceUpdateOverflow, groupId, pinned]); return ref; } From 266a042c6d7a2999145208cb4814187f302f5d8c Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 4 Jun 2026 10:20:50 +0200 Subject: [PATCH 2/6] chore: move force update on observe to the manager --- .../src/overflowManager.test.ts | 36 +++++++++++++++++++ .../priority-overflow/src/overflowManager.ts | 12 ++++++- .../library/src/useOverflowContainer.ts | 31 +++++----------- .../library/src/useOverflowItem.ts | 8 ++--- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/react-components/priority-overflow/src/overflowManager.test.ts b/packages/react-components/priority-overflow/src/overflowManager.test.ts index fc489028228e3..2918847ff7eda 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.test.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -223,4 +223,40 @@ describe('overflowManager', () => { expect(manager.getSnapshot().itemVisibility).toEqual({}); }); + + it('applies a forceUpdate requested before observe once observation starts (deferred first paint)', () => { + const manager = createOverflowManager(createObserveOptions()); + const container = createContainer(100); + const itemA = createElementWithSize('button', 60); + const itemB = createElementWithSize('button', 60); + const menu = createElementWithSize('button', 30); + + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.addItem({ element: itemB, id: 'b', priority: 0 }); + manager.addOverflowMenu(menu); + + // forceUpdate before observe can't compute anything yet; the manager defers it and observe() + // applies it — so overflow is resolved synchronously without passing the forceUpdate option. + manager.forceUpdate(); + manager.observe(container); + + expect(getVisibleIds(manager)).toEqual(['a']); + expect(getInvisibleIds(manager)).toEqual(['b']); + }); + + it('does not apply a deferred forceUpdate when the container is not measured', () => { + const manager = createOverflowManager(createObserveOptions()); + const container = createContainer(0); + const itemA = createElementWithSize('button', 60); + const itemB = createElementWithSize('button', 60); + + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.addItem({ element: itemB, id: 'b', priority: 0 }); + + // Degenerate 0 size — observe() skips the deferred force so nothing collapses. + manager.forceUpdate(); + manager.observe(container); + + expect(manager.getSnapshot().itemVisibility).toEqual({}); + }); }); diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index d26a3af1f3959..4d46af709dcf9 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -44,6 +44,7 @@ export function createOverflowManager(initialOptions: Partial = // If true, next update will dispatch to onUpdateOverflow even if queue top states don't change // Initially true to force dispatch on first mount let forceDispatch = true; + let forceUpdateOnObserve = false; const options: Required = { ...DEFAULT_OPTIONS, ...initialOptions }; const overflowItems: Record = {}; const overflowDividers: Record = {}; @@ -236,6 +237,13 @@ export function createOverflowManager(initialOptions: Partial = }; const forceUpdate: OverflowManager['forceUpdate'] = () => { + if (!container) { + // Not observing yet — there is nothing to measure. Remember the request and let observe() + // honor it once the container is being observed (first-paint correctness). + forceUpdateOnObserve = true; + return; + } + if (processOverflowItems() || forceDispatch) { forceDispatch = false; dispatchOverflowUpdate(); @@ -283,9 +291,10 @@ export function createOverflowManager(initialOptions: Partial = update(); }); - if (shouldForceUpdate && getClientSize(observedContainer) > 0) { + if ((shouldForceUpdate || forceUpdateOnObserve) && getClientSize(observedContainer) > 0) { forceUpdate(); } + forceUpdateOnObserve = false; }; const disconnect: OverflowManager['disconnect'] = () => { @@ -298,6 +307,7 @@ export function createOverflowManager(initialOptions: Partial = container = undefined; observing = false; forceDispatch = true; + forceUpdateOnObserve = false; // clear all entries Object.keys(overflowItems).forEach(itemId => removeItem(itemId)); diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 2f8f4e89e0d2d..12176ec3492c6 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -56,27 +56,17 @@ export const useOverflowContainer = ( const managerRef = React.useRef(null); - // Whether the container's observe effect has run. Item/menu hooks request first-paint correctness - // via `forceUpdateOverflow`; before the container observes there is nothing to compute yet, so the - // request is recorded here and honored when the observe effect runs. - const hasObservedRef = React.useRef(false); - // Set when a descendant requests first-paint correctness before the container observes. The default - // item/menu hooks make this request; a hook that omits it opts the container out of the synchronous - // first-paint pass (the hot path), letting the ResizeObserver drive the first overflow pass instead. - const pendingForceUpdateRef = React.useRef(false); - if (managerRef.current === null) { managerRef.current = canUseDOM() ? createOverflowManager(observeOptions) : null; } useIsomorphicLayoutEffect(() => { if (managerRef.current && containerRef.current) { - // Child item/menu effects already ran (child-before-parent), so `pendingForceUpdateRef` - // reflects whether any descendant requested first-paint correctness. When requested, resolve - // overflow synchronously so the first paint is correct; the manager guards the force on the - // container being measured. - managerRef.current.observe(containerRef.current, { forceUpdate: pendingForceUpdateRef.current }); - hasObservedRef.current = true; + // First-paint correctness is requested by descendants (the default item/menu hooks call + // forceUpdateOverflow during their layout effects, which commit before this one). The manager + // honors any such pre-observe request here, guarded on the container being measured. A consumer + // whose item hook omits that request opts out — the ResizeObserver drives the first pass. + managerRef.current.observe(containerRef.current); return () => managerRef.current?.disconnect(); } }, []); @@ -132,14 +122,9 @@ export const useOverflowContainer = ( ); const forceUpdateOverflow = React.useCallback(() => { - // Before the container observes, a force can't compute anything (it isn't observing yet), so - // record the request and let the observe effect honor it (first-paint correctness). After - // observing, force directly. - if (hasObservedRef.current) { - managerRef.current?.forceUpdate(); - } else { - pendingForceUpdateRef.current = true; - } + // The manager handles the before/after-observe distinction: a call before observation is + // recorded and applied once it observes (first-paint correctness); a call after forces directly. + managerRef.current?.forceUpdate(); }, []); return { diff --git a/packages/react-components/react-overflow/library/src/useOverflowItem.ts b/packages/react-components/react-overflow/library/src/useOverflowItem.ts index 011f4b2fdba1f..232ca5e31642d 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowItem.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowItem.ts @@ -43,11 +43,11 @@ export function useOverflowItem( groupId, pinned, }); - // Request first-paint correctness. Before the container observes this defers a synchronous - // force to the container's observe effect; after, it recomputes for the (re)registered item. - // An item hook that omits this call opts the container out of the synchronous first-paint pass. forceUpdateOverflow(); - return unregister; + return () => { + unregister(); + forceUpdateOverflow(); + }; } }, [id, priority, registerItem, forceUpdateOverflow, groupId, pinned]); From e5785067f4b7e034707036799d62a4e842434d63 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 4 Jun 2026 11:45:45 +0200 Subject: [PATCH 3/6] test(react-overflow): fix useOverflowItem registration test The cleanup now invokes the unregister function returned by registerItem, so the mocked registerItem must return one too (the real one always does). Co-Authored-By: Claude Opus 4.8 --- .../react-overflow/library/src/useOverflowItem.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx index 54af20a22e0ee..ce3323df3b7b6 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx @@ -17,7 +17,8 @@ const mockContextValue = (options: Partial = {}) => describe('useOverflowItem', () => { it('should register item', () => { - const registerItemMock = jest.fn(); + // registerItem returns an unregister cleanup, which the hook invokes on unmount. + const registerItemMock = jest.fn(() => jest.fn()); const value = mockContextValue({ registerItem: registerItemMock }); renderHook( () => { From 4ffaa3b317a1fd9a5f9a5e76fb2dd405bc48553c Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 4 Jun 2026 11:45:48 +0200 Subject: [PATCH 4/6] test(react-overflow): rewrite paint-probe as a paint-only opt-out matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measure only what reaches the screen: a plain requestAnimationFrame loop, decoupled from React's render/commit/effect cycle, records a filmstrip of every painted frame. Covers all four opt-out combinations (item/menu x in/out) and asserts the stable anchors — the first painted frame and the converged final frame — leaving the timing-dependent intermediate (a transient menu-count flicker) unasserted so the test does not flake. Co-Authored-By: Claude Opus 4.8 --- .../library/src/Overflow.paint-probe.cy.tsx | 421 ++++++------------ 1 file changed, 124 insertions(+), 297 deletions(-) diff --git a/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx index d8ea0251cbf2a..62aeea014039f 100644 --- a/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx +++ b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx @@ -1,15 +1,7 @@ import * as React from 'react'; import { mount as mountBase } from '@fluentui/scripts-cypress'; -import { - Overflow, - OverflowItem, - useOverflowMenu, - useOverflowContext, - useOverflowCount, - type OverflowProps, - type OverflowItemProps, -} from '@fluentui/react-overflow'; -import { useIsomorphicLayoutEffect, type DistributiveOmit } from '@fluentui/react-utilities'; +import { Overflow, useOverflowContext, useOverflowCount } from '@fluentui/react-overflow'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; // Disable StrictMode so the probe measures a single mount/commit path. const mount = (element: React.ReactElement) => mountBase(element, { strict: false }); @@ -18,213 +10,147 @@ const selectors = { container: 'data-test-container', item: 'data-test-item', menu: 'data-test-menu', - probe: 'data-test-paint-probe', - probePhase: 'data-test-paint-phase', }; -type PaintPhaseSnapshot = { - menuText: string | null; - overflowingItemIds: string[]; -}; +// The only thing this probe measures: what is on screen. `overflowingItemIds` are the items marked +// overflowing (what the real component hides); `menuText` is the rendered overflow-menu count. +type Paint = { menuText: string | null; overflowingItemIds: string[] }; -const readPaintPhaseSnapshot = (): PaintPhaseSnapshot => { +const read = (): Paint => { const menu = document.querySelector(`[${selectors.menu}]`); const overflowingItemIds = Array.from(document.querySelectorAll(`[${selectors.item}]`)) .filter(item => item.getAttribute('data-overflowing') !== null) .map(item => item.getAttribute(selectors.item) ?? ''); - - return { - menuText: menu?.textContent ?? null, - overflowingItemIds, - }; + return { menuText: menu?.textContent ?? null, overflowingItemIds }; }; -const writePhaseSnapshot = ( - name: string, - phase: 'layout' | 'effect' | 'raf1' | 'raf2', - snapshot: PaintPhaseSnapshot, -) => { - const probeRoot = document.querySelector(`[${selectors.probe}="${name}"]`); - const phaseNode = probeRoot?.querySelector(`[${selectors.probePhase}="${phase}"]`); - - if (phaseNode) { - phaseNode.textContent = JSON.stringify(snapshot); - } -}; - -const Container: React.FC<{ children?: React.ReactNode; size?: number } & Omit> = ({ - children, - size, - ...userProps -}) => { - const selector = { - [selectors.container]: '', +// ── Paint recorder ────────────────────────────────────────────────────────────────────────────── +// A plain requestAnimationFrame loop, deliberately decoupled from React's render/commit/effect +// cycle. rAF fires once per frame, so every entry is a real painted frame — a faithful "filmstrip" +// of what was actually on screen, not a sample taken at some React lifecycle hook. The metric is +// paint, measured without React. +const paints: Record = {}; +const recordPaints = (name: string, frames: number) => { + const filmstrip: Paint[] = []; + const tick = () => { + filmstrip.push(read()); + if (filmstrip.length < frames) { + requestAnimationFrame(tick); + } else { + paints[name] = filmstrip; + } }; - - return ( - -
- {children} -
-
- ); + requestAnimationFrame(tick); }; -type ItemProps = { children?: React.ReactNode; width?: number | string } & DistributiveOmit< - OverflowItemProps, - 'children' ->; - -const Item = ({ children, width, ...overflowItemProps }: ItemProps) => { - const selector = { - [selectors.item]: overflowItemProps.id, - }; - - return ( - - - - ); +// Kicks the recorder off at mount. The layout effect runs before the first paint, so the first +// captured frame IS the first paint. React is used only to start the loop — never to measure. +const PaintRecorder: React.FC<{ name: string; frames: number }> = ({ name, frames }) => { + // eslint-disable-next-line no-restricted-properties + React.useLayoutEffect(() => recordPaints(name, frames), [name, frames]); + return null; }; -const Menu = () => { - const { isOverflowing, ref, overflowCount } = useOverflowMenu(); - const selector = { - [selectors.menu]: '', - }; +// Collapse consecutive identical frames into the sequence of distinct painted states. +const distinctPaints = (filmstrip: Paint[]): Paint[] => + filmstrip.filter((paint, i) => i === 0 || JSON.stringify(paint) !== JSON.stringify(filmstrip[i - 1])); - if (!isOverflowing) { - return null; - } - - return ( - - ); -}; - -// Opt-out hooks: equivalent to useOverflowItem / useOverflowMenu but WITHOUT requesting first-paint -// correctness (no forceUpdateOverflow on registration). This is how a hot-path consumer opts the -// container out of the synchronous first-paint pass — no Overflow prop, just a custom item/menu hook. -const useOptOutOverflowItem = (id: string): React.RefObject => { - const ref = React.useRef(null); +// ── Opt-in / opt-out building blocks ────────────────────────────────────────────────────────────── +// Opt-in item mirrors useOverflowItem: it requests first-paint correctness by calling +// forceUpdateOverflow on registration. Opt-out only registers. +const Item: React.FC<{ id: string; optOut: boolean }> = ({ id, optOut }) => { + const ref = React.useRef(null); const registerItem = useOverflowContext(v => v.registerItem); + const forceUpdateOverflow = useOverflowContext(v => v.forceUpdateOverflow); useIsomorphicLayoutEffect(() => { if (ref.current) { - return registerItem({ element: ref.current, id, priority: 0 }); + const unregister = registerItem({ element: ref.current, id, priority: 0 }); + if (!optOut) { + forceUpdateOverflow(); + } + return unregister; } - }, [id, registerItem]); - return ref; + }, [id, registerItem, forceUpdateOverflow, optOut]); + return ( + + ); }; -const useOptOutOverflowMenu = () => { - const ref = React.useRef(null); +// Opt-in menu mirrors useOverflowMenu: it forces synchronously when overflowing so its own width is +// accounted before paint. Opt-out only registers (relying on addOverflowMenu's async pass). +const Menu: React.FC<{ optOut: boolean }> = ({ optOut }) => { + const ref = React.useRef(null); const overflowCount = useOverflowCount(); const registerOverflowMenu = useOverflowContext(v => v.registerOverflowMenu); + const forceUpdateOverflow = useOverflowContext(v => v.forceUpdateOverflow); const isOverflowing = overflowCount > 0; useIsomorphicLayoutEffect(() => { if (ref.current) { - return registerOverflowMenu(ref.current); + const unregister = registerOverflowMenu(ref.current); + if (!optOut && isOverflowing) { + forceUpdateOverflow(); + } + return unregister; } - }, [registerOverflowMenu, isOverflowing]); - return { ref, overflowCount, isOverflowing }; -}; - -const OptOutItem = ({ children, width, id }: { children?: React.ReactNode; width?: number; id: string }) => { - const ref = useOptOutOverflowItem(id); - return ( - - ); -}; - -const OptOutMenu = () => { - const { isOverflowing, ref, overflowCount } = useOptOutOverflowMenu(); + }, [registerOverflowMenu, forceUpdateOverflow, isOverflowing, optOut]); if (!isOverflowing) { return null; } return ( - ); }; -const PaintPhaseProbe: React.FC<{ name: string }> = ({ name }) => { - // The probe deliberately distinguishes the layout phase from the passive effect phase, - // so it must use the non-isomorphic variant. - // eslint-disable-next-line no-restricted-properties - React.useLayoutEffect(() => { - writePhaseSnapshot(name, 'layout', readPaintPhaseSnapshot()); - }, [name]); +const Container: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + +
+ {children} +
+
+); - React.useEffect(() => { - writePhaseSnapshot(name, 'effect', readPaintPhaseSnapshot()); +// 300px container, 10 items @ 50px, menu @ 50px, padding 0 -> settled state hides items 5..9 (+5). +const ITEM_IDS = Array.from({ length: 10 }, (_, i) => String(i)); +const FRAMES = 12; - requestAnimationFrame(() => { - writePhaseSnapshot(name, 'raf1', readPaintPhaseSnapshot()); - // Second frame: used to assert the first-rAF snapshot is already settled (no drift), - // i.e. it is the converged value rather than a transient mid-convergence reading. - requestAnimationFrame(() => { - writePhaseSnapshot(name, 'raf2', readPaintPhaseSnapshot()); - }); - }); - }, [name]); +const SETTLED: Paint = { menuText: '+5', overflowingItemIds: ['5', '6', '7', '8', '9'] }; +const UNRESOLVED: Paint = { menuText: null, overflowingItemIds: [] }; +const RESOLVED_ITEMS = ['5', '6', '7', '8', '9']; - return null; -}; - -const PaintPhaseProbeHarness: React.FC<{ name: string; children: React.ReactNode }> = ({ name, children }) => { - return ( +const recordCase = (name: string, itemsOptOut: boolean, menuOptOut: boolean) => { + mount( <> - {children} -
-
-        
-        
-        
-      
- - + + {ITEM_IDS.map(id => ( + + ))} + + + + , ); -}; - -const assertProbeConvergence = (name: string, expected: PaintPhaseSnapshot) => { - cy.get(`[${selectors.probe}="${name}"] [${selectors.probePhase}="raf1"]`).should($node => { - expect($node.text(), 'raf1 snapshot marker').not.to.equal(''); - }); - cy.get(`[${selectors.probe}="${name}"] [${selectors.probePhase}="raf2"]`).should($node => { - expect($node.text(), 'raf2 snapshot marker').not.to.equal(''); + return cy.wrap(null, { timeout: 4000 }).should(() => { + expect(paints[name], `${name}: recorded ${FRAMES} frames`).to.have.length(FRAMES); }); +}; - cy.get(`[${selectors.probe}="${name}"]`).then($probe => { - const read = (phase: 'layout' | 'effect' | 'raf1' | 'raf2') => { - const text = $probe.find(`[${selectors.probePhase}="${phase}"]`).text(); - return JSON.parse(text) as PaintPhaseSnapshot; - }; - - const raf1 = read('raf1'); - const raf2 = read('raf2'); - const debugSnapshots = `raf1=${JSON.stringify(raf1)} raf2=${JSON.stringify(raf2)}`; - - // First-paint correctness: the snapshot is already the expected final value by the first rAF. - expect(raf1, `unexpected first-raf snapshot; ${debugSnapshots}`).to.deep.equal(expected); - // Convergence: the first-rAF value is settled, not a transient — it does not drift next frame. - expect(raf2, `first-raf snapshot drifted on the next frame; ${debugSnapshots}`).to.deep.equal(raf1); +// Asserts on the distinct painted filmstrip: the first painted frame and the converged final frame. +// The middle of an opt-out filmstrip (e.g. a transient menu-count flicker) is timing-dependent and +// intentionally not asserted. +const assertFilmstrip = (name: string, assertFirst: (first: Paint) => void) => { + cy.then(() => { + const film = distinctPaints(paints[name]); + const debug = `; filmstrip=${JSON.stringify(film)}`; + assertFirst(film[0]); + expect(film[film.length - 1], `${name}: converges to settled${debug}`).to.deep.equal(SETTLED); }); }; @@ -233,139 +159,40 @@ describe('Overflow paint probe', () => { cy.viewport(700, 700); }); - it('is already final by first rAF on initial overflowing mount', { retries: 0 }, () => { - const mapHelper = new Array(10).fill(0).map((_, i) => i); - - mount( - - - {mapHelper.map(i => ( - - {i} - - ))} - - - , - ); - - assertProbeConvergence('initial-overflow', { - menuText: '+5', - overflowingItemIds: ['5', '6', '7', '8', '9'], + // No opt-out: both item and menu request first-paint correctness, so the very first painted frame + // is already fully settled (items hidden AND menu count correct). Filmstrip: [+5]. + it('no opt-out: first paint is fully settled', () => { + recordCase('no-opt-out', false, false); + assertFilmstrip('no-opt-out', first => { + expect(first, 'no-opt-out: first paint is fully settled').to.deep.equal(SETTLED); }); }); - it('is already final by first rAF for a slightly wider initial-overflow case', { retries: 0 }, () => { - const mapHelper = new Array(10).fill(0).map((_, i) => i); - - mount( - - - {mapHelper.map(i => ( - - {i} - - ))} - - - , - ); - - assertProbeConvergence('initial-overflow-wide', { - menuText: '+4', - overflowingItemIds: ['6', '7', '8', '9'], + // Menu opt-out: items still resolve at first paint, but the menu does not force, so its count is + // corrected asynchronously and the menu number flickers. Filmstrip: [+4 -> +5]. We assert only the + // stable part — items are correct on the first painted frame. + it('menu opt-out: items correct at first paint (menu count may flicker)', () => { + recordCase('menu-opt-out', false, true); + assertFilmstrip('menu-opt-out', first => { + expect(first.overflowingItemIds, 'menu-opt-out: items resolved at first paint').to.deep.equal(RESOLVED_ITEMS); }); }); - it('is already final by first rAF for an uneven-width initial-overflow case', { retries: 0 }, () => { - mount( - - {/* Explicit, uneven, font-independent widths. Text-content widths vary with the host's - installed fonts (narrower on CI), shifting the overflow boundary and making the - expected snapshot non-deterministic across environments. */} - - - Item 0 - - - Item 1 - - - Super Long Item 2 - - - 3 - - - Item 4 - - - Item 5 - - - - , - ); - - assertProbeConvergence('initial-overflow-uneven', { - menuText: '+2', - overflowingItemIds: ['4', '5'], + // Items opt-out: nothing forces, so the first painted frame is unresolved (all items visible, no + // menu); the ResizeObserver drives a later pass. Filmstrip: [none -> +5]. + it('items opt-out: first paint is unresolved, settles later', () => { + recordCase('items-opt-out', true, false); + assertFilmstrip('items-opt-out', first => { + expect(first, 'items-opt-out: first paint is unresolved').to.deep.equal(UNRESOLVED); }); }); - it('is already final by first rAF when the menu never becomes visible', { retries: 0 }, () => { - const mapHelper = new Array(5).fill(0).map((_, i) => i); - - mount( - - - {mapHelper.map(i => ( - - {i} - - ))} - - - , - ); - - assertProbeConvergence('initial-no-menu', { - menuText: null, - overflowingItemIds: [], - }); - }); - - it('defers overflow past first paint when items and menu opt out of first-paint correctness', { retries: 0 }, () => { - const mapHelper = new Array(10).fill(0).map((_, i) => i); - - mount( - - - {mapHelper.map(i => ( - - {i} - - ))} - - - , - ); - - // The opt-out hooks never call forceUpdateOverflow, so nothing requests the synchronous - // first-paint pass. At the synchronous commit (layout phase) overflow is therefore unresolved — - // the eager cases above collapse items here instead. The ResizeObserver resolves it afterwards. - cy.get(`[${selectors.probe}="opt-out"] [${selectors.probePhase}="raf2"]`).should($node => { - expect($node.text(), 'probe snapshots written').not.to.equal(''); + // Both opt-out: the worst case — first paint unresolved, then items + menu appear, then the menu + // count settles. Filmstrip: [none -> (+4) -> +5]. First paint unresolved is the stable anchor. + it('both opt-out: first paint is unresolved, settles later', () => { + recordCase('both-opt-out', true, true); + assertFilmstrip('both-opt-out', first => { + expect(first, 'both-opt-out: first paint is unresolved').to.deep.equal(UNRESOLVED); }); - cy.get(`[${selectors.probe}="opt-out"]`).then($probe => { - const layout = JSON.parse($probe.find(`[${selectors.probePhase}="layout"]`).text()) as PaintPhaseSnapshot; - expect(layout, 'first paint (layout) is unresolved when opting out of first-paint correctness').to.deep.equal({ - menuText: null, - overflowingItemIds: [], - }); - }); - - // It still resolves eventually — the ResizeObserver drives the deferred overflow pass. - cy.get(`[${selectors.menu}]`).should('exist'); }); }); From 71303ecd80989d5a6a5bbd98060350f69d40f2b2 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 4 Jun 2026 11:57:59 +0200 Subject: [PATCH 5/6] chore: add priority-overflow change file for the opt-out deferral Co-Authored-By: Claude Opus 4.8 --- change/@fluentui-priority-overflow-pr4-opt-out.json | 7 +++++++ .../priority-overflow/src/overflowManager.ts | 2 -- .../react-overflow/library/src/useOverflowContainer.ts | 6 ------ 3 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 change/@fluentui-priority-overflow-pr4-opt-out.json diff --git a/change/@fluentui-priority-overflow-pr4-opt-out.json b/change/@fluentui-priority-overflow-pr4-opt-out.json new file mode 100644 index 0000000000000..c5bc00edd69ef --- /dev/null +++ b/change/@fluentui-priority-overflow-pr4-opt-out.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: apply a forceUpdate requested before observe once observation begins", + "packageName": "@fluentui/priority-overflow", + "email": "bsunderhus@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index 4d46af709dcf9..e05c70fc337b6 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -238,8 +238,6 @@ export function createOverflowManager(initialOptions: Partial = const forceUpdate: OverflowManager['forceUpdate'] = () => { if (!container) { - // Not observing yet — there is nothing to measure. Remember the request and let observe() - // honor it once the container is being observed (first-paint correctness). forceUpdateOnObserve = true; return; } diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 12176ec3492c6..aa1bb8325b354 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -62,10 +62,6 @@ export const useOverflowContainer = ( useIsomorphicLayoutEffect(() => { if (managerRef.current && containerRef.current) { - // First-paint correctness is requested by descendants (the default item/menu hooks call - // forceUpdateOverflow during their layout effects, which commit before this one). The manager - // honors any such pre-observe request here, guarded on the container being measured. A consumer - // whose item hook omits that request opts out — the ResizeObserver drives the first pass. managerRef.current.observe(containerRef.current); return () => managerRef.current?.disconnect(); } @@ -122,8 +118,6 @@ export const useOverflowContainer = ( ); const forceUpdateOverflow = React.useCallback(() => { - // The manager handles the before/after-observe distinction: a call before observation is - // recorded and applied once it observes (first-paint correctness); a call after forces directly. managerRef.current?.forceUpdate(); }, []); From ef30b0e132f283faed2039f96b87b96b34687f39 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 4 Jun 2026 13:15:29 +0200 Subject: [PATCH 6/6] test(react-overflow): opt out via context override, not reimplemented hooks The paint-probe now drives the real OverflowItem and useOverflowMenu. Each opt-out case is expressed by wrapping the relevant subtree in a provider that overrides forceUpdateOverflow to a no-op, instead of reimplementing the hooks to drop that call. A Context.Provider renders no DOM node, so the flex layout and overflow geometry are unchanged. Same stable filmstrips, now with real component coverage. Co-Authored-By: Claude Opus 4.8 --- .../library/src/Overflow.paint-probe.cy.tsx | 108 +++++++++--------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx index 62aeea014039f..21766094b2422 100644 --- a/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx +++ b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { mount as mountBase } from '@fluentui/scripts-cypress'; -import { Overflow, useOverflowContext, useOverflowCount } from '@fluentui/react-overflow'; -import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { Overflow, OverflowItem, useOverflowMenu } from '@fluentui/react-overflow'; +import { OverflowContext, useOverflowContext } from './overflowContext'; // Disable StrictMode so the probe measures a single mount/commit path. const mount = (element: React.ReactElement) => mountBase(element, { strict: false }); @@ -55,46 +55,31 @@ const PaintRecorder: React.FC<{ name: string; frames: number }> = ({ name, frame const distinctPaints = (filmstrip: Paint[]): Paint[] => filmstrip.filter((paint, i) => i === 0 || JSON.stringify(paint) !== JSON.stringify(filmstrip[i - 1])); -// ── Opt-in / opt-out building blocks ────────────────────────────────────────────────────────────── -// Opt-in item mirrors useOverflowItem: it requests first-paint correctness by calling -// forceUpdateOverflow on registration. Opt-out only registers. -const Item: React.FC<{ id: string; optOut: boolean }> = ({ id, optOut }) => { - const ref = React.useRef(null); - const registerItem = useOverflowContext(v => v.registerItem); - const forceUpdateOverflow = useOverflowContext(v => v.forceUpdateOverflow); - useIsomorphicLayoutEffect(() => { - if (ref.current) { - const unregister = registerItem({ element: ref.current, id, priority: 0 }); - if (!optOut) { - forceUpdateOverflow(); - } - return unregister; - } - }, [id, registerItem, forceUpdateOverflow, optOut]); - return ( - - ); -}; + +); -// Opt-in menu mirrors useOverflowMenu: it forces synchronously when overflowing so its own width is -// accounted before paint. Opt-out only registers (relying on addOverflowMenu's async pass). -const Menu: React.FC<{ optOut: boolean }> = ({ optOut }) => { - const ref = React.useRef(null); - const overflowCount = useOverflowCount(); - const registerOverflowMenu = useOverflowContext(v => v.registerOverflowMenu); - const forceUpdateOverflow = useOverflowContext(v => v.forceUpdateOverflow); - const isOverflowing = overflowCount > 0; - useIsomorphicLayoutEffect(() => { - if (ref.current) { - const unregister = registerOverflowMenu(ref.current); - if (!optOut && isOverflowing) { - forceUpdateOverflow(); - } - return unregister; - } - }, [registerOverflowMenu, forceUpdateOverflow, isOverflowing, optOut]); +const Menu: React.FC = () => { + const { isOverflowing, ref, overflowCount } = useOverflowMenu(); if (!isOverflowing) { return null; } @@ -117,22 +102,17 @@ const Container: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( ); // 300px container, 10 items @ 50px, menu @ 50px, padding 0 -> settled state hides items 5..9 (+5). -const ITEM_IDS = Array.from({ length: 10 }, (_, i) => String(i)); +const items = Array.from({ length: 10 }, (_, i) => ); const FRAMES = 12; const SETTLED: Paint = { menuText: '+5', overflowingItemIds: ['5', '6', '7', '8', '9'] }; const UNRESOLVED: Paint = { menuText: null, overflowingItemIds: [] }; const RESOLVED_ITEMS = ['5', '6', '7', '8', '9']; -const recordCase = (name: string, itemsOptOut: boolean, menuOptOut: boolean) => { +const recordCase = (name: string, content: React.ReactNode) => { mount( <> - - {ITEM_IDS.map(id => ( - - ))} - - + {content} , ); @@ -162,7 +142,13 @@ describe('Overflow paint probe', () => { // No opt-out: both item and menu request first-paint correctness, so the very first painted frame // is already fully settled (items hidden AND menu count correct). Filmstrip: [+5]. it('no opt-out: first paint is fully settled', () => { - recordCase('no-opt-out', false, false); + recordCase( + 'no-opt-out', + <> + {items} + + , + ); assertFilmstrip('no-opt-out', first => { expect(first, 'no-opt-out: first paint is fully settled').to.deep.equal(SETTLED); }); @@ -172,7 +158,15 @@ describe('Overflow paint probe', () => { // corrected asynchronously and the menu number flickers. Filmstrip: [+4 -> +5]. We assert only the // stable part — items are correct on the first painted frame. it('menu opt-out: items correct at first paint (menu count may flicker)', () => { - recordCase('menu-opt-out', false, true); + recordCase( + 'menu-opt-out', + <> + {items} + + + + , + ); assertFilmstrip('menu-opt-out', first => { expect(first.overflowingItemIds, 'menu-opt-out: items resolved at first paint').to.deep.equal(RESOLVED_ITEMS); }); @@ -181,7 +175,13 @@ describe('Overflow paint probe', () => { // Items opt-out: nothing forces, so the first painted frame is unresolved (all items visible, no // menu); the ResizeObserver drives a later pass. Filmstrip: [none -> +5]. it('items opt-out: first paint is unresolved, settles later', () => { - recordCase('items-opt-out', true, false); + recordCase( + 'items-opt-out', + <> + {items} + + , + ); assertFilmstrip('items-opt-out', first => { expect(first, 'items-opt-out: first paint is unresolved').to.deep.equal(UNRESOLVED); }); @@ -190,7 +190,13 @@ describe('Overflow paint probe', () => { // Both opt-out: the worst case — first paint unresolved, then items + menu appear, then the menu // count settles. Filmstrip: [none -> (+4) -> +5]. First paint unresolved is the stable anchor. it('both opt-out: first paint is unresolved, settles later', () => { - recordCase('both-opt-out', true, true); + recordCase( + 'both-opt-out', + + {items} + + , + ); assertFilmstrip('both-opt-out', first => { expect(first, 'both-opt-out: first paint is unresolved').to.deep.equal(UNRESOLVED); });