Skip to content

perf(components): keep the props context stable when a UI component re-renders - #3579

Merged
mfal merged 6 commits into
nextfrom
perf/stable-props-context
Oct 27, 2026
Merged

mfal merged 6 commits into
nextfrom
perf/stable-props-context

Conversation

@mfal

@mfal mfal commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

A re-render of a Flow UI component no longer re-renders every Flow component below it. Four changes:

  1. PropsContextProvider updates its value when its props change (fix). Until now it recomputed only on a new parent context or a changed dependencies entry. 71 of 87 providers in Flow pass no dependencies. 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. dependencies still works but is no longer needed for an inline props object.
  2. ClearPropsContext provides one constant empty context. flowComponent wraps the inside of every UI component in it, and it provided a new {} on each render. Every PropsContextProvider below recomputed, and every Flow component below re-rendered with it, also inside memoized subtrees such as list items with renderDependencies.
  3. TunnelProvider memoizes its context value. A new { state, parentContext } on each render re-rendered every TunnelExit and TunnelEntry below. UiComponentTunnelProvider adds one per UI and layout component, so this hits the same subtrees.
  4. Plain objects in props are compared by content. Props contexts hold plain objects created during render, such as tunnel: { id, component } in List or a dynamic(...) 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 generic isPlainObjectDeepEqual in lib/object; remeda's isDeepEqual does 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's isDisabled on the inner FileField.

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:

Refetch before + 1, 2, 4 + 3 runs
invoice table (160 rows), only the customer queries 122 ms 68 ms 36 ms 14
domain list (185 items), only the list query 833 ms 602 ms 525 ms 10
domain list (185 items), all 500 queries of the page 1092 ms 893 ms 770 ms 8

The effect is not limited to lists: every component below a re-rendering UI component profits, e.g. the content of a Modal or a Section whose parent renders.

Not covered: in the domain list most of the remaining time comes from ListItemView and ListItemViewContent. Both rebuild a props context on each render that holds inline dynamic(...) functions and a wrapWith element, 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:
    • re-rendering a UI component with a new prop does not render the implementation of a Flow component below it again (2 renders without the change, 1 with it).
    • a UI component whose props context holds a plain object written inline does not render the Flow components below it again.
  • Tunnel.browser.test.tsx: re-rendering a TunnelProvider does not render its TunnelExit again.
  • 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 without dependencies. The old cases asserted that a changed prop stays stale while dependencies is 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 on next and fails with 2 but without 1.

Each browser test above except the FileDropZone one fails without its change. The existing FieldError × FileDropZone tests cover nested props that change through dependencies (aria-describedby).

🤖 Generated with Claude Code

mfal and others added 5 commits October 26, 2026 10:44
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>
@mfal
mfal requested a review from a team October 26, 2026 12:40
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

Type URL
docs pr-3579.docs.review.flow-components.de
storybook pr-3579.storybook.review.flow-components.de

Images:

  • docs: ghcr.io/mittwald/flow/docs:pr-3579
  • storybook: ghcr.io/mittwald/flow/storybook:pr-3579

…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
mfal enabled auto-merge October 27, 2026 09:30
@mfal
mfal merged commit 2b04ca3 into next Oct 27, 2026
26 of 27 checks passed
@mfal
mfal deleted the perf/stable-props-context branch October 27, 2026 09:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants