@samitouri / QOS-React / commits / 914319ae59

[Flight] Don't hang forever when prerendering a rejected promise (#32953)

Hendrik Liebau committed Apr 23, 2025 at 11:02 UTC 914319ae595010cd5d3f0e277c77eb86da18e4f0
3 files changed +299 -17
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+281
@@ -1354,4 +1354,285 @@ describe('ReactFlightDOMEdge', () => {
1354 expect(error).not.toBe(null);
1355 expect(error.message).toBe('Connection closed.');
1356 });
1357 +
1358 + // @gate experimental
1359 + it('should be able to handle a rejected promise in unstable_prerender', async () => {
1360 + const expectedError = new Error('Bam!');
1361 + const errors = [];
1362 +
1363 + const {prelude} = await ReactServerDOMStaticServer.unstable_prerender(
1364 + Promise.reject(expectedError),
1365 + webpackMap,
1366 + {
1367 + onError(err) {
1368 + errors.push(err);
1369 + },
1370 + },
1371 + );
1372 +
1373 + expect(errors).toEqual([expectedError]);
1374 +
1375 + const response = ReactServerDOMClient.createFromReadableStream(prelude, {
1376 + serverConsumerManifest: {
1377 + moduleMap: {},
1378 + moduleLoading: {},
1379 + },
1380 + });
1381 +
1382 + let error = null;
1383 + try {
1384 + await response;
1385 + } catch (x) {
1386 + error = x;
1387 + }
1388 +
1389 + const expectedMessage = __DEV__
1390 + ? expectedError.message
1391 + : 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.';
1392 +
1393 + expect(error).not.toBe(null);
1394 + expect(error.message).toBe(expectedMessage);
1395 + });
1396 +
1397 + // @gate experimental
1398 + it('should be able to handle an erroring async iterable in unstable_prerender', async () => {
1399 + const expectedError = new Error('Bam!');
1400 + const errors = [];
1401 +
1402 + const {prelude} = await ReactServerDOMStaticServer.unstable_prerender(
1403 + {
1404 + async *[Symbol.asyncIterator]() {
1405 + await serverAct(() => {
1406 + throw expectedError;
1407 + });
1408 + },
1409 + },
1410 + webpackMap,
1411 + {
1412 + onError(err) {
1413 + errors.push(err);
1414 + },
1415 + },
1416 + );
1417 +
1418 + expect(errors).toEqual([expectedError]);
1419 +
1420 + const response = ReactServerDOMClient.createFromReadableStream(prelude, {
1421 + serverConsumerManifest: {
1422 + moduleMap: {},
1423 + moduleLoading: {},
1424 + },
1425 + });
1426 +
1427 + let error = null;
1428 + try {
1429 + const result = await response;
1430 + const iterator = result[Symbol.asyncIterator]();
1431 + await iterator.next();
1432 + } catch (x) {
1433 + error = x;
1434 + }
1435 +
1436 + const expectedMessage = __DEV__
1437 + ? expectedError.message
1438 + : 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.';
1439 +
1440 + expect(error).not.toBe(null);
1441 + expect(error.message).toBe(expectedMessage);
1442 + });
1443 +
1444 + // @gate experimental
1445 + it('should be able to handle an erroring readable stream in unstable_prerender', async () => {
1446 + const expectedError = new Error('Bam!');
1447 + const errors = [];
1448 +
1449 + const {prelude} = await ReactServerDOMStaticServer.unstable_prerender(
1450 + new ReadableStream({
1451 + async start(controller) {
1452 + await serverAct(() => {
1453 + setTimeout(() => {
1454 + controller.error(expectedError);
1455 + });
1456 + });
1457 + },
1458 + }),
1459 + webpackMap,
1460 + {
1461 + onError(err) {
1462 + errors.push(err);
1463 + },
1464 + },
1465 + );
1466 +
1467 + expect(errors).toEqual([expectedError]);
1468 +
1469 + const response = ReactServerDOMClient.createFromReadableStream(prelude, {
1470 + serverConsumerManifest: {
1471 + moduleMap: {},
1472 + moduleLoading: {},
1473 + },
1474 + });
1475 +
1476 + let error = null;
1477 + try {
1478 + const stream = await response;
1479 + await stream.getReader().read();
1480 + } catch (x) {
1481 + error = x;
1482 + }
1483 +
1484 + const expectedMessage = __DEV__
1485 + ? expectedError.message
1486 + : 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.';
1487 +
1488 + expect(error).not.toBe(null);
1489 + expect(error.message).toBe(expectedMessage);
1490 + });
1491 +
1492 + // @gate experimental
1493 + it('can prerender an async iterable', async () => {
1494 + const errors = [];
1495 +
1496 + const {prelude} = await ReactServerDOMStaticServer.unstable_prerender(
1497 + {
1498 + async *[Symbol.asyncIterator]() {
1499 + yield 'hello';
1500 + yield ' ';
1501 + yield 'world';
1502 + },
1503 + },
1504 + webpackMap,
1505 + {
1506 + onError(err) {
1507 + errors.push(err);
1508 + },
1509 + },
1510 + );
1511 +
1512 + expect(errors).toEqual([]);
1513 +
1514 + const response = ReactServerDOMClient.createFromReadableStream(prelude, {
1515 + serverConsumerManifest: {
1516 + moduleMap: {},
1517 + moduleLoading: {},
1518 + },
1519 + });
1520 +
1521 + let text = '';
1522 + const result = await response;
1523 + const iterator = result[Symbol.asyncIterator]();
1524 +
1525 + while (true) {
1526 + const {done, value} = await iterator.next();
1527 + if (done) {
1528 + break;
1529 + }
1530 + text += value;
1531 + }
1532 +
1533 + expect(text).toBe('hello world');
1534 + });
1535 +
1536 + // @gate experimental
1537 + it('can prerender a readable stream', async () => {
1538 + const errors = [];
1539 +
1540 + const {prelude} = await ReactServerDOMStaticServer.unstable_prerender(
1541 + new ReadableStream({
1542 + start(controller) {
1543 + controller.enqueue('hello world');
1544 + controller.close();
1545 + },
1546 + }),
1547 + webpackMap,
1548 + {
1549 + onError(err) {
1550 + errors.push(err);
1551 + },
1552 + },
1553 + );
1554 +
1555 + expect(errors).toEqual([]);
1556 +
1557 + const response = ReactServerDOMClient.createFromReadableStream(prelude, {
1558 + serverConsumerManifest: {
1559 + moduleMap: {},
1560 + moduleLoading: {},
1561 + },
1562 + });
1563 +
1564 + const stream = await response;
1565 + const result = await readResult(stream);
1566 +
1567 + expect(result).toBe('hello world');
1568 + });
1569 +
1570 + // @gate experimental
1571 + it('does not return a prerender prelude early when an error is emitted and there are still pending tasks', async () => {
1572 + let rejectPromise;
1573 + const rejectingPromise = new Promise(
1574 + (resolve, reject) => (rejectPromise = reject),
1575 + );
1576 + const expectedError = new Error('Boom!');
1577 + const errors = [];
1578 +
1579 + const {prelude} = await ReactServerDOMStaticServer.unstable_prerender(
1580 + [
1581 + rejectingPromise,
1582 + {
1583 + async *[Symbol.asyncIterator]() {
1584 + yield 'hello';
1585 + yield ' ';
1586 + await serverAct(() => {
1587 + rejectPromise(expectedError);
1588 + });
1589 + yield 'world';
1590 + },
1591 + },
1592 + ],
1593 + webpackMap,
1594 + {
1595 + onError(err) {
1596 + errors.push(err);
1597 + },
1598 + },
1599 + );
1600 +
1601 + expect(errors).toEqual([expectedError]);
1602 +
1603 + const response = ReactServerDOMClient.createFromReadableStream(prelude, {
1604 + serverConsumerManifest: {
1605 + moduleMap: {},
1606 + moduleLoading: {},
1607 + },
1608 + });
1609 +
1610 + let text = '';
1611 + const [promise, iterable] = await response;
1612 + const iterator = iterable[Symbol.asyncIterator]();
1613 +
1614 + while (true) {
1615 + const {done, value} = await iterator.next();
1616 + if (done) {
1617 + break;
1618 + }
1619 + text += value;
1620 + }
1621 +
1622 + expect(text).toBe('hello world');
1623 +
1624 + let error = null;
1625 + try {
1626 + await promise;
1627 + } catch (x) {
1628 + error = x;
1629 + }
1630 +
1631 + const expectedMessage = __DEV__
1632 + ? expectedError.message
1633 + : 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.';
1634 +
1635 + expect(error).not.toBe(null);
1636 + expect(error.message).toBe(expectedMessage);
1637 + });
1638 });
packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js
-3
@@ -121,9 +121,6 @@ function prerender(
121 const stream = new ReadableStream(
122 {
123 type: 'bytes',
124 - start: (controller): ?Promise<void> => {
125 - startWork(request);
126 - },
124 pull: (controller): ?Promise<void> => {
125 startFlowing(request, controller);
126 },
packages/react-server/src/ReactFlightServer.js
+18 -14
@@ -768,10 +768,11 @@ function serializeReadableStream(
768 }
769
770 if (entry.done) {
771 - request.abortListeners.delete(abortStream);
771 const endStreamRow = streamTask.id.toString(16) + ':C\n';
772 request.completedRegularChunks.push(stringToChunk(endStreamRow));
773 enqueueFlush(request);
774 + request.abortListeners.delete(abortStream);
775 + callOnAllReadyIfReady(request);
776 aborted = true;
777 } else {
778 try {
@@ -869,7 +870,6 @@ function serializeAsyncIterable(
870 }
871
872 if (entry.done) {
872 - request.abortListeners.delete(abortIterable);
873 let endStreamRow;
874 if (entry.value === undefined) {
875 endStreamRow = streamTask.id.toString(16) + ':C\n';
@@ -890,6 +890,8 @@ function serializeAsyncIterable(
890 }
891 request.completedRegularChunks.push(stringToChunk(endStreamRow));
892 enqueueFlush(request);
893 + request.abortListeners.delete(abortIterable);
894 + callOnAllReadyIfReady(request);
895 aborted = true;
896 } else {
897 try {
@@ -3901,7 +3903,6 @@ function erroredTask(request: Request, task: Task, error: mixed): void {
3903 emitTimingChunk(request, task.id, performance.now());
3904 }
3905 }
3904 - request.abortableTasks.delete(task);
3906 task.status = ERRORED;
3907 if (
3908 enablePostpone &&
@@ -3916,6 +3917,8 @@ function erroredTask(request: Request, task: Task, error: mixed): void {
3917 const digest = logRecoverableError(request, error, task);
3918 emitErrorChunk(request, task.id, digest, error);
3919 }
3920 + request.abortableTasks.delete(task);
3921 + callOnAllReadyIfReady(request);
3922 }
3923
3924 const emptyRoot = {};
@@ -3995,8 +3998,9 @@ function retryTask(request: Request, task: Task): void {
3998 emitModelChunk(request, task.id, json);
3999 }
4000
3998 - request.abortableTasks.delete(task);
4001 task.status = COMPLETED;
4002 + request.abortableTasks.delete(task);
4003 + callOnAllReadyIfReady(request);
4004 } catch (thrownValue) {
4005 if (request.status === ABORTING) {
4006 request.abortableTasks.delete(task);
@@ -4067,7 +4071,6 @@ function performWork(request: Request): void {
4071 currentRequest = request;
4072 prepareToUseHooksForRequest(request);
4073
4070 - const hadAbortableTasks = request.abortableTasks.size > 0;
4074 try {
4075 const pingedTasks = request.pingedTasks;
4076 request.pingedTasks = [];
@@ -4078,13 +4081,6 @@ function performWork(request: Request): void {
4081 if (request.destination !== null) {
4082 flushCompletedChunks(request, request.destination);
4083 }
4081 - if (hadAbortableTasks && request.abortableTasks.size === 0) {
4082 - // We can ping after completing but if this happens there already
4083 - // wouldn't be any abortable tasks. So we only call allReady after
4084 - // the work which actually completed the last pending task
4085 - const onAllReady = request.onAllReady;
4086 - onAllReady();
4087 - }
4084 } catch (error) {
4085 logRecoverableError(request, error, null);
4086 fatalError(request, error);
@@ -4246,6 +4242,12 @@ function enqueueFlush(request: Request): void {
4242 }
4243 }
4244
4245 +function callOnAllReadyIfReady(request: Request): void {
4246 + if (request.abortableTasks.size === 0 && request.abortListeners.size === 0) {
4247 + request.onAllReady();
4248 + }
4249 +}
4250 +
4251 export function startFlowing(request: Request, destination: Destination): void {
4252 if (request.status === CLOSING) {
4253 request.status = CLOSED;
@@ -4285,6 +4287,7 @@ export function abort(request: Request, reason: mixed): void {
4287 // and leave the reference unfulfilled.
4288 abortableTasks.forEach(task => haltTask(task, request));
4289 abortableTasks.clear();
4290 + callOnAllReadyIfReady(request);
4291 } else if (
4292 enablePostpone &&
4293 typeof reason === 'object' &&
@@ -4301,6 +4304,7 @@ export function abort(request: Request, reason: mixed): void {
4304 emitPostponeChunk(request, errorId, postponeInstance);
4305 abortableTasks.forEach(task => abortTask(task, request, errorId));
4306 abortableTasks.clear();
4307 + callOnAllReadyIfReady(request);
4308 } else {
4309 const error =
4310 reason === undefined
@@ -4323,9 +4327,8 @@ export function abort(request: Request, reason: mixed): void {
4327 emitErrorChunk(request, errorId, digest, error);
4328 abortableTasks.forEach(task => abortTask(task, request, errorId));
4329 abortableTasks.clear();
4330 + callOnAllReadyIfReady(request);
4331 }
4327 - const onAllReady = request.onAllReady;
4328 - onAllReady();
4332 }
4333 const abortListeners = request.abortListeners;
4334 if (abortListeners.size > 0) {
@@ -4356,6 +4359,7 @@ export function abort(request: Request, reason: mixed): void {
4359 }
4360 abortListeners.forEach(callback => callback(error));
4361 abortListeners.clear();
4362 + callOnAllReadyIfReady(request);
4363 }
4364 if (request.destination !== null) {
4365 flushCompletedChunks(request, request.destination);