Skip to content

fix(react-context-selector): avoid useState eager-bailout pitfall#36002

Merged
layershifter merged 6 commits intomasterfrom
layershifter/fix-context-selector-bailout
Apr 23, 2026
Merged

fix(react-context-selector): avoid useState eager-bailout pitfall#36002
layershifter merged 6 commits intomasterfrom
layershifter/fix-context-selector-bailout

Conversation

@layershifter
Copy link
Copy Markdown
Member

@layershifter layershifter commented Apr 18, 2026

Summary

Rewrites @fluentui/react-context-selector's useContextSelector hook so it no longer depends on React's useState - eager-bailout fast path.

The previous implementation works correctly for the first listener-driven render of any memoized consumer, but silently degrades for every subsequent one - producing wasted render passes that React later discards via bailoutOnAlreadyFinishedWork.

Note
👆 the mentioned repro seems to be specific to React 18. React 19 is not affected under the same conditions.

Anyway, it's not safe to rely on React for bail-out behavior as previously it was removed from useReducer(), #30951.

Why

See a complete explainer in #36001.

The fix

The new hook avoids the eager-bailout dependency entirely:

  • Returns selector(valueRef.current) directly during render (same as useSyncExternalStore's getSnapshot — the only read of valueRef.current during render).
  • Uses useReducer(x => x + 1) as an opaque force-update counter. The reducer queue is never walked by the eager-bailout codepath because it's invoked through dispatch() with no action.
  • Listener compares selector(payload) against lastReturnedRef.current and calls forceUpdate() only when the selected slice actually changed. No setState(prev => prev) trick is used, so fiber.lanes is never polluted with bailed-out updates.
  • Layout-effect fixup (per Relay's useFragmentInternal) catches updates that occurred between render and effect run.

Tearing behavior is unchanged from main — still Level 1 per the original RFC's accepted tradeoff.

Measurements

React 18.3.1, StrictMode off. Render counts per item over a fixed click sequence.

Memoized ListItems (matches RFC "Scenario 2")

main (this PR base) this PR
set 1 (4→1), item 1 renders 4 3
set 2 (1→2) 2nd, item 2 5 4
set 3 (2→3) 2nd, item 3 5 4

Eliminates the "+2 on 2nd activation" anomaly the RFC flagged as "some kind of issue with the useState() bailout mechanism".

Plain (non-memoized) ListItems

main this PR
cycling set 1 (4→1), any item 7 7
transition, any item 8–7 7

No regression. Both main and this PR re-render plain items once from parent cascade and again from listener-driven heal (Level 1 tearing behavior retained).

Parent tick (Provider value unchanged)

main this PR
Memo items, per tick 0 0
Plain items, per tick +1 +1

Identical. Provider-value-unchanged re-renders skip memoized descendants regardless.

Tearing (parent-prop-vs-context)

main:    9 tears / 10 clicks
this PR: 9 tears / 10 clicks

Level 1 tearing preserved — explicit non-goal per the original RFC.

Not covered

  • Tearing on parent-driven updates (Level 1). Existing behavior preserved; fix is out of scope here.

…r-bailout pitfall

The previous implementation depends on React's useState eager-bailout fast
path in dispatchSetStateInternal:

  if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
    // eager bailout — drop the update without scheduling a render
  }

The fiber passed is the one bound at mount. After the first listener-driven
render commits, that fiber becomes the alternate of the new current — but its
.lanes is never cleared from the enqueueUpdate that preceded the render.

From that point on, every subsequent listener dispatch fails the NoLanes
precondition. Updates are queued normally, schedule a render, and the reducer
runs in-render only to return prevState. React then calls
bailoutOnAlreadyFinishedWork — the DOM doesn't update, but the component
function already executed. This is the "Glitchy Behavior" documented in the
context-selector-tearing RFC.

New implementation:
- Reads valueRef.current during render (like useSyncExternalStore's getSnapshot).
- No [value, selected] state tuple. No setState(prev => prev) trick.
- useReducer(x => x + 1) as opaque force-update counter.
- Listener compares selector(newValue) against lastReturnedRef and forces only
  when the slice actually differs.
- Layout-effect fixup per Relay's useFragmentInternal pattern.

Preserves: public API, listener payload shape ([version, value]),
useHasParentContext behavior, Level 1 tearing behavior.

See RFC PR for the full Option D writeup.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 18, 2026

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-accordion
Accordion (including children components)
103.546 kB
31.361 kB
103.397 kB
31.296 kB
-149 B
-65 B
react-avatar
AvatarGroup
17.525 kB
7.018 kB
17.468 kB
6.999 kB
-57 B
-19 B
react-avatar
AvatarGroupItem
61.88 kB
19.405 kB
61.513 kB
19.251 kB
-367 B
-154 B
react-charts
AreaChart
413.179 kB
126.715 kB
412.997 kB
126.642 kB
-182 B
-73 B
react-charts
DeclarativeChart
763.941 kB
220.708 kB
763.759 kB
220.641 kB
-182 B
-67 B
react-charts
DonutChart
323.598 kB
97.203 kB
323.416 kB
97.131 kB
-182 B
-72 B
react-charts
FunnelChart
315.145 kB
94.214 kB
314.963 kB
94.145 kB
-182 B
-69 B
react-charts
GanttChart
396.287 kB
120.229 kB
396.105 kB
120.154 kB
-182 B
-75 B
react-charts
GaugeChart
323.031 kB
96.624 kB
322.849 kB
96.554 kB
-182 B
-70 B
react-charts
GroupedVerticalBarChart
404.162 kB
122.801 kB
403.98 kB
122.722 kB
-182 B
-79 B
react-charts
HeatMapChart
398.359 kB
122.12 kB
398.177 kB
122.047 kB
-182 B
-73 B
react-charts
HorizontalBarChart
303.328 kB
89.375 kB
303.146 kB
89.291 kB
-182 B
-84 B
react-charts
Legends
243.069 kB
71.827 kB
242.887 kB
71.756 kB
-182 B
-71 B
react-charts
LineChart
424.519 kB
128.787 kB
424.337 kB
128.724 kB
-182 B
-63 B
react-charts
PolarChart
352.167 kB
107.633 kB
351.985 kB
107.563 kB
-182 B
-70 B
react-charts
SankeyChart
221.013 kB
68.074 kB
220.864 kB
67.971 kB
-149 B
-103 B
react-charts
ScatterChart
403.901 kB
122.916 kB
403.719 kB
122.848 kB
-182 B
-68 B
react-charts
VerticalBarChart
440.631 kB
128.396 kB
440.449 kB
128.312 kB
-182 B
-84 B
react-charts
VerticalStackedBarChart
410.173 kB
124.305 kB
409.991 kB
124.241 kB
-182 B
-64 B
react-color-picker
ColorArea
47.556 kB
16.703 kB
47.407 kB
16.636 kB
-149 B
-67 B
react-color-picker
ColorPicker
16.214 kB
6.542 kB
16.157 kB
6.526 kB
-57 B
-16 B
react-color-picker
ColorSlider
39.73 kB
14.746 kB
39.577 kB
14.684 kB
-153 B
-62 B
react-combobox
Combobox (including child components)
106.891 kB
34.646 kB
106.709 kB
34.573 kB
-182 B
-73 B
react-combobox
Dropdown (including child components)
106.661 kB
34.384 kB
106.479 kB
34.32 kB
-182 B
-64 B
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
237.472 kB
68.929 kB
237.29 kB
68.851 kB
-182 B
-78 B
react-components
react-components: entire library
1.302 MB
325.417 kB
1.302 MB
325.338 kB
-182 B
-79 B
react-dialog
Dialog (including children components)
102.176 kB
30.389 kB
101.984 kB
30.317 kB
-192 B
-72 B
react-headless-components-preview
react-headless-components-preview: entire library
74.611 kB
21.728 kB
74.462 kB
21.666 kB
-149 B
-62 B
react-list
List
87.155 kB
25.773 kB
87.098 kB
25.756 kB
-57 B
-17 B
react-list
ListItem
111.036 kB
32.683 kB
110.887 kB
32.626 kB
-149 B
-57 B
react-menu
Menu (including children components)
170.91 kB
52.06 kB
170.728 kB
51.986 kB
-182 B
-74 B
react-menu
Menu (including selectable components)
174.088 kB
52.647 kB
173.906 kB
52.574 kB
-182 B
-73 B
react-overflow
hooks only
12.117 kB
4.627 kB
11.966 kB
4.565 kB
-151 B
-62 B
react-popover
Popover
134.299 kB
41.601 kB
134.15 kB
41.538 kB
-149 B
-63 B
react-swatch-picker
@fluentui/react-swatch-picker - package
104.356 kB
29.956 kB
104.207 kB
29.897 kB
-149 B
-59 B
react-table
DataGrid
159.836 kB
45.028 kB
159.681 kB
44.965 kB
-155 B
-63 B
react-tag-picker
@fluentui/react-tag-picker - package
187.196 kB
55.98 kB
187.014 kB
55.911 kB
-182 B
-69 B
react-teaching-popover
TeachingPopover
112.771 kB
34.321 kB
112.714 kB
34.302 kB
-57 B
-19 B
react-timepicker-compat
TimePicker
109.856 kB
36.253 kB
109.674 kB
36.193 kB
-182 B
-60 B
react-tree
FlatTree
148.205 kB
42.256 kB
148.056 kB
42.19 kB
-149 B
-66 B
react-tree
PersonaFlatTree
150.033 kB
42.629 kB
149.884 kB
42.567 kB
-149 B
-62 B
react-tree
PersonaTree
146.093 kB
41.446 kB
145.944 kB
41.374 kB
-149 B
-72 B
react-tree
Tree
144.271 kB
41.075 kB
144.122 kB
41.003 kB
-149 B
-72 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
global-context
createContext
510 B
328 B
global-context
createContextSelector
531 B
335 B
react-avatar
Avatar
48.492 kB
15.379 kB
react-breadcrumb
@fluentui/react-breadcrumb - package
115.18 kB
31.479 kB
react-charts
HorizontalBarChartWithAxis
63 B
83 B
react-charts
Sparkline
91.4 kB
28.708 kB
react-checkbox
Checkbox
33.718 kB
11.424 kB
react-components
react-components: Button, FluentProvider & webLightTheme
70.415 kB
19.963 kB
react-components
react-components: FluentProvider & webLightTheme
43.63 kB
14.026 kB
react-datepicker-compat
DatePicker Compat
225.645 kB
63.659 kB
react-field
Field
22.411 kB
8.393 kB
react-input
Input
26.298 kB
8.705 kB
react-persona
Persona
55.447 kB
17.311 kB
react-portal-compat
PortalCompatProvider
8.386 kB
2.624 kB
react-progress
ProgressBar
20.23 kB
7.866 kB
react-radio
Radio
31.105 kB
9.66 kB
react-radio
RadioGroup
14.053 kB
5.703 kB
react-select
Select
26.183 kB
9.474 kB
react-slider
Slider
36.377 kB
12.034 kB
react-spinbutton
SpinButton
33.822 kB
11.128 kB
react-switch
Switch
36.351 kB
11.07 kB
react-table
Table (Primitives only)
41.015 kB
13.174 kB
react-table
Table as DataGrid
131.023 kB
36.014 kB
react-table
Table (Selection only)
69.409 kB
19.408 kB
react-table
Table (Sort only)
68.052 kB
19.026 kB
react-tags
InteractionTag
13.742 kB
5.473 kB
react-tags
Tag
29.666 kB
9.433 kB
react-tags
TagGroup
82.265 kB
24.156 kB
react-textarea
Textarea
24.686 kB
8.972 kB
🤖 This report was generated against 13bcf75018ad4fa0b2f1b6e2de42d2cfcbdfaf6c

@github-actions
Copy link
Copy Markdown

Pull request demo site: URL

…ive-deps

The react-compiler rule errors when react-hooks rules are disabled, and
exhaustive-deps doesn't actually complain about the effect's deps (both
listeners and valueRef are referentially stable context fields).
…ssion tests

Addressing code review feedback on #36002:

1. [BLOCKER] Restore try/catch around selector calls in both the listener
   and the effect-fixup. The previous implementation wrapped the selector in
   a try/catch with the comment 'ignored (stale props or some other reason)'.
   The new hook dropped that guard; since the provider iterates listeners
   with Array.prototype.forEach which aborts on throw, any consumer whose
   selector throws on an intermediate payload would silently starve all
   later subscribers of that update. Restoring the guard preserves the
   prior isolation contract.

2. Add an inline comment explaining why the new hook destructures only
   { value, listeners } from the context and ignores the version field.
   The version is still maintained by the provider and consulted by
   useHasParentContext as a parent-presence sentinel; it is no longer
   needed as a staleness guard because the provider fires listeners
   synchronously inside useIsomorphicLayoutEffect, and freshness is
   guaranteed by the effect-fixup.

3. Add regression tests:
   - 'memoized consumers re-render only when their selected slice changes':
     the RFC's Scenario 2 with React.memo'd items, asserting per-item
     onUpdate call counts across a cycling sequence. Catches the
     'three items re-rendered instead of two' glitch if it regresses.
   - 'a single consumer throw does not starve later subscribers of
     context updates': exercises the listener error-isolation path
     restored above.

StrictMode sanity-checked locally: layout-effect mount → cleanup → mount
in dev produces idempotent ref re-assignments and no extra forceUpdate()
calls — render counts scale by React's dev-mode 2x factor as expected,
with no pathological churn.
@layershifter layershifter force-pushed the layershifter/fix-context-selector-bailout branch from 8eed133 to 3cc3c8a Compare April 20, 2026 09:21
@layershifter layershifter marked this pull request as ready for review April 20, 2026 09:33
@layershifter layershifter requested a review from a team as a code owner April 20, 2026 09:33
Copy link
Copy Markdown
Member

@miroslavstastny miroslavstastny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in TMP that the change fixes the additional render originally caused by a no-op setState in layout effect.

Comment thread packages/react-components/react-context-selector/src/useContextSelector.test.tsx Outdated
layershifter and others added 2 commits April 23, 2026 12:25
@layershifter layershifter enabled auto-merge (squash) April 23, 2026 10:29
@layershifter layershifter merged commit faf6311 into master Apr 23, 2026
15 checks passed
@layershifter layershifter deleted the layershifter/fix-context-selector-bailout branch April 23, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants