-
Notifications
You must be signed in to change notification settings - Fork 663
Eliminate nested-update cascades in useFocus, PageLayout.Pane, and AnchoredOverlay #7864
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mattcosta7
wants to merge
1
commit into
main
Choose a base branch
from
cascade-elimination
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+309
−18
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| --- | ||
| '@primer/react': patch | ||
| --- | ||
|
|
||
| Eliminate nested-update cascades in `useFocus`, `PageLayout.Pane`, and | ||
| `AnchoredOverlay`: | ||
|
|
||
| - `useFocus` no longer produces a second re-render after focusing; one | ||
| `focus()` call now results in exactly one render instead of two. | ||
| - `PageLayout.Pane` (resizable) no longer triggers a forced re-render | ||
| before paint on mount. The CSS variable and ARIA attributes are still | ||
| updated synchronously in the layout effect; the React state sync is | ||
| wrapped in `startTransition` so it runs in the transition lane rather | ||
| than as part of the layout-effect commit. | ||
| - `AnchoredOverlay` no longer keeps `useAnchoredPosition`'s scroll | ||
| listeners and `ResizeObserver` attached while it is closed. After an | ||
| open→close cycle, the first scroll/resize event no longer fires a | ||
| spurious `setPosition(undefined)` that re-renders the closed overlay. | ||
|
|
||
| Also adds a profiler-based test harness at | ||
| `src/utils/testing/profiler.tsx` so future regressions can be pinned with | ||
| `counter.updateCount` and `counter.nestedUpdateCount` assertions. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 100 additions & 0 deletions
100
packages/react/src/internal/hooks/__tests__/useFocus.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import {act, render, screen} from '@testing-library/react' | ||
| import userEvent from '@testing-library/user-event' | ||
| import React from 'react' | ||
| import {describe, expect, it} from 'vitest' | ||
| import {useFocus} from '../useFocus' | ||
| import {createRenderCounter} from '../../../utils/testing/profiler' | ||
|
|
||
| describe('useFocus', () => { | ||
| it('focuses the element on the next commit', async () => { | ||
| function Demo() { | ||
| const focus = useFocus() | ||
| const [show, setShow] = React.useState(false) | ||
| const inputRef = React.useRef<HTMLInputElement>(null) | ||
| return ( | ||
| <> | ||
| {show ? <input ref={inputRef} data-testid="target" /> : null} | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setShow(true) | ||
| focus(inputRef) | ||
| }} | ||
| > | ||
| show | ||
| </button> | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| render(<Demo />) | ||
| await userEvent.click(screen.getByText('show')) | ||
| expect(screen.getByTestId('target')).toHaveFocus() | ||
| }) | ||
|
|
||
| it('does not cause a cascade — one focus call produces exactly one render', async () => { | ||
| // Renders are caused by: initial mount (1), and the single user click that | ||
| // sets visibility + calls focus() (2 — both setState calls are batched). | ||
| // The previous implementation produced an extra render because the effect | ||
| // reset focusTarget to null, triggering a second render. | ||
| const [Wrap, counter] = createRenderCounter() | ||
|
|
||
| function Demo() { | ||
| const focus = useFocus() | ||
| const [show, setShow] = React.useState(false) | ||
| const inputRef = React.useRef<HTMLInputElement>(null) | ||
| return ( | ||
| <Wrap> | ||
| <> | ||
| {show ? <input ref={inputRef} data-testid="target" /> : null} | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setShow(true) | ||
| focus(inputRef) | ||
| }} | ||
| > | ||
| show | ||
| </button> | ||
| </> | ||
| </Wrap> | ||
| ) | ||
| } | ||
|
|
||
| render(<Demo />) | ||
| counter.reset() | ||
|
|
||
| await userEvent.click(screen.getByText('show')) | ||
|
|
||
| // Exactly one render from the click — no cascade from useFocus's effect. | ||
| expect(counter.updateCount).toBe(1) | ||
| }) | ||
|
|
||
| it('host component is not re-rendered by focus() when version is stable', async () => { | ||
| // The hook should not cause the host component to render more than what | ||
| // the user's own setState triggers. We track function-body executions | ||
| // directly (rather than using <Profiler>) so we count this component | ||
| // specifically rather than any wrapper subtree. | ||
| let renderCount = 0 | ||
|
|
||
| let triggerFocus: (() => void) | null = null | ||
|
|
||
| function Demo() { | ||
| renderCount += 1 | ||
| const focus = useFocus() | ||
| const ref = React.useRef<HTMLInputElement>(null) | ||
| triggerFocus = () => focus(ref.current!) | ||
| return <input ref={ref} data-testid="target" /> | ||
| } | ||
|
|
||
| render(<Demo />) | ||
| expect(renderCount).toBe(1) // initial mount | ||
|
|
||
| await act(async () => { | ||
| triggerFocus!() | ||
| }) | ||
| // The host re-renders once because useFocus bumps its internal version state. | ||
| // It must NOT re-render a second time (which the old impl did when resetting state). | ||
| expect(renderCount).toBe(2) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,31 @@ | ||
| import {type RefObject, useEffect, useState} from 'react' | ||
| import {type RefObject, useCallback, useEffect, useRef, useState} from 'react' | ||
|
|
||
| /** | ||
| * Returns a function that focuses the given element on the next render commit. | ||
| * | ||
| * The target is stored in a ref (not state) and the effect is gated by a | ||
| * version counter, so calling `focus()` produces exactly one render — never | ||
| * the two-render cascade you'd get from `setState(target)` followed by | ||
| * `setState(null)` inside the effect. | ||
| */ | ||
| export function useFocus() { | ||
| const [focusTarget, setFocusTarget] = useState<HTMLElement | RefObject<HTMLElement> | null>(null) | ||
| const targetRef = useRef<HTMLElement | RefObject<HTMLElement> | null>(null) | ||
| const [version, setVersion] = useState(0) | ||
|
|
||
| useEffect(() => { | ||
| if (focusTarget === null) { | ||
| return | ||
| } | ||
| const target = targetRef.current | ||
| if (target === null) return | ||
| targetRef.current = null | ||
|
|
||
| if (focusTarget instanceof HTMLElement) { | ||
| focusTarget.focus() | ||
| if (target instanceof HTMLElement) { | ||
| target.focus() | ||
| } else { | ||
| focusTarget.current?.focus() | ||
| target.current?.focus() | ||
| } | ||
| }, [version]) | ||
|
|
||
| // eslint-disable-next-line react-hooks/set-state-in-effect | ||
| setFocusTarget(null) | ||
| }, [focusTarget]) | ||
|
|
||
| return setFocusTarget | ||
| return useCallback((target: HTMLElement | RefObject<HTMLElement>) => { | ||
| targetRef.current = target | ||
| setVersion(v => v + 1) | ||
| }, []) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import React, {Profiler, type ProfilerOnRenderCallback, type ReactElement} from 'react' | ||
|
|
||
| export type RenderCounter = { | ||
| /** Total number of times the wrapped subtree has rendered (mount + updates). */ | ||
| readonly count: number | ||
| /** Number of mount renders. */ | ||
| readonly mountCount: number | ||
| /** Number of update renders (any cause). */ | ||
| readonly updateCount: number | ||
| /** | ||
| * Number of updates triggered inside the same commit cycle that produced the | ||
| * original render — i.e., a `setState` call from inside a `useLayoutEffect` | ||
| * that forced a synchronous re-render before paint. This is the canonical | ||
| * "nested update" / cascade signal; assert `nestedUpdateCount === 0` to pin | ||
| * that a component never forces a double-commit. | ||
| */ | ||
| readonly nestedUpdateCount: number | ||
| /** Reset the counter. */ | ||
| reset(): void | ||
| } | ||
|
|
||
| /** | ||
| * Returns a `[Wrap, counter]` pair. Wrap the component under test with `Wrap` | ||
| * to count how many times its subtree renders. Useful for asserting that a | ||
| * single user interaction (or a stable prop) does not cause cascading renders. | ||
| * | ||
| * @example | ||
| * const [Wrap, counter] = createRenderCounter() | ||
| * render(<Wrap><MyComponent /></Wrap>) | ||
| * // ... interaction ... | ||
| * expect(counter.updateCount).toBe(1) | ||
| * expect(counter.nestedUpdateCount).toBe(0) | ||
| */ | ||
| export function createRenderCounter( | ||
| id = 'profiler-root', | ||
| ): [(props: {children: ReactElement | ReactElement[]}) => ReactElement, RenderCounter] { | ||
| const state = {count: 0, mountCount: 0, updateCount: 0, nestedUpdateCount: 0} | ||
|
mattcosta7 marked this conversation as resolved.
|
||
|
|
||
| const onRender: ProfilerOnRenderCallback = (_id, phase) => { | ||
| state.count += 1 | ||
| if (phase === 'mount') state.mountCount += 1 | ||
| else state.updateCount += 1 | ||
| if (phase === 'nested-update') state.nestedUpdateCount += 1 | ||
| } | ||
|
|
||
| const counter: RenderCounter = { | ||
| get count() { | ||
| return state.count | ||
| }, | ||
| get mountCount() { | ||
| return state.mountCount | ||
| }, | ||
| get updateCount() { | ||
| return state.updateCount | ||
| }, | ||
| get nestedUpdateCount() { | ||
| return state.nestedUpdateCount | ||
| }, | ||
| reset() { | ||
| state.count = 0 | ||
| state.mountCount = 0 | ||
| state.updateCount = 0 | ||
| state.nestedUpdateCount = 0 | ||
| }, | ||
| } | ||
|
|
||
| const Wrap = ({children}: {children: ReactElement | ReactElement[]}) => ( | ||
| <Profiler id={id} onRender={onRender}> | ||
| {children} | ||
| </Profiler> | ||
| ) | ||
|
|
||
| return [Wrap, counter] | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.