Skip to content

Commit 64c896b

Browse files
committed
[Fizz] Push halted await to the owner stack for late-arriving I/O info
To quote from facebook#33634: > If an aborted task is not rendering, then this is an async abort. > Conceptually it's as if the abort happened inside the async gap. The > abort reason's stack frame won't have that on the stack so instead we > use the owner stack and debug task of any halted async debug info. This PR extends that logic to also try to resolve lazy components to find debug info that has been transferred to the inner value. In addition, we ignore any time and component info that might precede the I/O info, effectively allowing resolved I/O to also be considered for the owner stack. This is useful in a scenario where the Flight rendering might have been completed (and not prematurely aborted), but then the Fizz rendering is intentionally aborted before all chunks were received, while still allowing the remaining chunks (including I/O info for halted components) to be processed while the prerender is in the aborting state.
1 parent 3a0ab8a commit 64c896b

File tree

2 files changed

+241
-14
lines changed

2 files changed

+241
-14
lines changed

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js

Lines changed: 211 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,25 @@ describe('ReactFlightDOMNode', () => {
8686
);
8787
}
8888

89-
function normalizeCodeLocInfo(str) {
89+
const relativeFilename = path.relative(__dirname, __filename);
90+
91+
function normalizeCodeLocInfo(str, {preserveLocation = false} = {}) {
9092
return (
9193
str &&
92-
str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) {
93-
return ' in ' + name + (/\d/.test(m) ? ' (at **)' : '');
94-
})
94+
str.replace(
95+
/^ +(?:at|in) ([\S]+) ([^\n]*)/gm,
96+
function (m, name, location) {
97+
return (
98+
' in ' +
99+
name +
100+
(/\d/.test(m)
101+
? preserveLocation
102+
? ' ' + location.replace(__filename, relativeFilename)
103+
: ' (at **)'
104+
: '')
105+
);
106+
},
107+
)
95108
);
96109
}
97110

@@ -1169,4 +1182,198 @@ describe('ReactFlightDOMNode', () => {
11691182
// Must not throw an error.
11701183
await readable.pipeTo(writable);
11711184
});
1185+
1186+
describe('with real timers', () => {
1187+
// These tests schedule their rendering in a way that requires real timers
1188+
// to be used to accurately represent how this interacts with React's
1189+
// internal scheduling.
1190+
1191+
beforeEach(() => {
1192+
jest.useRealTimers();
1193+
});
1194+
1195+
afterEach(() => {
1196+
jest.useFakeTimers();
1197+
});
1198+
1199+
it('should use late-arriving I/O debug info to enhance component and owner stacks when aborting a prerender', async () => {
1200+
let resolveDynamicData;
1201+
1202+
async function getCachedData() {
1203+
// Cached data resolves in microtasks.
1204+
return Promise.resolve('Hi');
1205+
}
1206+
1207+
async function getDynamicData() {
1208+
return new Promise(resolve => {
1209+
resolveDynamicData = resolve;
1210+
});
1211+
}
1212+
1213+
async function Dynamic() {
1214+
const cachedData = await getCachedData();
1215+
const dynamicData = await getDynamicData();
1216+
1217+
return (
1218+
<p>
1219+
{cachedData} {dynamicData}
1220+
</p>
1221+
);
1222+
}
1223+
1224+
function App() {
1225+
return ReactServer.createElement(
1226+
'html',
1227+
null,
1228+
ReactServer.createElement(
1229+
'body',
1230+
null,
1231+
ReactServer.createElement(Dynamic),
1232+
),
1233+
);
1234+
}
1235+
1236+
const stream = await ReactServerDOMServer.renderToPipeableStream(
1237+
ReactServer.createElement(App),
1238+
webpackMap,
1239+
{filterStackFrame},
1240+
);
1241+
1242+
const staticChunks = [];
1243+
const dynamicChunks = [];
1244+
let isStatic = true;
1245+
1246+
const passThrough = new Stream.PassThrough(streamOptions);
1247+
stream.pipe(passThrough);
1248+
1249+
// Split chunks into static and dynamic chunks.
1250+
passThrough.on('data', chunk => {
1251+
if (isStatic) {
1252+
staticChunks.push(chunk);
1253+
} else {
1254+
dynamicChunks.push(chunk);
1255+
}
1256+
});
1257+
1258+
await new Promise(resolve => {
1259+
setTimeout(() => {
1260+
isStatic = false;
1261+
resolveDynamicData('Josh');
1262+
resolve();
1263+
});
1264+
});
1265+
1266+
await new Promise(resolve => {
1267+
passThrough.on('end', resolve);
1268+
});
1269+
1270+
// Create a new Readable and push all static chunks immediately.
1271+
const readable = new Stream.Readable({...streamOptions, read() {}});
1272+
for (let i = 0; i < staticChunks.length; i++) {
1273+
readable.push(staticChunks[i]);
1274+
}
1275+
1276+
const abortController = new AbortController();
1277+
1278+
// When prerendering is aborted, push all dynamic chunks.
1279+
abortController.signal.addEventListener(
1280+
'abort',
1281+
() => {
1282+
for (let i = 0; i < dynamicChunks.length; i++) {
1283+
readable.push(dynamicChunks[i]);
1284+
}
1285+
},
1286+
{once: true},
1287+
);
1288+
1289+
const response = ReactServerDOMClient.createFromNodeStream(readable, {
1290+
serverConsumerManifest: {
1291+
moduleMap: null,
1292+
moduleLoading: null,
1293+
},
1294+
});
1295+
1296+
function ClientRoot() {
1297+
return use(response);
1298+
}
1299+
1300+
let componentStack;
1301+
let ownerStack;
1302+
1303+
const {prelude} = await new Promise(resolve => {
1304+
let result;
1305+
1306+
setTimeout(() => {
1307+
result = ReactDOMFizzStatic.prerenderToNodeStream(
1308+
React.createElement(ClientRoot),
1309+
{
1310+
signal: abortController.signal,
1311+
onError(error, errorInfo) {
1312+
componentStack = errorInfo.componentStack;
1313+
ownerStack = React.captureOwnerStack
1314+
? React.captureOwnerStack()
1315+
: null;
1316+
},
1317+
},
1318+
);
1319+
});
1320+
1321+
setTimeout(() => {
1322+
abortController.abort();
1323+
resolve(result);
1324+
});
1325+
});
1326+
1327+
const prerenderHTML = await readResult(prelude);
1328+
1329+
expect(prerenderHTML).toContain('');
1330+
1331+
if (__DEV__) {
1332+
expect(
1333+
normalizeCodeLocInfo(componentStack, {preserveLocation: true}),
1334+
).toBe(
1335+
'\n' +
1336+
' in Dynamic' +
1337+
(gate(flags => flags.enableAsyncDebugInfo)
1338+
? ' (file://ReactFlightDOMNode-test.js:1215:33)\n'
1339+
: '\n') +
1340+
' in body\n' +
1341+
' in html\n' +
1342+
' in App (file://ReactFlightDOMNode-test.js:1231:25)\n' +
1343+
' in ClientRoot (ReactFlightDOMNode-test.js:1297:16)',
1344+
);
1345+
} else {
1346+
expect(
1347+
normalizeCodeLocInfo(componentStack, {preserveLocation: true}),
1348+
).toBe(
1349+
'\n' +
1350+
' in body\n' +
1351+
' in html\n' +
1352+
' in ClientRoot (ReactFlightDOMNode-test.js:1297:16)',
1353+
);
1354+
}
1355+
1356+
if (__DEV__) {
1357+
if (gate(flags => flags.enableAsyncDebugInfo)) {
1358+
expect(
1359+
normalizeCodeLocInfo(ownerStack, {preserveLocation: true}),
1360+
).toBe(
1361+
'\n' +
1362+
' in Dynamic (file://ReactFlightDOMNode-test.js:1215:33)\n' +
1363+
' in App (file://ReactFlightDOMNode-test.js:1231:25)',
1364+
);
1365+
} else {
1366+
expect(
1367+
normalizeCodeLocInfo(ownerStack, {preserveLocation: true}),
1368+
).toBe(
1369+
'' +
1370+
'\n' +
1371+
' in App (file://ReactFlightDOMNode-test.js:1231:25)',
1372+
);
1373+
}
1374+
} else {
1375+
expect(ownerStack).toBeNull();
1376+
}
1377+
});
1378+
});
11721379
});

packages/react-server/src/ReactFizzServer.js

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,14 +1004,6 @@ function pushHaltedAwaitOnComponentStack(
10041004
if (debugInfo != null) {
10051005
for (let i = debugInfo.length - 1; i >= 0; i--) {
10061006
const info = debugInfo[i];
1007-
if (typeof info.name === 'string') {
1008-
// This is a Server Component. Any awaits in previous Server Components already resolved.
1009-
break;
1010-
}
1011-
if (typeof info.time === 'number') {
1012-
// This had an end time. Any awaits before this must have already resolved.
1013-
break;
1014-
}
10151007
if (info.awaited != null) {
10161008
const asyncInfo: ReactAsyncInfo = (info: any);
10171009
const bestStack =
@@ -4653,10 +4645,38 @@ function abortTask(task: Task, request: Request, error: mixed): void {
46534645
// If the task is not rendering, then this is an async abort. Conceptually it's as if
46544646
// the abort happened inside the async gap. The abort reason's stack frame won't have that
46554647
// on the stack so instead we use the owner stack and debug task of any halted async debug info.
4656-
const node: any = task.node;
4648+
let node: any = task.node;
46574649
if (node !== null && typeof node === 'object') {
46584650
// Push a fake component stack frame that represents the await.
4659-
pushHaltedAwaitOnComponentStack(task, node._debugInfo);
4651+
let debugInfo = node._debugInfo;
4652+
if (debugInfo == null || debugInfo.length === 0) {
4653+
// If there's no debug info, try resolving lazy components to find debug
4654+
// info that has been transferred to the inner value.
4655+
while (
4656+
typeof node === 'object' &&
4657+
node !== null &&
4658+
node.$$typeof === REACT_LAZY_TYPE
4659+
) {
4660+
const payload = node._payload;
4661+
if (payload.status === 'fulfilled') {
4662+
node = payload.value;
4663+
continue;
4664+
}
4665+
break;
4666+
}
4667+
if (
4668+
typeof node === 'object' &&
4669+
node !== null &&
4670+
(isArray(node) ||
4671+
typeof node[ASYNC_ITERATOR] === 'function' ||
4672+
node.$$typeof === REACT_ELEMENT_TYPE ||
4673+
node.$$typeof === REACT_LAZY_TYPE) &&
4674+
isArray(node._debugInfo)
4675+
) {
4676+
debugInfo = node._debugInfo;
4677+
}
4678+
}
4679+
pushHaltedAwaitOnComponentStack(task, debugInfo);
46604680
/*
46614681
if (task.thenableState !== null) {
46624682
// TODO: If we were stalled inside use() of a Client Component then we should

0 commit comments

Comments
 (0)