@samitouri / QOS-React-1 / commits / c932e45780

[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 );