[Flight] Support postponing through a serialized promise (#27818)
Postponing in a promise that is being serialized to the client from the server should be possible however prior to this change Flight treated this case like an error rather than a postpone. This fix adds support for postponing in this position and adds a test asserting you can successfully prerender the root if you unwrap this promise inside a suspense boundary.
Josh Story committed
Dec 8, 2023 at 11:05 UTC
5bcade5fcf5610e82e7cda05cc6de574bdace0c7
2 files changed
+101
-4
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+87
@@ -32,6 +32,7 @@ let ReactDOMClient;
32
let ReactServerDOMServer;
33
let ReactServerDOMClient;
34
let ReactDOMFizzServer;
35
+let ReactDOMStaticServer;
36
let Suspense;
37
let ErrorBoundary;
38
let JSDOM;
@@ -71,6 +72,7 @@ describe('ReactFlightDOM', () => {
72
Suspense = React.Suspense;
73
ReactDOMClient = require('react-dom/client');
74
ReactDOMFizzServer = require('react-dom/server.node');
75
+ ReactDOMStaticServer = require('react-dom/static.node');
76
ReactServerDOMClient = require('react-server-dom-webpack/client');
77
78
ErrorBoundary = class extends React.Component {
@@ -1300,6 +1302,91 @@ describe('ReactFlightDOM', () => {
1302
expect(getMeaningfulChildren(container)).toEqual(<p>hello world</p>);
1303
});
1304
1305
+ // @gate enablePostpone
1306
+ it('should allow postponing in Flight through a serialized promise', async () => {
1307
+ const Context = React.createContext();
1308
+ const ContextProvider = Context.Provider;
1309
+
1310
+ function Foo() {
1311
+ const value = React.use(React.useContext(Context));
1312
+ return <span>{value}</span>;
1313
+ }
1314
+
1315
+ const ClientModule = clientExports({
1316
+ ContextProvider,
1317
+ Foo,
1318
+ });
1319
+
1320
+ async function getFoo() {
1321
+ React.unstable_postpone('foo');
1322
+ }
1323
+
1324
+ function App() {
1325
+ return (
1326
+ <ClientModule.ContextProvider value={getFoo()}>
1327
+ <div>
1328
+ <Suspense fallback="loading...">
1329
+ <ClientModule.Foo />
1330
+ </Suspense>
1331
+ </div>
1332
+ </ClientModule.ContextProvider>
1333
+ );
1334
+ }
1335
+
1336
+ const {writable, readable} = getTestStream();
1337
+
1338
+ const {pipe} = ReactServerDOMServer.renderToPipeableStream(
1339
+ <App />,
1340
+ webpackMap,
1341
+ );
1342
+ pipe(writable);
1343
+
1344
+ let response = null;
1345
+ function getResponse() {
1346
+ if (response === null) {
1347
+ response = ReactServerDOMClient.createFromReadableStream(readable);
1348
+ }
1349
+ return response;
1350
+ }
1351
+
1352
+ function Response() {
1353
+ return getResponse();
1354
+ }
1355
+
1356
+ const errors = [];
1357
+ function onError(error, errorInfo) {
1358
+ errors.push(error, errorInfo);
1359
+ }
1360
+ const result = await ReactDOMStaticServer.prerenderToNodeStream(
1361
+ <Response />,
1362
+ {
1363
+ onError,
1364
+ },
1365
+ );
1366
+
1367
+ const prelude = await new Promise((resolve, reject) => {
1368
+ let content = '';
1369
+ result.prelude.on('data', chunk => {
1370
+ content += Buffer.from(chunk).toString('utf8');
1371
+ });
1372
+ result.prelude.on('error', error => {
1373
+ reject(error);
1374
+ });
1375
+ result.prelude.on('end', () => resolve(content));
1376
+ });
1377
+
1378
+ expect(errors).toEqual([]);
1379
+ const doc = new JSDOM(prelude).window.document;
1380
+ expect(getMeaningfulChildren(doc)).toEqual(
1381
+ <html>
1382
+ <head />
1383
+ <body>
1384
+ <div>loading...</div>
1385
+ </body>
1386
+ </html>,
1387
+ );
1388
+ });
1389
+
1390
it('should support float methods when rendering in Fizz', async () => {
1391
function Component() {
1392
return <p>hello world</p>;
packages/react-server/src/ReactFlightServer.js
+14
-4
@@ -407,11 +407,21 @@ function serializeThenable(request: Request, thenable: Thenable<any>): number {
407
pingTask(request, newTask);
408
},
409
reason => {
410
- newTask.status = ERRORED;
410
+ if (
411
+ enablePostpone &&
412
+ typeof reason === 'object' &&
413
+ reason !== null &&
414
+ (reason: any).$$typeof === REACT_POSTPONE_TYPE
415
+ ) {
416
+ const postponeInstance: Postpone = (reason: any);
417
+ logPostpone(request, postponeInstance.message);
418
+ emitPostponeChunk(request, newTask.id, postponeInstance);
419
+ } else {
420
+ newTask.status = ERRORED;
421
+ const digest = logRecoverableError(request, reason);
422
+ emitErrorChunk(request, newTask.id, digest, reason);
423
+ }
424
request.abortableTasks.delete(newTask);
412
- // TODO: We should ideally do this inside performWork so it's scheduled
413
- const digest = logRecoverableError(request, reason);
414
- emitErrorChunk(request, newTask.id, digest, reason);
425
if (request.destination !== null) {
426
flushCompletedChunks(request, request.destination);
427
}