Fix context propagation through suspended Suspense boundaries (#35839)
When a Suspense boundary suspends during initial mount, the primary children's fibers are discarded because there is no current tree to preserve them. If the suspended promise never resolves, the only way to retry is something external like a context change. However, lazy context propagation could not find the consumer fibers — they no longer exist in the tree — so the Suspense boundary was never marked for retry and remained stuck in fallback state indefinitely. The fix teaches context propagation to conservatively mark suspended Suspense boundaries for retry when a parent context changes, even when the consumer fibers can't be found. This matches the existing conservative approach used for dehydrated (SSR) Suspense boundaries.
Andrew Clark committed
Feb 20, 2026 at 22:03 UTC
ab18f33d46171ed1963ae1ac955c5110bb1eb199
3 files changed
+155
-5
packages/react-reconciler/src/ReactFiberBeginWork.js
+15
-1
@@ -3991,9 +3991,23 @@ function attemptEarlyBailoutIfNoScheduledUpdate(
3991
// whether to retry the primary children, or to skip over it and
3992
// go straight to the fallback. Check the priority of the primary
3993
// child fragment.
3994
+ //
3995
+ // Propagate context changes first. If a parent context changed
3996
+ // and the primary children's consumer fibers were discarded
3997
+ // during initial mount suspension, normal propagation can't find
3998
+ // them. In that case we conservatively retry the boundary — the
3999
+ // re-mounted children will read the updated context value.
4000
+ const contextChanged = lazilyPropagateParentContextChanges(
4001
+ current,
4002
+ workInProgress,
4003
+ renderLanes,
4004
+ );
4005
const primaryChildFragment: Fiber = (workInProgress.child: any);
4006
const primaryChildLanes = primaryChildFragment.childLanes;
3996
- if (includesSomeLane(renderLanes, primaryChildLanes)) {
4007
+ if (
4008
+ contextChanged ||
4009
+ includesSomeLane(renderLanes, primaryChildLanes)
4010
+ ) {
4011
// The primary children have pending work. Use the normal path
4012
// to attempt to render the primary children again.
4013
return updateSuspenseComponent(current, workInProgress, renderLanes);
packages/react-reconciler/src/ReactFiberNewContext.js
+40
-4
@@ -20,7 +20,11 @@ import type {Hook} from './ReactFiberHooks';
20
21
import {isPrimaryRenderer, HostTransitionContext} from './ReactFiberConfig';
22
import {createCursor, push, pop} from './ReactFiberStack';
23
-import {ContextProvider, DehydratedFragment} from './ReactWorkTags';
23
+import {
24
+ ContextProvider,
25
+ DehydratedFragment,
26
+ SuspenseComponent,
27
+} from './ReactWorkTags';
28
import {NoLanes, isSubsetOfLanes, mergeLanes} from './ReactFiberLane';
29
import {
30
NoFlags,
@@ -295,6 +299,37 @@ function propagateContextChanges<T>(
299
workInProgress,
300
);
301
nextFiber = null;
302
+ } else if (
303
+ fiber.tag === SuspenseComponent &&
304
+ fiber.memoizedState !== null &&
305
+ fiber.memoizedState.dehydrated === null
306
+ ) {
307
+ // This is a client-rendered Suspense boundary that is currently
308
+ // showing its fallback. The primary children may include context
309
+ // consumers, but their fibers may not exist in the tree — during
310
+ // initial mount, if the primary children suspended, their fibers
311
+ // were discarded since there was no current tree to preserve them.
312
+ // We can't walk into the primary tree to find consumers, so
313
+ // conservatively mark the Suspense boundary itself for retry.
314
+ // When it re-renders, it will re-mount the primary children,
315
+ // which will read the updated context value.
316
+ fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
317
+ const alternate = fiber.alternate;
318
+ if (alternate !== null) {
319
+ alternate.lanes = mergeLanes(alternate.lanes, renderLanes);
320
+ }
321
+ scheduleContextWorkOnParentPath(
322
+ fiber.return,
323
+ renderLanes,
324
+ workInProgress,
325
+ );
326
+ if (!forcePropagateEntireTree) {
327
+ // During lazy propagation, we can defer propagating changes to
328
+ // the children, same as the consumer match above.
329
+ nextFiber = null;
330
+ } else {
331
+ nextFiber = fiber.child;
332
+ }
333
} else {
334
// Traverse down.
335
nextFiber = fiber.child;
@@ -331,9 +366,9 @@ export function lazilyPropagateParentContextChanges(
366
current: Fiber,
367
workInProgress: Fiber,
368
renderLanes: Lanes,
334
-) {
369
+): boolean {
370
const forcePropagateEntireTree = false;
336
- propagateParentContextChanges(
371
+ return propagateParentContextChanges(
372
current,
373
workInProgress,
374
renderLanes,
@@ -364,7 +399,7 @@ function propagateParentContextChanges(
399
workInProgress: Fiber,
400
renderLanes: Lanes,
401
forcePropagateEntireTree: boolean,
367
-) {
402
+): boolean {
403
// Collect all the parent providers that changed. Since this is usually small
404
// number, we use an Array instead of Set.
405
let contexts = null;
@@ -460,6 +495,7 @@ function propagateParentContextChanges(
495
// then we could remove both `DidPropagateContext` and `NeedsPropagation`.
496
// Consider this as part of the next refactor to the fiber tree structure.
497
workInProgress.flags |= DidPropagateContext;
498
+ return contexts !== null;
499
}
500
501
export function checkIfContextChanged(
packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js
+100
@@ -2,6 +2,7 @@ let React;
2
let ReactNoop;
3
let Scheduler;
4
let act;
5
+let use;
6
let useState;
7
let useContext;
8
let Suspense;
@@ -19,6 +20,7 @@ describe('ReactLazyContextPropagation', () => {
20
ReactNoop = require('react-noop-renderer');
21
Scheduler = require('scheduler');
22
act = require('internal-test-utils').act;
23
+ use = React.use;
24
useState = React.useState;
25
useContext = React.useContext;
26
Suspense = React.Suspense;
@@ -937,4 +939,102 @@ describe('ReactLazyContextPropagation', () => {
939
assertLog(['B', 'B']);
940
expect(root).toMatchRenderedOutput('BB');
941
});
942
+
943
+ it('regression: context change triggers retry of suspended Suspense boundary on initial mount', async () => {
944
+ // Regression test for a bug where a context change above a suspended
945
+ // Suspense boundary would fail to trigger a retry. When a Suspense
946
+ // boundary suspends during initial mount, the primary children's fibers
947
+ // are discarded because there is no current tree to preserve them. If
948
+ // the suspended promise never resolves, the only way to retry is
949
+ // something external — like a context change. Context propagation must
950
+ // mark suspended Suspense boundaries for retry even though the consumer
951
+ // fibers no longer exist in the tree.
952
+ //
953
+ // The Provider component owns the state update. The children are
954
+ // passed in from above, so they are not re-created when the Provider
955
+ // re-renders — this means the Suspense boundary bails out, exercising
956
+ // the lazy context propagation path where the bug manifests.
957
+ const Context = React.createContext(null);
958
+ const neverResolvingPromise = new Promise(() => {});
959
+ const resolvedThenable = {status: 'fulfilled', value: 'Result', then() {}};
960
+
961
+ function Consumer() {
962
+ return <Text text={use(use(Context))} />;
963
+ }
964
+
965
+ let setPromise;
966
+ function Provider({children}) {
967
+ const [promise, _setPromise] = useState(neverResolvingPromise);
968
+ setPromise = _setPromise;
969
+ return <Context.Provider value={promise}>{children}</Context.Provider>;
970
+ }
971
+
972
+ const root = ReactNoop.createRoot();
973
+ await act(() => {
974
+ root.render(
975
+ <Provider>
976
+ <Suspense fallback={<Text text="Loading" />}>
977
+ <Consumer />
978
+ </Suspense>
979
+ </Provider>,
980
+ );
981
+ });
982
+ assertLog(['Loading']);
983
+ expect(root).toMatchRenderedOutput('Loading');
984
+
985
+ await act(() => {
986
+ setPromise(resolvedThenable);
987
+ });
988
+ assertLog(['Result']);
989
+ expect(root).toMatchRenderedOutput('Result');
990
+ });
991
+
992
+ it('regression: context change triggers retry of suspended Suspense boundary on initial mount (nested)', async () => {
993
+ // Same as above, but with an additional indirection component between
994
+ // the provider and the Suspense boundary. This exercises the
995
+ // propagateContextChanges walker path rather than the
996
+ // propagateParentContextChanges path.
997
+ const Context = React.createContext(null);
998
+ const neverResolvingPromise = new Promise(() => {});
999
+ const resolvedThenable = {status: 'fulfilled', value: 'Result', then() {}};
1000
+
1001
+ function Consumer() {
1002
+ return <Text text={use(use(Context))} />;
1003
+ }
1004
+
1005
+ function Indirection({children}) {
1006
+ Scheduler.log('Indirection');
1007
+ return children;
1008
+ }
1009
+
1010
+ let setPromise;
1011
+ function Provider({children}) {
1012
+ const [promise, _setPromise] = useState(neverResolvingPromise);
1013
+ setPromise = _setPromise;
1014
+ return <Context.Provider value={promise}>{children}</Context.Provider>;
1015
+ }
1016
+
1017
+ const root = ReactNoop.createRoot();
1018
+ await act(() => {
1019
+ root.render(
1020
+ <Provider>
1021
+ <Indirection>
1022
+ <Suspense fallback={<Text text="Loading" />}>
1023
+ <Consumer />
1024
+ </Suspense>
1025
+ </Indirection>
1026
+ </Provider>,
1027
+ );
1028
+ });
1029
+ assertLog(['Indirection', 'Loading']);
1030
+ expect(root).toMatchRenderedOutput('Loading');
1031
+
1032
+ // Indirection should not re-render — only the Suspense boundary
1033
+ // should be retried.
1034
+ await act(() => {
1035
+ setPromise(resolvedThenable);
1036
+ });
1037
+ assertLog(['Result']);
1038
+ expect(root).toMatchRenderedOutput('Result');
1039
+ });
1040
});