Skip to content

Commit 6e47e12

Browse files
bsunderhusclaude
andcommitted
feat(react-overflow): allow opting out of first-paint correctness
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 <noreply@anthropic.com>
1 parent ef140f0 commit 6e47e12

5 files changed

Lines changed: 124 additions & 7 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: allow opting out of first-paint overflow correctness for hot-path consumers",
4+
"packageName": "@fluentui/react-overflow",
5+
"email": "bsunderhus@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import {
44
Overflow,
55
OverflowItem,
66
useOverflowMenu,
7+
useOverflowContext,
8+
useOverflowCount,
79
type OverflowProps,
810
type OverflowItemProps,
911
} from '@fluentui/react-overflow';
10-
import type { DistributiveOmit } from '@fluentui/react-utilities';
12+
import { useIsomorphicLayoutEffect, type DistributiveOmit } from '@fluentui/react-utilities';
1113

1214
// Disable StrictMode so the probe measures a single mount/commit path.
1315
const mount = (element: React.ReactElement) => mountBase(element, { strict: false });
@@ -113,6 +115,54 @@ const Menu = () => {
113115
);
114116
};
115117

118+
// Opt-out hooks: equivalent to useOverflowItem / useOverflowMenu but WITHOUT requesting first-paint
119+
// correctness (no forceUpdateOverflow on registration). This is how a hot-path consumer opts the
120+
// container out of the synchronous first-paint pass — no Overflow prop, just a custom item/menu hook.
121+
const useOptOutOverflowItem = <TElement extends HTMLElement>(id: string): React.RefObject<TElement | null> => {
122+
const ref = React.useRef<TElement | null>(null);
123+
const registerItem = useOverflowContext(v => v.registerItem);
124+
useIsomorphicLayoutEffect(() => {
125+
if (ref.current) {
126+
return registerItem({ element: ref.current, id, priority: 0 });
127+
}
128+
}, [id, registerItem]);
129+
return ref;
130+
};
131+
132+
const useOptOutOverflowMenu = <TElement extends HTMLElement>() => {
133+
const ref = React.useRef<TElement | null>(null);
134+
const overflowCount = useOverflowCount();
135+
const registerOverflowMenu = useOverflowContext(v => v.registerOverflowMenu);
136+
const isOverflowing = overflowCount > 0;
137+
useIsomorphicLayoutEffect(() => {
138+
if (ref.current) {
139+
return registerOverflowMenu(ref.current);
140+
}
141+
}, [registerOverflowMenu, isOverflowing]);
142+
return { ref, overflowCount, isOverflowing };
143+
};
144+
145+
const OptOutItem = ({ children, width, id }: { children?: React.ReactNode; width?: number; id: string }) => {
146+
const ref = useOptOutOverflowItem<HTMLButtonElement>(id);
147+
return (
148+
<button ref={ref} {...{ [selectors.item]: id }} style={{ width: width ?? 50, height: 50, flexShrink: 0 }}>
149+
{children}
150+
</button>
151+
);
152+
};
153+
154+
const OptOutMenu = () => {
155+
const { isOverflowing, ref, overflowCount } = useOptOutOverflowMenu<HTMLButtonElement>();
156+
if (!isOverflowing) {
157+
return null;
158+
}
159+
return (
160+
<button {...{ [selectors.menu]: '' }} ref={ref} style={{ width: 50, height: 50, flexShrink: 0 }}>
161+
+{overflowCount}
162+
</button>
163+
);
164+
};
165+
116166
const PaintPhaseProbe: React.FC<{ name: string }> = ({ name }) => {
117167
// The probe deliberately distinguishes the layout phase from the passive effect phase,
118168
// so it must use the non-isomorphic variant.
@@ -284,4 +334,38 @@ describe('Overflow paint probe', () => {
284334
overflowingItemIds: [],
285335
});
286336
});
337+
338+
it('defers overflow past first paint when items and menu opt out of first-paint correctness', { retries: 0 }, () => {
339+
const mapHelper = new Array(10).fill(0).map((_, i) => i);
340+
341+
mount(
342+
<PaintPhaseProbeHarness name="opt-out">
343+
<Container size={300}>
344+
{mapHelper.map(i => (
345+
<OptOutItem key={i} id={i.toString()}>
346+
{i}
347+
</OptOutItem>
348+
))}
349+
<OptOutMenu />
350+
</Container>
351+
</PaintPhaseProbeHarness>,
352+
);
353+
354+
// The opt-out hooks never call forceUpdateOverflow, so nothing requests the synchronous
355+
// first-paint pass. At the synchronous commit (layout phase) overflow is therefore unresolved —
356+
// the eager cases above collapse items here instead. The ResizeObserver resolves it afterwards.
357+
cy.get(`[${selectors.probe}="opt-out"] [${selectors.probePhase}="raf2"]`).should($node => {
358+
expect($node.text(), 'probe snapshots written').not.to.equal('');
359+
});
360+
cy.get(`[${selectors.probe}="opt-out"]`).then($probe => {
361+
const layout = JSON.parse($probe.find(`[${selectors.probePhase}="layout"]`).text()) as PaintPhaseSnapshot;
362+
expect(layout, 'first paint (layout) is unresolved when opting out of first-paint correctness').to.deep.equal({
363+
menuText: null,
364+
overflowingItemIds: [],
365+
});
366+
});
367+
368+
// It still resolves eventually — the ResizeObserver drives the deferred overflow pass.
369+
cy.get(`[${selectors.menu}]`).should('exist');
370+
});
287371
});

packages/react-components/react-overflow/library/src/useOverflowContainer.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,27 @@ export const useOverflowContainer = <TElement extends HTMLElement>(
5656

5757
const managerRef = React.useRef<OverflowManager | null>(null);
5858

59+
// Whether the container's observe effect has run. Item/menu hooks request first-paint correctness
60+
// via `forceUpdateOverflow`; before the container observes there is nothing to compute yet, so the
61+
// request is recorded here and honored when the observe effect runs.
62+
const hasObservedRef = React.useRef(false);
63+
// Set when a descendant requests first-paint correctness before the container observes. The default
64+
// item/menu hooks make this request; a hook that omits it opts the container out of the synchronous
65+
// first-paint pass (the hot path), letting the ResizeObserver drive the first overflow pass instead.
66+
const pendingForceUpdateRef = React.useRef(false);
67+
5968
if (managerRef.current === null) {
6069
managerRef.current = canUseDOM() ? createOverflowManager(observeOptions) : null;
6170
}
6271

6372
useIsomorphicLayoutEffect(() => {
6473
if (managerRef.current && containerRef.current) {
65-
// forceUpdate resolves overflow synchronously for a correct first paint; the manager guards it
66-
// on the container being measured.
67-
managerRef.current.observe(containerRef.current, { forceUpdate: true });
74+
// Child item/menu effects already ran (child-before-parent), so `pendingForceUpdateRef`
75+
// reflects whether any descendant requested first-paint correctness. When requested, resolve
76+
// overflow synchronously so the first paint is correct; the manager guards the force on the
77+
// container being measured.
78+
managerRef.current.observe(containerRef.current, { forceUpdate: pendingForceUpdateRef.current });
79+
hasObservedRef.current = true;
6880
return () => managerRef.current?.disconnect();
6981
}
7082
}, []);
@@ -120,7 +132,14 @@ export const useOverflowContainer = <TElement extends HTMLElement>(
120132
);
121133

122134
const forceUpdateOverflow = React.useCallback(() => {
123-
managerRef.current?.forceUpdate();
135+
// Before the container observes, a force can't compute anything (it isn't observing yet), so
136+
// record the request and let the observe effect honor it (first-paint correctness). After
137+
// observing, force directly.
138+
if (hasObservedRef.current) {
139+
managerRef.current?.forceUpdate();
140+
} else {
141+
pendingForceUpdateRef.current = true;
142+
}
124143
}, []);
125144

126145
return {

packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const mockContextValue = (options: Partial<OverflowContextValue> = {}) =>
1111
itemVisibility: {},
1212
registerItem: jest.fn(),
1313
updateOverflow: jest.fn(),
14+
forceUpdateOverflow: jest.fn(),
1415
...options,
1516
} as OverflowContextValue);
1617

packages/react-components/react-overflow/library/src/useOverflowItem.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function useOverflowItem<TElement extends HTMLElement>(
2222
): React.RefObject<TElement | null> {
2323
const ref = React.useRef<TElement | null>(null);
2424
const registerItem = useOverflowContext(v => v.registerItem);
25+
const forceUpdateOverflow = useOverflowContext(v => v.forceUpdateOverflow);
2526

2627
useIsomorphicLayoutEffect(() => {
2728
if (process.env.NODE_ENV !== 'production') {
@@ -35,15 +36,20 @@ export function useOverflowItem<TElement extends HTMLElement>(
3536
}
3637

3738
if (ref.current) {
38-
return registerItem({
39+
const unregister = registerItem({
3940
element: ref.current,
4041
id,
4142
priority: priority ?? 0,
4243
groupId,
4344
pinned,
4445
});
46+
// Request first-paint correctness. Before the container observes this defers a synchronous
47+
// force to the container's observe effect; after, it recomputes for the (re)registered item.
48+
// An item hook that omits this call opts the container out of the synchronous first-paint pass.
49+
forceUpdateOverflow();
50+
return unregister;
4551
}
46-
}, [id, priority, registerItem, groupId, pinned]);
52+
}, [id, priority, registerItem, forceUpdateOverflow, groupId, pinned]);
4753

4854
return ref;
4955
}

0 commit comments

Comments
 (0)