Skip to content

Commit ab18f33

Browse files
authored
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.
1 parent b16b768 commit ab18f33

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎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+
constcontextChanged=lazilyPropagateParentContextChanges(
4001+
current,
4002+
workInProgress,
4003+
renderLanes,
4004+
);
39944005
constprimaryChildFragment: Fiber=(workInProgress.child: any);
39954006
constprimaryChildLanes=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
returnupdateSuspenseComponent(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+
}elseif(
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+
constalternate=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
constforcePropagateEntireTree=false;
336-
propagateParentContextChanges(
371+
returnpropagateParentContextChanges(
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
letcontexts=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
exportfunctioncheckIfContextChanged(

‎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
letReactNoop;
33
letScheduler;
44
letact;
5+
letuse;
56
letuseState;
67
letuseContext;
78
letSuspense;
@@ -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+
constContext=React.createContext(null);
958+
constneverResolvingPromise=newPromise(()=>{});
959+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
960+
961+
functionConsumer(){
962+
return<Texttext={use(use(Context))}/>;
963+
}
964+
965+
letsetPromise;
966+
functionProvider({children}){
967+
const[promise,_setPromise]=useState(neverResolvingPromise);
968+
setPromise=_setPromise;
969+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
970+
}
971+
972+
constroot=ReactNoop.createRoot();
973+
awaitact(()=>{
974+
root.render(
975+
<Provider>
976+
<Suspensefallback={<Texttext="Loading"/>}>
977+
<Consumer/>
978+
</Suspense>
979+
</Provider>,
980+
);
981+
});
982+
assertLog(['Loading']);
983+
expect(root).toMatchRenderedOutput('Loading');
984+
985+
awaitact(()=>{
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+
constContext=React.createContext(null);
998+
constneverResolvingPromise=newPromise(()=>{});
999+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
1000+
1001+
functionConsumer(){
1002+
return<Texttext={use(use(Context))}/>;
1003+
}
1004+
1005+
functionIndirection({children}){
1006+
Scheduler.log('Indirection');
1007+
returnchildren;
1008+
}
1009+
1010+
letsetPromise;
1011+
functionProvider({children}){
1012+
const[promise,_setPromise]=useState(neverResolvingPromise);
1013+
setPromise=_setPromise;
1014+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
1015+
}
1016+
1017+
constroot=ReactNoop.createRoot();
1018+
awaitact(()=>{
1019+
root.render(
1020+
<Provider>
1021+
<Indirection>
1022+
<Suspensefallback={<Texttext="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+
awaitact(()=>{
1035+
setPromise(resolvedThenable);
1036+
});
1037+
assertLog(['Result']);
1038+
expect(root).toMatchRenderedOutput('Result');
1039+
});
9401040
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Fix context propagation through suspended Suspense boundaries (#35839) · react/react@ab18f33 · GitHub
Skip to content

Commit ab18f33

Browse files
authored
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.
1 parent b16b768 commit ab18f33

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎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+
constcontextChanged=lazilyPropagateParentContextChanges(
4001+
current,
4002+
workInProgress,
4003+
renderLanes,
4004+
);
39944005
constprimaryChildFragment: Fiber=(workInProgress.child: any);
39954006
constprimaryChildLanes=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
returnupdateSuspenseComponent(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+
}elseif(
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+
constalternate=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
constforcePropagateEntireTree=false;
336-
propagateParentContextChanges(
371+
returnpropagateParentContextChanges(
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
letcontexts=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
exportfunctioncheckIfContextChanged(

‎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
letReactNoop;
33
letScheduler;
44
letact;
5+
letuse;
56
letuseState;
67
letuseContext;
78
letSuspense;
@@ -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+
constContext=React.createContext(null);
958+
constneverResolvingPromise=newPromise(()=>{});
959+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
960+
961+
functionConsumer(){
962+
return<Texttext={use(use(Context))}/>;
963+
}
964+
965+
letsetPromise;
966+
functionProvider({children}){
967+
const[promise,_setPromise]=useState(neverResolvingPromise);
968+
setPromise=_setPromise;
969+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
970+
}
971+
972+
constroot=ReactNoop.createRoot();
973+
awaitact(()=>{
974+
root.render(
975+
<Provider>
976+
<Suspensefallback={<Texttext="Loading"/>}>
977+
<Consumer/>
978+
</Suspense>
979+
</Provider>,
980+
);
981+
});
982+
assertLog(['Loading']);
983+
expect(root).toMatchRenderedOutput('Loading');
984+
985+
awaitact(()=>{
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+
constContext=React.createContext(null);
998+
constneverResolvingPromise=newPromise(()=>{});
999+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
1000+
1001+
functionConsumer(){
1002+
return<Texttext={use(use(Context))}/>;
1003+
}
1004+
1005+
functionIndirection({children}){
1006+
Scheduler.log('Indirection');
1007+
returnchildren;
1008+
}
1009+
1010+
letsetPromise;
1011+
functionProvider({children}){
1012+
const[promise,_setPromise]=useState(neverResolvingPromise);
1013+
setPromise=_setPromise;
1014+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
1015+
}
1016+
1017+
constroot=ReactNoop.createRoot();
1018+
awaitact(()=>{
1019+
root.render(
1020+
<Provider>
1021+
<Indirection>
1022+
<Suspensefallback={<Texttext="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+
awaitact(()=>{
1035+
setPromise(resolvedThenable);
1036+
});
1037+
assertLog(['Result']);
1038+
expect(root).toMatchRenderedOutput('Result');
1039+
});
9401040
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix context propagation through suspended Suspense boundaries (#35839) · react/react@ab18f33 · GitHub
Skip to content

Commit ab18f33

Browse files
authored
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.
1 parent b16b768 commit ab18f33

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎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+
constcontextChanged=lazilyPropagateParentContextChanges(
4001+
current,
4002+
workInProgress,
4003+
renderLanes,
4004+
);
39944005
constprimaryChildFragment: Fiber=(workInProgress.child: any);
39954006
constprimaryChildLanes=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
returnupdateSuspenseComponent(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+
}elseif(
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+
constalternate=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
constforcePropagateEntireTree=false;
336-
propagateParentContextChanges(
371+
returnpropagateParentContextChanges(
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
letcontexts=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
exportfunctioncheckIfContextChanged(

‎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
letReactNoop;
33
letScheduler;
44
letact;
5+
letuse;
56
letuseState;
67
letuseContext;
78
letSuspense;
@@ -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+
constContext=React.createContext(null);
958+
constneverResolvingPromise=newPromise(()=>{});
959+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
960+
961+
functionConsumer(){
962+
return<Texttext={use(use(Context))}/>;
963+
}
964+
965+
letsetPromise;
966+
functionProvider({children}){
967+
const[promise,_setPromise]=useState(neverResolvingPromise);
968+
setPromise=_setPromise;
969+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
970+
}
971+
972+
constroot=ReactNoop.createRoot();
973+
awaitact(()=>{
974+
root.render(
975+
<Provider>
976+
<Suspensefallback={<Texttext="Loading"/>}>
977+
<Consumer/>
978+
</Suspense>
979+
</Provider>,
980+
);
981+
});
982+
assertLog(['Loading']);
983+
expect(root).toMatchRenderedOutput('Loading');
984+
985+
awaitact(()=>{
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+
constContext=React.createContext(null);
998+
constneverResolvingPromise=newPromise(()=>{});
999+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
1000+
1001+
functionConsumer(){
1002+
return<Texttext={use(use(Context))}/>;
1003+
}
1004+
1005+
functionIndirection({children}){
1006+
Scheduler.log('Indirection');
1007+
returnchildren;
1008+
}
1009+
1010+
letsetPromise;
1011+
functionProvider({children}){
1012+
const[promise,_setPromise]=useState(neverResolvingPromise);
1013+
setPromise=_setPromise;
1014+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
1015+
}
1016+
1017+
constroot=ReactNoop.createRoot();
1018+
awaitact(()=>{
1019+
root.render(
1020+
<Provider>
1021+
<Indirection>
1022+
<Suspensefallback={<Texttext="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+
awaitact(()=>{
1035+
setPromise(resolvedThenable);
1036+
});
1037+
assertLog(['Result']);
1038+
expect(root).toMatchRenderedOutput('Result');
1039+
});
9401040
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix context propagation through suspended Suspense boundaries (#35839) · react/react@ab18f33 · GitHub
Skip to content

Commit ab18f33

Browse files
authored
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.
1 parent b16b768 commit ab18f33

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎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+
constcontextChanged=lazilyPropagateParentContextChanges(
4001+
current,
4002+
workInProgress,
4003+
renderLanes,
4004+
);
39944005
constprimaryChildFragment: Fiber=(workInProgress.child: any);
39954006
constprimaryChildLanes=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
returnupdateSuspenseComponent(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+
}elseif(
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+
constalternate=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
constforcePropagateEntireTree=false;
336-
propagateParentContextChanges(
371+
returnpropagateParentContextChanges(
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
letcontexts=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
exportfunctioncheckIfContextChanged(

‎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
letReactNoop;
33
letScheduler;
44
letact;
5+
letuse;
56
letuseState;
67
letuseContext;
78
letSuspense;
@@ -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+
constContext=React.createContext(null);
958+
constneverResolvingPromise=newPromise(()=>{});
959+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
960+
961+
functionConsumer(){
962+
return<Texttext={use(use(Context))}/>;
963+
}
964+
965+
letsetPromise;
966+
functionProvider({children}){
967+
const[promise,_setPromise]=useState(neverResolvingPromise);
968+
setPromise=_setPromise;
969+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
970+
}
971+
972+
constroot=ReactNoop.createRoot();
973+
awaitact(()=>{
974+
root.render(
975+
<Provider>
976+
<Suspensefallback={<Texttext="Loading"/>}>
977+
<Consumer/>
978+
</Suspense>
979+
</Provider>,
980+
);
981+
});
982+
assertLog(['Loading']);
983+
expect(root).toMatchRenderedOutput('Loading');
984+
985+
awaitact(()=>{
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+
constContext=React.createContext(null);
998+
constneverResolvingPromise=newPromise(()=>{});
999+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
1000+
1001+
functionConsumer(){
1002+
return<Texttext={use(use(Context))}/>;
1003+
}
1004+
1005+
functionIndirection({children}){
1006+
Scheduler.log('Indirection');
1007+
returnchildren;
1008+
}
1009+
1010+
letsetPromise;
1011+
functionProvider({children}){
1012+
const[promise,_setPromise]=useState(neverResolvingPromise);
1013+
setPromise=_setPromise;
1014+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
1015+
}
1016+
1017+
constroot=ReactNoop.createRoot();
1018+
awaitact(()=>{
1019+
root.render(
1020+
<Provider>
1021+
<Indirection>
1022+
<Suspensefallback={<Texttext="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+
awaitact(()=>{
1035+
setPromise(resolvedThenable);
1036+
});
1037+
assertLog(['Result']);
1038+
expect(root).toMatchRenderedOutput('Result');
1039+
});
9401040
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Fix context propagation through suspended Suspense boundaries (#35839) · react/react@ab18f33 · GitHub
Skip to content

Commit ab18f33

Browse files
authored
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.
1 parent b16b768 commit ab18f33

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎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+
constcontextChanged=lazilyPropagateParentContextChanges(
4001+
current,
4002+
workInProgress,
4003+
renderLanes,
4004+
);
39944005
constprimaryChildFragment: Fiber=(workInProgress.child: any);
39954006
constprimaryChildLanes=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
returnupdateSuspenseComponent(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+
}elseif(
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+
constalternate=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
constforcePropagateEntireTree=false;
336-
propagateParentContextChanges(
371+
returnpropagateParentContextChanges(
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
letcontexts=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
exportfunctioncheckIfContextChanged(

‎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
letReactNoop;
33
letScheduler;
44
letact;
5+
letuse;
56
letuseState;
67
letuseContext;
78
letSuspense;
@@ -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+
constContext=React.createContext(null);
958+
constneverResolvingPromise=newPromise(()=>{});
959+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
960+
961+
functionConsumer(){
962+
return<Texttext={use(use(Context))}/>;
963+
}
964+
965+
letsetPromise;
966+
functionProvider({children}){
967+
const[promise,_setPromise]=useState(neverResolvingPromise);
968+
setPromise=_setPromise;
969+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
970+
}
971+
972+
constroot=ReactNoop.createRoot();
973+
awaitact(()=>{
974+
root.render(
975+
<Provider>
976+
<Suspensefallback={<Texttext="Loading"/>}>
977+
<Consumer/>
978+
</Suspense>
979+
</Provider>,
980+
);
981+
});
982+
assertLog(['Loading']);
983+
expect(root).toMatchRenderedOutput('Loading');
984+
985+
awaitact(()=>{
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+
constContext=React.createContext(null);
998+
constneverResolvingPromise=newPromise(()=>{});
999+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
1000+
1001+
functionConsumer(){
1002+
return<Texttext={use(use(Context))}/>;
1003+
}
1004+
1005+
functionIndirection({children}){
1006+
Scheduler.log('Indirection');
1007+
returnchildren;
1008+
}
1009+
1010+
letsetPromise;
1011+
functionProvider({children}){
1012+
const[promise,_setPromise]=useState(neverResolvingPromise);
1013+
setPromise=_setPromise;
1014+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
1015+
}
1016+
1017+
constroot=ReactNoop.createRoot();
1018+
awaitact(()=>{
1019+
root.render(
1020+
<Provider>
1021+
<Indirection>
1022+
<Suspensefallback={<Texttext="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+
awaitact(()=>{
1035+
setPromise(resolvedThenable);
1036+
});
1037+
assertLog(['Result']);
1038+
expect(root).toMatchRenderedOutput('Result');
1039+
});
9401040
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix context propagation through suspended Suspense boundaries (#35839) · react/react@ab18f33 · GitHub
Skip to content

Commit ab18f33

Browse files
authored
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.
1 parent b16b768 commit ab18f33

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎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+
constcontextChanged=lazilyPropagateParentContextChanges(
4001+
current,
4002+
workInProgress,
4003+
renderLanes,
4004+
);
39944005
constprimaryChildFragment: Fiber=(workInProgress.child: any);
39954006
constprimaryChildLanes=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
returnupdateSuspenseComponent(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+
}elseif(
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+
constalternate=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
constforcePropagateEntireTree=false;
336-
propagateParentContextChanges(
371+
returnpropagateParentContextChanges(
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
letcontexts=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
exportfunctioncheckIfContextChanged(

‎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
letReactNoop;
33
letScheduler;
44
letact;
5+
letuse;
56
letuseState;
67
letuseContext;
78
letSuspense;
@@ -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+
constContext=React.createContext(null);
958+
constneverResolvingPromise=newPromise(()=>{});
959+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
960+
961+
functionConsumer(){
962+
return<Texttext={use(use(Context))}/>;
963+
}
964+
965+
letsetPromise;
966+
functionProvider({children}){
967+
const[promise,_setPromise]=useState(neverResolvingPromise);
968+
setPromise=_setPromise;
969+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
970+
}
971+
972+
constroot=ReactNoop.createRoot();
973+
awaitact(()=>{
974+
root.render(
975+
<Provider>
976+
<Suspensefallback={<Texttext="Loading"/>}>
977+
<Consumer/>
978+
</Suspense>
979+
</Provider>,
980+
);
981+
});
982+
assertLog(['Loading']);
983+
expect(root).toMatchRenderedOutput('Loading');
984+
985+
awaitact(()=>{
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+
constContext=React.createContext(null);
998+
constneverResolvingPromise=newPromise(()=>{});
999+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
1000+
1001+
functionConsumer(){
1002+
return<Texttext={use(use(Context))}/>;
1003+
}
1004+
1005+
functionIndirection({children}){
1006+
Scheduler.log('Indirection');
1007+
returnchildren;
1008+
}
1009+
1010+
letsetPromise;
1011+
functionProvider({children}){
1012+
const[promise,_setPromise]=useState(neverResolvingPromise);
1013+
setPromise=_setPromise;
1014+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
1015+
}
1016+
1017+
constroot=ReactNoop.createRoot();
1018+
awaitact(()=>{
1019+
root.render(
1020+
<Provider>
1021+
<Indirection>
1022+
<Suspensefallback={<Texttext="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+
awaitact(()=>{
1035+
setPromise(resolvedThenable);
1036+
});
1037+
assertLog(['Result']);
1038+
expect(root).toMatchRenderedOutput('Result');
1039+
});
9401040
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Fix context propagation through suspended Suspense boundaries (#35839) · react/react@ab18f33 · GitHub
Skip to content

Commit ab18f33

Browse files
authored
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.
1 parent b16b768 commit ab18f33

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎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+
constcontextChanged=lazilyPropagateParentContextChanges(
4001+
current,
4002+
workInProgress,
4003+
renderLanes,
4004+
);
39944005
constprimaryChildFragment: Fiber=(workInProgress.child: any);
39954006
constprimaryChildLanes=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
returnupdateSuspenseComponent(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+
}elseif(
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+
constalternate=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
constforcePropagateEntireTree=false;
336-
propagateParentContextChanges(
371+
returnpropagateParentContextChanges(
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
letcontexts=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
exportfunctioncheckIfContextChanged(

‎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
letReactNoop;
33
letScheduler;
44
letact;
5+
letuse;
56
letuseState;
67
letuseContext;
78
letSuspense;
@@ -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+
constContext=React.createContext(null);
958+
constneverResolvingPromise=newPromise(()=>{});
959+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
960+
961+
functionConsumer(){
962+
return<Texttext={use(use(Context))}/>;
963+
}
964+
965+
letsetPromise;
966+
functionProvider({children}){
967+
const[promise,_setPromise]=useState(neverResolvingPromise);
968+
setPromise=_setPromise;
969+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
970+
}
971+
972+
constroot=ReactNoop.createRoot();
973+
awaitact(()=>{
974+
root.render(
975+
<Provider>
976+
<Suspensefallback={<Texttext="Loading"/>}>
977+
<Consumer/>
978+
</Suspense>
979+
</Provider>,
980+
);
981+
});
982+
assertLog(['Loading']);
983+
expect(root).toMatchRenderedOutput('Loading');
984+
985+
awaitact(()=>{
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+
constContext=React.createContext(null);
998+
constneverResolvingPromise=newPromise(()=>{});
999+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
1000+
1001+
functionConsumer(){
1002+
return<Texttext={use(use(Context))}/>;
1003+
}
1004+
1005+
functionIndirection({children}){
1006+
Scheduler.log('Indirection');
1007+
returnchildren;
1008+
}
1009+
1010+
letsetPromise;
1011+
functionProvider({children}){
1012+
const[promise,_setPromise]=useState(neverResolvingPromise);
1013+
setPromise=_setPromise;
1014+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
1015+
}
1016+
1017+
constroot=ReactNoop.createRoot();
1018+
awaitact(()=>{
1019+
root.render(
1020+
<Provider>
1021+
<Indirection>
1022+
<Suspensefallback={<Texttext="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+
awaitact(()=>{
1035+
setPromise(resolvedThenable);
1036+
});
1037+
assertLog(['Result']);
1038+
expect(root).toMatchRenderedOutput('Result');
1039+
});
9401040
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Fix context propagation through suspended Suspense boundaries (#35839) · react/react@ab18f33 · GitHub
Skip to content

Commit ab18f33

Browse files
authored
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.
1 parent b16b768 commit ab18f33

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎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+
constcontextChanged=lazilyPropagateParentContextChanges(
4001+
current,
4002+
workInProgress,
4003+
renderLanes,
4004+
);
39944005
constprimaryChildFragment: Fiber=(workInProgress.child: any);
39954006
constprimaryChildLanes=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
returnupdateSuspenseComponent(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+
}elseif(
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+
constalternate=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
constforcePropagateEntireTree=false;
336-
propagateParentContextChanges(
371+
returnpropagateParentContextChanges(
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
letcontexts=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
exportfunctioncheckIfContextChanged(

‎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
letReactNoop;
33
letScheduler;
44
letact;
5+
letuse;
56
letuseState;
67
letuseContext;
78
letSuspense;
@@ -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+
constContext=React.createContext(null);
958+
constneverResolvingPromise=newPromise(()=>{});
959+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
960+
961+
functionConsumer(){
962+
return<Texttext={use(use(Context))}/>;
963+
}
964+
965+
letsetPromise;
966+
functionProvider({children}){
967+
const[promise,_setPromise]=useState(neverResolvingPromise);
968+
setPromise=_setPromise;
969+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
970+
}
971+
972+
constroot=ReactNoop.createRoot();
973+
awaitact(()=>{
974+
root.render(
975+
<Provider>
976+
<Suspensefallback={<Texttext="Loading"/>}>
977+
<Consumer/>
978+
</Suspense>
979+
</Provider>,
980+
);
981+
});
982+
assertLog(['Loading']);
983+
expect(root).toMatchRenderedOutput('Loading');
984+
985+
awaitact(()=>{
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+
constContext=React.createContext(null);
998+
constneverResolvingPromise=newPromise(()=>{});
999+
constresolvedThenable={status: 'fulfilled',value: 'Result',then(){}};
1000+
1001+
functionConsumer(){
1002+
return<Texttext={use(use(Context))}/>;
1003+
}
1004+
1005+
functionIndirection({children}){
1006+
Scheduler.log('Indirection');
1007+
returnchildren;
1008+
}
1009+
1010+
letsetPromise;
1011+
functionProvider({children}){
1012+
const[promise,_setPromise]=useState(neverResolvingPromise);
1013+
setPromise=_setPromise;
1014+
return<Context.Providervalue={promise}>{children}</Context.Provider>;
1015+
}
1016+
1017+
constroot=ReactNoop.createRoot();
1018+
awaitact(()=>{
1019+
root.render(
1020+
<Provider>
1021+
<Indirection>
1022+
<Suspensefallback={<Texttext="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+
awaitact(()=>{
1035+
setPromise(resolvedThenable);
1036+
});
1037+
assertLog(['Result']);
1038+
expect(root).toMatchRenderedOutput('Result');
1039+
});
9401040
});

0 commit comments

Comments
 (0)