@samitouri / QOS-React / commits / ab18f33d46

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 });