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