Repository navigation
perf(components): keep the props context stable when a UI component re-renders - #3579
Merged
Merged
Conversation
The provider recomputed its value only when the parent context or a `dependencies` entry changed. A changed prop without a matching dependency stayed stale. 71 of 87 providers in Flow pass no `dependencies`; they kept working only because `ClearPropsContext` handed every UI component a new parent context on each render. The provider now compares its props with the previous ones: component levels recursively, prop values by identity. Equal props keep the value, so a props object written inline no longer needs `dependencies`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-renders
`flowComponent` wraps the inside of every UI component in
`ClearPropsContext`, which provided a new `{}` on each render. Every
`PropsContextProvider` below recomputed its value, and every Flow component
below re-rendered with it, also inside memoized subtrees such as list items.
`ClearPropsContext` now provides one constant empty context.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The provider passed a new `{ state, parentContext }` object on each render,
so every `TunnelExit` and `TunnelEntry` below re-rendered with it. The value
now changes only with `state` or `parentContext`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Props contexts hold plain objects created during render, such as a
`tunnel: { id, component }` or a `dynamic(...)` prop. Compared by identity,
each of them made the provider recompute on every render of its owner, and
everything below re-rendered with it. Plain objects are now compared
recursively. Functions, elements and ref objects stay compared by identity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
🚀 Preview DeploymentPreview environments are ready:
Images:
|
…DeepEqual The comparison is not specific to props contexts. Component levels are plain objects too, so the special case for component names goes away: `isPlainObjectDeepEqual` compares plain objects recursively and every other value by identity. It lives in `lib/object` for other callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mfal
enabled auto-merge
October 27, 2026 09:30
Lisa18289
approved these changes
Oct 27, 2026
This was referenced Oct 27, 2026
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.
A re-render of a Flow UI component no longer re-renders every Flow component below it. Four changes:
PropsContextProviderupdates its value when its props change (fix). Until now it recomputed only on a new parent context or a changeddependenciesentry. 71 of 87 providers in Flow pass nodependencies. They only worked because every UI component got a new parent context on each render (see 2). The provider now compares its props with the previous ones and keeps the value while they are equal.dependenciesstill works but is no longer needed for an inline props object.ClearPropsContextprovides one constant empty context.flowComponentwraps the inside of every UI component in it, and it provided a new{}on each render. EveryPropsContextProviderbelow recomputed, and every Flow component below re-rendered with it, also inside memoized subtrees such as list items withrenderDependencies.TunnelProvidermemoizes its context value. A new{ state, parentContext }on each render re-rendered everyTunnelExitandTunnelEntrybelow.UiComponentTunnelProvideradds one per UI and layout component, so this hits the same subtrees.tunnel: { id, component }inListor adynamic(...)prop. Compared by identity, each of them recomputed the provider on every render of its owner. Functions, arrays, class instances, elements and ref objects stay compared by identity. The comparison is the genericisPlainObjectDeepEqualinlib/object; remeda'sisDeepEqualdoes not fit, since it descends into React elements and into DOM nodes behind refs, and from both into the fiber tree.1 must land with 2. Without it, 2 freezes props such as
FileDropZone'sisDisabledon the innerFileField.A provider whose props hold a function or element created during render still recomputes on each render of its owner. That is correct, and the cascade stops only where props are equal.
Related: #3528, #3575
Effect
Measured in mStudio, dev build, with #3575 applied. React Profiler around the page, render time summed over all commits of one refetch, median:
The effect is not limited to lists: every component below a re-rendering UI component profits, e.g. the content of a
Modalor aSectionwhose parent renders.Not covered: in the domain list most of the remaining time comes from
ListItemViewandListItemViewContent. Both rebuild a props context on each render that holds inlinedynamic(...)functions and awrapWithelement, although all of it is static. Every item's children therefore still recompute when the item renders. Moving these contexts to module scope is a separate change.Tests
flowComponent.browser.test.tsx:Tunnel.browser.test.tsx: re-rendering aTunnelProviderdoes not render itsTunnelExitagain.useProps.browser.test.tsx: the "Context memoization" cases now follow the new contract. Equal props render once, changed props render again with the new value, also withoutdependencies. The old cases asserted that a changed prop stays stale whiledependenciesis unchanged.isPlainObjectDeepEqual.test.tsx: plain objects, also without prototype, are compared recursively; functions, arrays, class instances, elements and refs by identity.FileDropZone.browser.test.tsx: disabling a rendered zone disables its inner file field. It passes onnextand fails with 2 but without 1.Each browser test above except the
FileDropZoneone fails without its change. The existingFieldError×FileDropZonetests cover nested props that change throughdependencies(aria-describedby).🤖 Generated with Claude Code