perf(UnderlinePanels): eliminate cascading renders on mount and resize#7876
Draft
mattcosta7 wants to merge 5 commits into
Draft
perf(UnderlinePanels): eliminate cascading renders on mount and resize#7876mattcosta7 wants to merge 5 commits into
mattcosta7 wants to merge 5 commits into
Conversation
The component used to mirror its children into two pieces of state via a
useEffect:
const [tabs, setTabs] = useState([])
const [tabPanels, setTabPanels] = useState([])
useEffect(() => { /* clone children → setTabs / setTabPanels */ }, deps)
That is React's documented anti-pattern for syncing props to state. On
mount it produced one empty-tablist render followed by a second render
with the real tabs after the effect ran, and every consumer rerender
went through the same two-commit cycle.
Replace with a useMemo that derives the cloned tabs and panels in
render. Behaviour is identical (same id assignment, same prop
overrides, same children walk), but consumers no longer see an
empty-tablist frame on mount and the component renders once per
prop/state change instead of twice.
listWidth was stored in useState even though no render path reads it — only the resize-observer callback does, to compare against the wrapper width and decide whether the icons fit. Storing in state forced an extra commit each time the value was set (mount via the layout effect, plus every list-width change). Move to a useRef. The layout effect writes ref.current; the resize observer reads ref.current. setIconsVisible is the only setState the resize path needs, and it only fires when the visibility decision actually flips.
iconsVisible (resize-driven) and loadingCounters were baked into each Tab element by cloneElement. Both deps had to live in the memo's deps array, so every iconsVisible flip re-cloned every Tab element and forced reconciliation of the entire tablist subtree. Move both flags to a new UnderlinePanelsContext. The cloneElement call now only assigns the generated id, and the memo deps shrink to [children, parentId]. Tabs read the flags from context, so they still react to iconsVisible toggles, but unrelated UnderlinePanels re-renders no longer cause Tab elements to lose referential stability. React.memo on the Tab implementation completes the picture: with referentially stable Tab elements + React.memo, an iconsVisible-driven re-render of UnderlinePanels updates only the context-consuming part of each Tab, not the surrounding subtree.
…o stability Three regression tests for the perf refactor in this branch: 1. Tabs render synchronously on mount — no empty-tablist frame between the initial commit and the (previously) useEffect-driven setState. 2. The host wrapping UnderlinePanels renders at most once on mount. Catches the props-in-state anti-pattern (mount → effect → setState → second render) if someone reintroduces it. 3. Re-rendering an ancestor for an unrelated reason does not re-render any Tab body. iconsVisible / loadingCounters flow via context, Tab itself is React.memo'd, and consumer-provided children references are stable — so memo should skip every Tab on an unrelated parent render. The test pokes the host three times and asserts zero extra Tab body renders. Includes a patch changeset.
🦋 Changeset detectedLatest commit: 61f3421 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
|
tabsHaveIcons was computed in render-body via tabs.some(...). The walk is
cheap (O(N), single-digit constant) but it ran on every render and lived
outside the cache that already produces tabs / tabPanels. Moving the
.some() inside that same useMemo means:
- tabsHaveIcons is now cached on [children, parentId], aligned with
the cache for the data it walks.
- One less O(N) iteration per render on the cache-hit path.
- The closures that depend on it (the listWidth layout effect and
the resize-observer callback) see exactly the value the memo
produced — no chance of skew between when tabs were partitioned
and when tabsHaveIcons was computed.
Pure refactor; behaviour identical. Existing tests cover it.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes #
Summary
Removes the cascading-render anti-patterns from
UnderlinePanels—specifically the one mount-after-effect cycle (props mirrored into state via
useEffect), the extra commit from storinglistWidthin state when nothingin render reads it, and the tablist-wide reconciliation that happened every
time
iconsVisibleflipped becausecloneElementbaked the flag into everyTabelement.Behavior is identical. The component now renders once on mount instead of
twice, never produces an empty-tablist frame, and resize-driven icon toggles
only update each
Tab's context-consuming subtree instead of forcing thewhole tablist through reconciliation.
What was wrong
packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsxonmainhad three independent sources of unnecessary renders:
1. Props mirrored in state via
useEffectThe React docs flag this exact pattern as an anti-pattern. Mount sequence:
tabs = [],tabPanels = []→ renders an empty tablist for one frame.setStates (batched into one commit).Every prop change re-ran the effect → another setState → another commit.
2.
listWidthstored in state but only read inside the resize callbackRender never reads
listWidth. The state existed solely to be read insidethe resize-observer callback. Each write was a wasted commit.
3.
iconsVisibleandloadingCountersbaked into every Tab viacloneElementBoth flags were in the effect's deps array, so the moment
iconsVisibleflipped (from the resize observer), the effect re-ran, every Tab element was
re-cloned with new props, and React reconciled fresh Tab elements across the
entire tablist. A consumer with 12 tabs would see all 12 Tab function bodies
re-execute on every resize that crossed the icon-fits threshold.
What this PR does
Four commits, each independently shippable and tests-green:
perf(UnderlinePanels): derive tabs and panels in render, not from stateReplaces the
useState+useEffectpair with a singleuseMemothat walkschildren, clones tabs/panels with their IDs, and returns the partitionedarrays. The empty-tablist mount frame is gone; consumer rerenders no longer
do the two-commit dance.
perf(UnderlinePanels): keep list width in a ref, not statelistWidthmoves fromuseStatetouseRef. The layout effect writesref.current; the resize-observer callback readsref.current. The onlystate setter left in the resize path is
setIconsVisible, which only fireswhen the decision actually flips.
perf(UnderlinePanels): pass iconsVisible / loadingCounters via contextIntroduces a new internal
UnderlinePanelsContextcarryingiconsVisibleand
loadingCounters. ThecloneElementcall now only assigns thegenerated
id, and the memo deps drop to[children, parentId]. Tabs readthe flags from context, so they still react to resize-driven toggles —
context propagation does the work, not element re-cloning.
React.memo(Tab)ties the bow: with referentially-stable Tab elements +memo, an
iconsVisibleflip updates only each Tab's context-consumingsubtree, not the surrounding tablist.
test(UnderlinePanels): pin no-empty-frame, bounded mount renders, memo stabilityThree new tests that pin every claim above:
renders tabs synchronously on mount (no empty-tablist frame)— runsrender(...), queries[role="tab"]immediately, expects 3 tabs. If afuture change reintroduces state-from-effect, the count is 0 on render
return and this trips.
bounds parent renders on initial mount— wrapsUnderlinePanelsin a render-counted host and asserts the host renders ≤ 1 time on mount.
does not re-render tabs when an unrelated re-render of the parent occurs—renders a stable
treereference, triggers three unrelated parentre-renders via
rerender(...), asserts the TabBody count inside Tabsstays at its mount-time value. Catches any future change that re-bakes
iconsVisible/loadingCountersinto Tab via cloneElement or drops theReact.memowrapper.Plus a patch changeset.
Profiles
listWidthsetStateiconsVisibleflipChangelog
New
UnderlinePanelsContextcarryingiconsVisibleandloadingCountersfor the Tab component.Changed
UnderlinePanelsderives tabs/panels in render (useMemo) instead ofsyncing props into state via
useEffect.UnderlinePanels.Tabis wrapped inReact.memoand readsiconsVisible/loadingCountersfrom context instead of viacloneElement-injected props.listWidthis kept in a ref instead of state.Removed
setListWidthstate setter (its value lives in a ref now).Rollout strategy
UnderlinePanelsisexperimental. Public API is unchanged; consumers getthe perf win automatically.
Testing & Reviewing
Reviewer focus:
useMemodeps are[children, parentId]—iconsVisibleandloadingCountersare no longer in there. Verify this is correct: thoseflags reach Tab via context, so the memo doesn't need to invalidate when
they change.
React.memo(Tab)cast:Tab.__SLOT__andTab.displayNamearestill set correctly on the memo wrapper so
isSlot(child, Tab)andchild.type === Tabkeep working through cloneElement.does not re-render tabs when an unrelated re-render of the parent occurs) relies on Tab having referentiallystable React element identity across the host's
rerender(...)—verify the
treeconstant pattern in the test models real consumerusage.
Merge checklist