Skip to content

Commit ab18f33

Browse files
authored
Fix context propagation through suspended Suspense boundaries (facebook#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.
1 parent b16b768 commit ab18f33

File tree

3 files changed

+155
-5
lines changed

3 files changed

+155
-5
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3991,9 +3991,23 @@ function attemptEarlyBailoutIfNoScheduledUpdate(
39913991
// whether to retry the primary children, or to skip over it and
39923992
// go straight to the fallback. Check the priority of the primary
39933993
// 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+
);
39944005
const primaryChildFragment: Fiber = (workInProgress.child: any);
39954006
const primaryChildLanes = primaryChildFragment.childLanes;
3996-
if (includesSomeLane(renderLanes, primaryChildLanes)) {
4007+
if (
4008+
contextChanged ||
4009+
includesSomeLane(renderLanes, primaryChildLanes)
4010+
) {
39974011
// The primary children have pending work. Use the normal path
39984012
// to attempt to render the primary children again.
39994013
return updateSuspenseComponent(current, workInProgress, renderLanes);

packages/react-reconciler/src/ReactFiberNewContext.js

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import type {Hook} from './ReactFiberHooks';
2020

2121
import {isPrimaryRenderer, HostTransitionContext} from './ReactFiberConfig';
2222
import {createCursor, push, pop} from './ReactFiberStack';
23-
import {ContextProvider, DehydratedFragment} from './ReactWorkTags';
23+
import {
24+
ContextProvider,
25+
DehydratedFragment,
26+
SuspenseComponent,
27+
} from './ReactWorkTags';
2428
import {NoLanes, isSubsetOfLanes, mergeLanes} from './ReactFiberLane';
2529
import {
2630
NoFlags,
@@ -295,6 +299,37 @@ function propagateContextChanges<T>(
295299
workInProgress,
296300
);
297301
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+
}
298333
} else {
299334
// Traverse down.
300335
nextFiber = fiber.child;
@@ -331,9 +366,9 @@ export function lazilyPropagateParentContextChanges(
331366
current: Fiber,
332367
workInProgress: Fiber,
333368
renderLanes: Lanes,
334-
) {
369+
): boolean {
335370
const forcePropagateEntireTree = false;
336-
propagateParentContextChanges(
371+
return propagateParentContextChanges(
337372
current,
338373
workInProgress,
339374
renderLanes,
@@ -364,7 +399,7 @@ function propagateParentContextChanges(
364399
workInProgress: Fiber,
365400
renderLanes: Lanes,
366401
forcePropagateEntireTree: boolean,
367-
) {
402+
): boolean {
368403
// Collect all the parent providers that changed. Since this is usually small
369404
// number, we use an Array instead of Set.
370405
let contexts = null;
@@ -460,6 +495,7 @@ function propagateParentContextChanges(
460495
// then we could remove both `DidPropagateContext` and `NeedsPropagation`.
461496
// Consider this as part of the next refactor to the fiber tree structure.
462497
workInProgress.flags |= DidPropagateContext;
498+
return contexts !== null;
463499
}
464500

465501
export function checkIfContextChanged(

packages/react-reconciler/src/__tests__/ReactContextPropagation-test.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ let React;
22
let ReactNoop;
33
let Scheduler;
44
let act;
5+
let use;
56
let useState;
67
let useContext;
78
let Suspense;
@@ -19,6 +20,7 @@ describe('ReactLazyContextPropagation', () => {
1920
ReactNoop = require('react-noop-renderer');
2021
Scheduler = require('scheduler');
2122
act = require('internal-test-utils').act;
23+
use = React.use;
2224
useState = React.useState;
2325
useContext = React.useContext;
2426
Suspense = React.Suspense;
@@ -937,4 +939,102 @@ describe('ReactLazyContextPropagation', () => {
937939
assertLog(['B', 'B']);
938940
expect(root).toMatchRenderedOutput('BB');
939941
});
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+
});
9401040
});

0 commit comments

Comments
 (0)