diff --git a/.changeset/defer-anchored-position-mount.md b/.changeset/defer-anchored-position-mount.md new file mode 100644 index 00000000000..23d047f72ee --- /dev/null +++ b/.changeset/defer-anchored-position-mount.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Defer `useAnchoredPosition` initial mount setState from useLayoutEffect to useEffect when overlay is closed, eliminating unnecessary cascading re-renders that block paint. diff --git a/packages/react/src/hooks/__tests__/useAnchoredPosition.test.tsx b/packages/react/src/hooks/__tests__/useAnchoredPosition.test.tsx index f822966f5a8..98a4d58fc2e 100644 --- a/packages/react/src/hooks/__tests__/useAnchoredPosition.test.tsx +++ b/packages/react/src/hooks/__tests__/useAnchoredPosition.test.tsx @@ -35,6 +35,47 @@ it('should should return a position', async () => { }) }) +it('should defer initial updatePosition to useEffect when overlay is closed on mount', async () => { + // When no floating element is present (overlay closed), the initial + // updatePosition call should be deferred from useLayoutEffect to useEffect. + // We verify this by checking that onPositionChange has NOT been called by + // the time the component's own useLayoutEffect runs (which fires after the + // hook's useLayoutEffect in declaration order). + const onPositionChange = vi.fn() + const layoutPhaseCheck = vi.fn() + + const ClosedOverlayComponent = ({ + onPositionChangeProp, + onLayoutEffect, + }: { + onPositionChangeProp: typeof onPositionChange + onLayoutEffect: (calledDuringLayout: boolean) => void + }) => { + const floatingElementRef = React.useRef(null) + const anchorElementRef = React.useRef(null) + useAnchoredPosition({floatingElementRef, anchorElementRef, onPositionChange: onPositionChangeProp}) + + // This layout effect runs after the hook's layout effects (declaration order). + // With the fix, onPositionChange should NOT have been called yet because + // the initial updatePosition is deferred to useEffect. + React.useLayoutEffect(() => { + onLayoutEffect(onPositionChangeProp.mock.calls.length > 0) + }, [onPositionChangeProp, onLayoutEffect]) + + return
+ } + + render() + + // onPositionChange should not have fired during the layout phase + expect(layoutPhaseCheck).toHaveBeenCalledWith(false) + + // After effects run, onPositionChange should have been called with undefined + await waitFor(() => { + expect(onPositionChange).toHaveBeenCalledWith(undefined) + }) +}) + describe('scroll recalculation', () => { it('should recalculate position when window scrolls', async () => { const cb = vi.fn() diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index b9ab4fcfb0e..c0a98dbb18a 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -116,7 +116,25 @@ export function useAnchoredPosition( savedOnPositionChange.current = settings?.onPositionChange }, [settings?.onPositionChange]) - useLayoutEffect(updatePosition, [updatePosition]) + // Defer the first updatePosition to useEffect when the overlay is closed on + // mount, avoiding paint-blocking cascading setState. If the overlay is already + // open on mount, run synchronously in useLayoutEffect to prevent a flash. + // After mount (including Suspense reappear), only call updatePosition when + // both refs are attached — skipping closed overlays avoids unnecessary setState. + const hasMountedRef = React.useRef(false) + useLayoutEffect(() => { + if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { + hasMountedRef.current = true + updatePosition() + } + }, [updatePosition, floatingElementRef, anchorElementRef]) + + React.useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true + updatePosition() + } + }, [updatePosition]) useResizeObserver(updatePosition) // watches for changes in window size useResizeObserver(updatePosition, floatingElementRef as React.RefObject) // watches for changes in floating element size