Skip to content

Commit 9627b5a

Browse files
authored
[Fiber] Fix context propagation into Suspense fallbacks (#36160)
## Summary When a context value changes above a Suspense boundary that is showing its fallback, context consumers inside the fallback do not re-render — they display stale values. `propagateContextChanges`, upon encountering a suspended Suspense boundary, marks the boundary for retry but stops traversing into its children entirely (`nextFiber = null`). This skips both the hidden primary subtree (intentional — those fibers may not exist) and the visible fallback subtree (a bug — those fibers are committed and visible to the user). The fix skips the primary OffscreenComponent and continues traversal into the FallbackFragment, so fallback context consumers are found and marked for re-render. In practice this often goes unnoticed because it's uncommon to read context inside a Suspense fallback, and when some other update (like a prop change) flows into the fallback it sidesteps the propagation path entirely. React Compiler makes the bug more likely to surface since it memoizes more aggressively, reducing the chance of an incidental re-render masking the stale value. ## Test plan - Added regression test `'context change propagates to Suspense fallback (memo boundary)'` in `ReactContextPropagation-test.js` - Verified the test fails without the fix and passes with it - All existing context propagation, Suspense, memo, and hooks tests pass
1 parent f944b4c commit 9627b5a

2 files changed

Lines changed: 92 additions & 5 deletions

File tree

packages/react-reconciler/src/ReactFiberNewContext.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,23 @@ function propagateContextChanges<T>(
323323
renderLanes,
324324
workInProgress,
325325
);
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;
326+
// The primary children's fibers may not exist in the tree (they
327+
// were discarded on initial mount if they suspended). However, the
328+
// fallback children ARE in the committed tree and visible to the
329+
// user. We need to continue propagating into the fallback subtree
330+
// so that its context consumers are marked for re-render.
331+
//
332+
// The fiber structure is:
333+
// SuspenseComponent
334+
// → child: OffscreenComponent (primary, hidden)
335+
// → sibling: FallbackFragment
336+
//
337+
// Skip the primary (hidden) subtree and jump to the fallback.
338+
const primaryChildFragment = fiber.child;
339+
if (primaryChildFragment !== null) {
340+
nextFiber = primaryChildFragment.sibling;
330341
} else {
331-
nextFiber = fiber.child;
342+
nextFiber = null;
332343
}
333344
} else {
334345
// Traverse down.

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,4 +1037,80 @@ describe('ReactLazyContextPropagation', () => {
10371037
assertLog(['Result']);
10381038
expect(root).toMatchRenderedOutput('Result');
10391039
});
1040+
1041+
// @gate enableLegacyCache
1042+
it('context change propagates to Suspense fallback (memo boundary)', async () => {
1043+
// When a context change occurs above a Suspense boundary that is currently
1044+
// showing its fallback, the fallback's context consumers should re-render
1045+
// with the updated value — even if there's a memo boundary between the
1046+
// provider and the Suspense boundary that prevents the fallback element
1047+
// references from changing.
1048+
const root = ReactNoop.createRoot();
1049+
const Context = React.createContext('A');
1050+
1051+
let setContext;
1052+
function App() {
1053+
const [value, _setValue] = useState('A');
1054+
setContext = _setValue;
1055+
return (
1056+
<Context.Provider value={value}>
1057+
<MemoizedWrapper />
1058+
<Text text={value} />
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
const MemoizedWrapper = React.memo(function MemoizedWrapper() {
1064+
return (
1065+
<Suspense fallback={<FallbackConsumer />}>
1066+
<AsyncChild />
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
function FallbackConsumer() {
1072+
const value = useContext(Context);
1073+
return <Text text={'Fallback: ' + value} />;
1074+
}
1075+
1076+
function AsyncChild() {
1077+
readText('async');
1078+
return <Text text="Content" />;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
await act(() => {
1083+
root.render(<App />);
1084+
});
1085+
assertLog([
1086+
'Suspend! [async]',
1087+
'Fallback: A',
1088+
'A',
1089+
// pre-warming
1090+
'Suspend! [async]',
1091+
]);
1092+
expect(root).toMatchRenderedOutput('Fallback: AA');
1093+
1094+
// Update context while still suspended. The fallback consumer should
1095+
// re-render with the new value.
1096+
await act(() => {
1097+
setContext('B');
1098+
});
1099+
assertLog([
1100+
// The Suspense boundary retries the primary children first
1101+
'Suspend! [async]',
1102+
'Fallback: B',
1103+
'B',
1104+
// pre-warming
1105+
'Suspend! [async]',
1106+
]);
1107+
expect(root).toMatchRenderedOutput('Fallback: BB');
1108+
1109+
// Unsuspend. The primary content should render with the latest context.
1110+
await act(async () => {
1111+
await resolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

0 commit comments

Comments
 (0)