Skip to content

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

Open
layershifter wants to merge 4 commits intomasterfrom
layershifter/fix-context-selector-bailout
Open

fix(react-context-selector): avoid useState eager-bailout pitfall#36002
layershifter wants to merge 4 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.

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.515 kB
31.358 kB
103.366 kB
31.294 kB
-149 B
-64 B
react-avatar
AvatarGroup
17.482 kB
7.002 kB
17.425 kB
6.983 kB
-57 B
-19 B
react-avatar
AvatarGroupItem
61.867 kB
19.384 kB
61.5 kB
19.233 kB
-367 B
-151 B
react-charts
AreaChart
412.925 kB
126.644 kB
412.743 kB
126.575 kB
-182 B
-69 B
react-charts
DeclarativeChart
763.687 kB
220.664 kB
763.505 kB
220.598 kB
-182 B
-66 B
react-charts
DonutChart
323.344 kB
97.162 kB
323.162 kB
97.088 kB
-182 B
-74 B
react-charts
FunnelChart
314.891 kB
94.176 kB
314.709 kB
94.104 kB
-182 B
-72 B
react-charts
GanttChart
396.033 kB
120.15 kB
395.851 kB
120.076 kB
-182 B
-74 B
react-charts
GaugeChart
322.777 kB
96.591 kB
322.595 kB
96.521 kB
-182 B
-70 B
react-charts
GroupedVerticalBarChart
403.908 kB
122.719 kB
403.726 kB
122.642 kB
-182 B
-77 B
react-charts
HeatMapChart
398.105 kB
122.05 kB
397.923 kB
121.976 kB
-182 B
-74 B
react-charts
HorizontalBarChart
303.074 kB
89.306 kB
302.892 kB
89.225 kB
-182 B
-81 B
react-charts
Legends
242.965 kB
71.798 kB
242.783 kB
71.725 kB
-182 B
-73 B
react-charts
LineChart
424.265 kB
128.706 kB
424.083 kB
128.644 kB
-182 B
-62 B
react-charts
PolarChart
351.913 kB
107.57 kB
351.731 kB
107.5 kB
-182 B
-70 B
react-charts
SankeyChart
220.845 kB
68 kB
220.696 kB
67.929 kB
-149 B
-71 B
react-charts
ScatterChart
403.647 kB
122.833 kB
403.465 kB
122.768 kB
-182 B
-65 B
react-charts
VerticalBarChart
440.377 kB
128.322 kB
440.195 kB
128.242 kB
-182 B
-80 B
react-charts
VerticalStackedBarChart
409.919 kB
124.239 kB
409.737 kB
124.176 kB
-182 B
-63 B
react-color-picker
ColorArea
47.538 kB
16.698 kB
47.389 kB
16.631 kB
-149 B
-67 B
react-color-picker
ColorPicker
16.169 kB
6.523 kB
16.112 kB
6.508 kB
-57 B
-15 B
react-color-picker
ColorSlider
39.712 kB
14.744 kB
39.559 kB
14.679 kB
-153 B
-65 B
react-combobox
Combobox (including child components)
106.012 kB
34.448 kB
105.83 kB
34.373 kB
-182 B
-75 B
react-combobox
Dropdown (including child components)
106.643 kB
34.38 kB
106.461 kB
34.316 kB
-182 B
-64 B
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
237.187 kB
68.891 kB
237.005 kB
68.813 kB
-182 B
-78 B
react-components
react-components: entire library
1.3 MB
324.941 kB
1.3 MB
324.869 kB
-182 B
-72 B
react-dialog
Dialog (including children components)
102.117 kB
30.367 kB
101.925 kB
30.294 kB
-192 B
-73 B
react-headless-components-preview
react-headless-components-preview: entire library
64.854 kB
19.302 kB
64.705 kB
19.239 kB
-149 B
-63 B
react-list
List
87.11 kB
25.762 kB
87.053 kB
25.745 kB
-57 B
-17 B
react-list
ListItem
111.018 kB
32.682 kB
110.869 kB
32.624 kB
-149 B
-58 B
react-menu
Menu (including children components)
170.806 kB
52.028 kB
170.624 kB
51.955 kB
-182 B
-73 B
react-menu
Menu (including selectable components)
173.984 kB
52.61 kB
173.802 kB
52.536 kB
-182 B
-74 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.131 kB
41.552 kB
133.982 kB
41.488 kB
-149 B
-64 B
react-swatch-picker
@fluentui/react-swatch-picker - package
104.305 kB
29.944 kB
104.156 kB
29.884 kB
-149 B
-60 B
react-table
DataGrid
159.783 kB
45.012 kB
159.628 kB
44.948 kB
-155 B
-64 B
react-tag-picker
@fluentui/react-tag-picker - package
187.178 kB
55.977 kB
186.996 kB
55.908 kB
-182 B
-69 B
react-teaching-popover
TeachingPopover
112.414 kB
34.219 kB
112.357 kB
34.2 kB
-57 B
-19 B
react-timepicker-compat
TimePicker
108.977 kB
36.038 kB
108.795 kB
35.976 kB
-182 B
-62 B
react-tree
FlatTree
148.099 kB
42.211 kB
147.95 kB
42.146 kB
-149 B
-65 B
react-tree
PersonaFlatTree
149.927 kB
42.585 kB
149.778 kB
42.523 kB
-149 B
-62 B
react-tree
PersonaTree
145.987 kB
41.411 kB
145.838 kB
41.34 kB
-149 B
-71 B
react-tree
Tree
144.165 kB
41.037 kB
144.016 kB
40.964 kB
-149 B
-73 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.479 kB
15.359 kB
react-breadcrumb
@fluentui/react-breadcrumb - package
115.162 kB
31.475 kB
react-charts
HorizontalBarChartWithAxis
63 B
83 B
react-charts
Sparkline
91.4 kB
28.708 kB
react-checkbox
Checkbox
33.7 kB
11.421 kB
react-components
react-components: Button, FluentProvider & webLightTheme
70.397 kB
19.96 kB
react-components
react-components: FluentProvider & webLightTheme
43.612 kB
14.022 kB
react-datepicker-compat
DatePicker Compat
225.627 kB
63.656 kB
react-field
Field
22.393 kB
8.39 kB
react-input
Input
26.28 kB
8.702 kB
react-persona
Persona
55.434 kB
17.299 kB
react-portal-compat
PortalCompatProvider
8.386 kB
2.624 kB
react-progress
ProgressBar
20.212 kB
7.863 kB
react-radio
Radio
31.087 kB
9.656 kB
react-radio
RadioGroup
14.035 kB
5.7 kB
react-select
Select
26.165 kB
9.472 kB
react-slider
Slider
36.359 kB
12.03 kB
react-spinbutton
SpinButton
33.804 kB
11.125 kB
react-switch
Switch
36.333 kB
11.067 kB
react-table
Table (Primitives only)
40.997 kB
13.172 kB
react-table
Table as DataGrid
131.005 kB
36.012 kB
react-table
Table (Selection only)
69.391 kB
19.404 kB
react-table
Table (Sort only)
68.034 kB
19.022 kB
react-tags
InteractionTag
13.724 kB
5.47 kB
react-tags
Tag
29.648 kB
9.429 kB
react-tags
TagGroup
82.247 kB
24.152 kB
react-textarea
Textarea
24.668 kB
8.969 kB
🤖 This report was generated against 3d2d9bf0e07e05f2320e5400efe6c01759426534

@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
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.

1 participant