[Fizz] Name content inside "Suspense fallback" (#33723)
Content in Suspense fallbacks are really not considered part of the Suspense but since it does have some behavior it should be marked somehow separately from the Suspense content. A follow up would be to do the same in Fiber.
Sebastian Markbåge committed
Jul 7, 2025 at 13:48 UTC
c932e457800f077352b1b322f14a796e589299fb
2 files changed
+74
-6
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+47
-1
@@ -816,6 +816,52 @@ describe('ReactDOMFizzServer', () => {
816
expect(loggedErrors).toEqual([theError]);
817
});
818
819
+ it('should have special stacks if Suspense fallback', async () => {
820
+ const infinitePromise = new Promise(() => {});
821
+ const InfiniteComponent = React.lazy(() => {
822
+ return infinitePromise;
823
+ });
824
+
825
+ function Throw({text}) {
826
+ throw new Error(text);
827
+ }
828
+
829
+ function App() {
830
+ return (
831
+ <Suspense fallback="Loading">
832
+ <div>
833
+ <Suspense fallback={<Throw text="Bye" />}>
834
+ <InfiniteComponent text="Hi" />
835
+ </Suspense>
836
+ </div>
837
+ </Suspense>
838
+ );
839
+ }
840
+
841
+ const loggedErrors = [];
842
+ function onError(x, errorInfo) {
843
+ loggedErrors.push({
844
+ message: x.message,
845
+ componentStack: errorInfo.componentStack,
846
+ });
847
+ return 'Hash of (' + x.message + ')';
848
+ }
849
+ loggedErrors.length = 0;
850
+
851
+ await act(() => {
852
+ const {pipe} = renderToPipeableStream(<App />, {
853
+ onError,
854
+ });
855
+ pipe(writable);
856
+ });
857
+
858
+ expect(loggedErrors.length).toBe(1);
859
+ expect(loggedErrors[0].message).toBe('Bye');
860
+ expect(normalizeCodeLocInfo(loggedErrors[0].componentStack)).toBe(
861
+ componentStack(['Throw', 'Suspense Fallback', 'div', 'Suspense', 'App']),
862
+ );
863
+ });
864
+
865
it('should asynchronously load a lazy element', async () => {
866
let resolveElement;
867
const lazyElement = React.lazy(() => {
@@ -1797,7 +1843,7 @@ describe('ReactDOMFizzServer', () => {
1843
function normalizeCodeLocInfo(str) {
1844
return (
1845
str &&
1800
- String(str).replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
1846
+ String(str).replace(/\n +(?:at|in) ([^\(]+) [^\n]*/g, function (m, name) {
1847
return '\n in ' + name + ' (at **)';
1848
})
1849
);
packages/react-server/src/ReactFizzServer.js
+27
-5
@@ -1105,8 +1105,8 @@ function pushComponentStack(task: Task): void {
1105
function createComponentStackFromType(
1106
parent: null | ComponentStackNode,
1107
type: Function | string | symbol,
1108
- owner: null | ReactComponentInfo | ComponentStackNode, // DEV only
1109
- stack: null | Error, // DEV only
1108
+ owner: void | null | ReactComponentInfo | ComponentStackNode, // DEV only
1109
+ stack: void | null | string | Error, // DEV only
1110
): ComponentStackNode {
1111
if (__DEV__) {
1112
return {
@@ -1122,6 +1122,20 @@ function createComponentStackFromType(
1122
};
1123
}
1124
1125
+function replaceSuspenseComponentStackWithSuspenseFallbackStack(
1126
+ componentStack: null | ComponentStackNode,
1127
+): null | ComponentStackNode {
1128
+ if (componentStack === null) {
1129
+ return null;
1130
+ }
1131
+ return createComponentStackFromType(
1132
+ componentStack.parent,
1133
+ 'Suspense Fallback',
1134
+ __DEV__ ? componentStack.owner : null,
1135
+ __DEV__ ? componentStack.stack : null,
1136
+ );
1137
+}
1138
+
1139
type ThrownInfo = {
1140
componentStack?: string,
1141
};
@@ -1350,6 +1364,8 @@ function renderSuspenseBoundary(
1364
contentRootSegment.parentFlushed = true;
1365
1366
if (request.trackedPostpones !== null) {
1367
+ // Stash the original stack frame.
1368
+ const suspenseComponentStack = task.componentStack;
1369
// This is a prerender. In this mode we want to render the fallback synchronously and schedule
1370
// the content to render later. This is the opposite of what we do during a normal render
1371
// where we try to skip rendering the fallback if the content itself can render synchronously
@@ -1374,6 +1390,10 @@ function renderSuspenseBoundary(
1390
request.resumableState,
1391
prevContext,
1392
);
1393
+ task.componentStack =
1394
+ replaceSuspenseComponentStackWithSuspenseFallbackStack(
1395
+ suspenseComponentStack,
1396
+ );
1397
boundarySegment.status = RENDERING;
1398
try {
1399
renderNode(request, task, fallback, -1);
@@ -1419,7 +1439,7 @@ function renderSuspenseBoundary(
1439
task.context,
1440
task.treeContext,
1441
null, // The row gets reset inside the Suspense boundary.
1422
- task.componentStack,
1442
+ suspenseComponentStack,
1443
!disableLegacyContext ? task.legacyContext : emptyContextObject,
1444
__DEV__ ? task.debugTask : null,
1445
);
@@ -1572,7 +1592,9 @@ function renderSuspenseBoundary(
1592
task.context,
1593
task.treeContext,
1594
task.row,
1575
- task.componentStack,
1595
+ replaceSuspenseComponentStackWithSuspenseFallbackStack(
1596
+ task.componentStack,
1597
+ ),
1598
!disableLegacyContext ? task.legacyContext : emptyContextObject,
1599
__DEV__ ? task.debugTask : null,
1600
);
@@ -1744,7 +1766,7 @@ function replaySuspenseBoundary(
1766
task.context,
1767
task.treeContext,
1768
task.row,
1747
- task.componentStack,
1769
+ replaceSuspenseComponentStackWithSuspenseFallbackStack(task.componentStack),
1770
!disableLegacyContext ? task.legacyContext : emptyContextObject,
1771
__DEV__ ? task.debugTask : null,
1772
);