diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index db2159570a932..ef947a15a33e2 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -2167,6 +2167,8 @@ function shouldRemainOnFallback(
// If we're already showing a fallback, there are cases where we need to
// remain on that fallback regardless of whether the content has resolved.
// For example, SuspenseList coordinates when nested content appears.
+ // TODO: For compatibility with offscreen prerendering, this should also check
+ // whether the current fiber (if it exists) was visible in the previous tree.
if (current !== null) {
const suspenseState: SuspenseState = current.memoizedState;
if (suspenseState === null) {
diff --git a/packages/react-reconciler/src/ReactFiberSuspenseContext.js b/packages/react-reconciler/src/ReactFiberSuspenseContext.js
index a64c8e90843b7..05850f92df584 100644
--- a/packages/react-reconciler/src/ReactFiberSuspenseContext.js
+++ b/packages/react-reconciler/src/ReactFiberSuspenseContext.js
@@ -45,6 +45,14 @@ export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void {
const current = handler.alternate;
const props: SuspenseProps = handler.pendingProps;
+ // Shallow Suspense context fields, like ForceSuspenseFallback, should only be
+ // propagated a single level. For example, when ForceSuspenseFallback is set,
+ // it should only force the nearest Suspense boundary into fallback mode.
+ pushSuspenseListContext(
+ handler,
+ setDefaultShallowSuspenseListContext(suspenseStackCursor.current),
+ );
+
// Experimental feature: Some Suspense boundaries are marked as having an
// undesirable fallback state. These have special behavior where we only
// activate the fallback if there's no other boundary on the stack that we can
@@ -100,6 +108,11 @@ export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void {
export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
if (fiber.tag === OffscreenComponent) {
+ // A SuspenseList context is only pushed here to avoid a push/pop mismatch.
+ // Reuse the current value on the stack.
+ // TODO: We can avoid needing to push here by by forking popSuspenseHandler
+ // into separate functions for Suspense and Offscreen.
+ pushSuspenseListContext(fiber, suspenseStackCursor.current);
push(suspenseHandlerStackCursor, fiber, fiber);
if (shellBoundary !== null) {
// A parent boundary is showing a fallback, so we've already rendered
@@ -122,6 +135,7 @@ export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
}
export function reuseSuspenseHandlerOnStack(fiber: Fiber) {
+ pushSuspenseListContext(fiber, suspenseStackCursor.current);
push(suspenseHandlerStackCursor, getSuspenseHandler(), fiber);
}
@@ -135,6 +149,7 @@ export function popSuspenseHandler(fiber: Fiber): void {
// Popping back into the shell.
shellBoundary = null;
}
+ popSuspenseListContext(fiber);
}
// SuspenseList context
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
index 39afe75732272..c3da64090e259 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
@@ -3003,4 +3003,108 @@ describe('ReactSuspenseList', () => {
>,
);
});
+
+ // @gate enableSuspenseList
+ it(
+ 'regression test: SuspenseList should never force boundaries deeper than ' +
+ 'a single level into fallback mode',
+ async () => {
+ const A = createAsyncText('A');
+
+ function UnreachableFallback() {
+ throw new Error('Should never be thrown!');
+ }
+
+ function Repro({update}) {
+ return (
+
+ {update && (
+ }>
+
+
+ )}
+ }>
+ {update ? (
+ }>
+
+
+ ) : (
+
+ )}
+
+ }>
+
+
+ {update && (
+ }>
+
+
+ )}
+
+ );
+ }
+
+ // Initial mount. Only two rows are mounted, B and C.
+ const root = ReactNoop.createRoot();
+ await act(() => root.render());
+ assertLog(['B1', 'C']);
+ expect(root).toMatchRenderedOutput(
+ <>
+ B1
+ C
+ >,
+ );
+
+ // During the update, a few things happen simultaneously:
+ // - A new row, A, is inserted into the head. This row suspends.
+ // - The context in row B is replaced. The new content contains a nested
+ // Suspense boundary.
+ // - A new row, D, is inserted into the tail.
+ await act(() => root.render());
+ assertLog([
+ // During the first pass, the new row, A, suspends. This means any new
+ // rows in the tail should be forced into fallback mode.
+ 'Suspend! [A]',
+ 'Loading A...',
+ 'B2',
+ 'C',
+
+ // A second pass is used to render the fallbacks in the tail.
+ //
+ // Rows B and C were already mounted, so they should not be forced into
+ // fallback mode.
+ //
+ // In the regression that this test was written for, the inner
+ // Suspense boundary around B2 was incorrectly activated. Only the
+ // nearest fallbacks per row should be activated, and only if they
+ // haven't already mounted.
+ 'Loading A...',
+ 'B2',
+ 'C',
+
+ // D is part of the tail, so it should show a fallback.
+ 'Loading D...',
+ ]);
+ expect(root).toMatchRenderedOutput(
+ <>
+ Loading A...
+ B2
+ C
+ Loading D...
+ >,
+ );
+
+ // Now finish loading A.
+ await act(() => A.resolve());
+ assertLog(['A', 'D']);
+ expect(root).toMatchRenderedOutput(
+ <>
+ A
+ B2
+ C
+ D
+ >,
+ );
+ },
+ );
});