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+
constroot=ReactNoop.createRoot();
1049+
constContext=React.createContext('A');
1050+
1051+
letsetContext;
1052+
functionApp(){
1053+
const[value,_setValue]=useState('A');
1054+
setContext=_setValue;
1055+
return(
1056+
<Context.Providervalue={value}>
1057+
<MemoizedWrapper/>
1058+
<Texttext={value}/>
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
constMemoizedWrapper=React.memo(functionMemoizedWrapper(){
1064+
return(
1065+
<Suspensefallback={<FallbackConsumer/>}>
1066+
<AsyncChild/>
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
functionFallbackConsumer(){
1072+
constvalue=useContext(Context);
1073+
return<Texttext={'Fallback: '+value}/>;
1074+
}
1075+
1076+
functionAsyncChild(){
1077+
readText('async');
1078+
return<Texttext="Content"/>;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
awaitact(()=>{
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+
awaitact(()=>{
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+
awaitact(async()=>{
1111+
awaitresolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

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" + '
[Fiber] Fix context propagation into Suspense fallbacks (#36160) · react/react@9627b5a · GitHub
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+
constroot=ReactNoop.createRoot();
1049+
constContext=React.createContext('A');
1050+
1051+
letsetContext;
1052+
functionApp(){
1053+
const[value,_setValue]=useState('A');
1054+
setContext=_setValue;
1055+
return(
1056+
<Context.Providervalue={value}>
1057+
<MemoizedWrapper/>
1058+
<Texttext={value}/>
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
constMemoizedWrapper=React.memo(functionMemoizedWrapper(){
1064+
return(
1065+
<Suspensefallback={<FallbackConsumer/>}>
1066+
<AsyncChild/>
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
functionFallbackConsumer(){
1072+
constvalue=useContext(Context);
1073+
return<Texttext={'Fallback: '+value}/>;
1074+
}
1075+
1076+
functionAsyncChild(){
1077+
readText('async');
1078+
return<Texttext="Content"/>;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
awaitact(()=>{
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+
awaitact(()=>{
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+
awaitact(async()=>{
1111+
awaitresolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

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('^' + ".*" + ' [Fiber] Fix context propagation into Suspense fallbacks (#36160) · react/react@9627b5a · GitHub
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+
constroot=ReactNoop.createRoot();
1049+
constContext=React.createContext('A');
1050+
1051+
letsetContext;
1052+
functionApp(){
1053+
const[value,_setValue]=useState('A');
1054+
setContext=_setValue;
1055+
return(
1056+
<Context.Providervalue={value}>
1057+
<MemoizedWrapper/>
1058+
<Texttext={value}/>
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
constMemoizedWrapper=React.memo(functionMemoizedWrapper(){
1064+
return(
1065+
<Suspensefallback={<FallbackConsumer/>}>
1066+
<AsyncChild/>
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
functionFallbackConsumer(){
1072+
constvalue=useContext(Context);
1073+
return<Texttext={'Fallback: '+value}/>;
1074+
}
1075+
1076+
functionAsyncChild(){
1077+
readText('async');
1078+
return<Texttext="Content"/>;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
awaitact(()=>{
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+
awaitact(()=>{
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+
awaitact(async()=>{
1111+
awaitresolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

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('^' + ".*" + ' [Fiber] Fix context propagation into Suspense fallbacks (#36160) · react/react@9627b5a · GitHub
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+
constroot=ReactNoop.createRoot();
1049+
constContext=React.createContext('A');
1050+
1051+
letsetContext;
1052+
functionApp(){
1053+
const[value,_setValue]=useState('A');
1054+
setContext=_setValue;
1055+
return(
1056+
<Context.Providervalue={value}>
1057+
<MemoizedWrapper/>
1058+
<Texttext={value}/>
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
constMemoizedWrapper=React.memo(functionMemoizedWrapper(){
1064+
return(
1065+
<Suspensefallback={<FallbackConsumer/>}>
1066+
<AsyncChild/>
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
functionFallbackConsumer(){
1072+
constvalue=useContext(Context);
1073+
return<Texttext={'Fallback: '+value}/>;
1074+
}
1075+
1076+
functionAsyncChild(){
1077+
readText('async');
1078+
return<Texttext="Content"/>;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
awaitact(()=>{
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+
awaitact(()=>{
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+
awaitact(async()=>{
1111+
awaitresolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

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" + ' [Fiber] Fix context propagation into Suspense fallbacks (#36160) · react/react@9627b5a · GitHub
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+
constroot=ReactNoop.createRoot();
1049+
constContext=React.createContext('A');
1050+
1051+
letsetContext;
1052+
functionApp(){
1053+
const[value,_setValue]=useState('A');
1054+
setContext=_setValue;
1055+
return(
1056+
<Context.Providervalue={value}>
1057+
<MemoizedWrapper/>
1058+
<Texttext={value}/>
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
constMemoizedWrapper=React.memo(functionMemoizedWrapper(){
1064+
return(
1065+
<Suspensefallback={<FallbackConsumer/>}>
1066+
<AsyncChild/>
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
functionFallbackConsumer(){
1072+
constvalue=useContext(Context);
1073+
return<Texttext={'Fallback: '+value}/>;
1074+
}
1075+
1076+
functionAsyncChild(){
1077+
readText('async');
1078+
return<Texttext="Content"/>;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
awaitact(()=>{
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+
awaitact(()=>{
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+
awaitact(async()=>{
1111+
awaitresolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

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('^' + ".*" + ' [Fiber] Fix context propagation into Suspense fallbacks (#36160) · react/react@9627b5a · GitHub
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+
constroot=ReactNoop.createRoot();
1049+
constContext=React.createContext('A');
1050+
1051+
letsetContext;
1052+
functionApp(){
1053+
const[value,_setValue]=useState('A');
1054+
setContext=_setValue;
1055+
return(
1056+
<Context.Providervalue={value}>
1057+
<MemoizedWrapper/>
1058+
<Texttext={value}/>
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
constMemoizedWrapper=React.memo(functionMemoizedWrapper(){
1064+
return(
1065+
<Suspensefallback={<FallbackConsumer/>}>
1066+
<AsyncChild/>
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
functionFallbackConsumer(){
1072+
constvalue=useContext(Context);
1073+
return<Texttext={'Fallback: '+value}/>;
1074+
}
1075+
1076+
functionAsyncChild(){
1077+
readText('async');
1078+
return<Texttext="Content"/>;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
awaitact(()=>{
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+
awaitact(()=>{
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+
awaitact(async()=>{
1111+
awaitresolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

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('^' + ".*" + ' [Fiber] Fix context propagation into Suspense fallbacks (#36160) · react/react@9627b5a · GitHub
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+
constroot=ReactNoop.createRoot();
1049+
constContext=React.createContext('A');
1050+
1051+
letsetContext;
1052+
functionApp(){
1053+
const[value,_setValue]=useState('A');
1054+
setContext=_setValue;
1055+
return(
1056+
<Context.Providervalue={value}>
1057+
<MemoizedWrapper/>
1058+
<Texttext={value}/>
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
constMemoizedWrapper=React.memo(functionMemoizedWrapper(){
1064+
return(
1065+
<Suspensefallback={<FallbackConsumer/>}>
1066+
<AsyncChild/>
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
functionFallbackConsumer(){
1072+
constvalue=useContext(Context);
1073+
return<Texttext={'Fallback: '+value}/>;
1074+
}
1075+
1076+
functionAsyncChild(){
1077+
readText('async');
1078+
return<Texttext="Content"/>;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
awaitact(()=>{
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+
awaitact(()=>{
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+
awaitact(async()=>{
1111+
awaitresolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

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); } })(); })(); [Fiber] Fix context propagation into Suspense fallbacks (#36160) · react/react@9627b5a · GitHub
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+
constroot=ReactNoop.createRoot();
1049+
constContext=React.createContext('A');
1050+
1051+
letsetContext;
1052+
functionApp(){
1053+
const[value,_setValue]=useState('A');
1054+
setContext=_setValue;
1055+
return(
1056+
<Context.Providervalue={value}>
1057+
<MemoizedWrapper/>
1058+
<Texttext={value}/>
1059+
</Context.Provider>
1060+
);
1061+
}
1062+
1063+
constMemoizedWrapper=React.memo(functionMemoizedWrapper(){
1064+
return(
1065+
<Suspensefallback={<FallbackConsumer/>}>
1066+
<AsyncChild/>
1067+
</Suspense>
1068+
);
1069+
});
1070+
1071+
functionFallbackConsumer(){
1072+
constvalue=useContext(Context);
1073+
return<Texttext={'Fallback: '+value}/>;
1074+
}
1075+
1076+
functionAsyncChild(){
1077+
readText('async');
1078+
return<Texttext="Content"/>;
1079+
}
1080+
1081+
// Initial render — primary content suspends, fallback is shown
1082+
awaitact(()=>{
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+
awaitact(()=>{
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+
awaitact(async()=>{
1111+
awaitresolveText('async');
1112+
});
1113+
assertLog(['Content']);
1114+
expect(root).toMatchRenderedOutput('ContentB');
1115+
});
10401116
});

0 commit comments

Comments
 (0)