@samitouri / QOS-React-2 / commits / 37fa36ced3

[Fizz] Fix crash when capturing the callsite of a stalled use() of a Flight chunk that was rejected in the meantime (#36544)

`ensureSuspendableThenableStateDEV` patches `then` in fulfilled thenables to avoid triggering a custom thenable's `then` in an unexpected state. However, we weren't doing the same for rejected thenables. This affected `ReactPromise`, the type used for thenables passed from server to client. if a `ReactPromise` passed to `use` was pending during the render but became rejected between the abort and `pushSuspendedCallSiteOnComponentStack`, then `ReactPromise#then` would crash. (see the added test for a reprouction) This is because we were putting the ReactPromise into an invalid state: a `PendingChunk` expects to have a `value: null | Array<...>`, but we were deleting `value` altogether, and tgus hitting `TypeError: can't access property "push" of undefined` here: https://github.com/facebook/react/blob/75b0945b18f4a60c80c931fd8067d9c715957879/packages/react-client/src/ReactFlightClient.js#L309 Bypassing the suspended thenable's `then` avoids this crash.

Janka Uryga committed May 26, 2026 at 21:48 UTC 37fa36ced31080c6446145921fd0883d272bd857
2 files changed +196 -2
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+183
@@ -1583,6 +1583,189 @@ describe('ReactFlightDOMNode', () => {
1583 }
1584 });
1585
1586 + it('should use late-arriving I/O debug info from rejected server promises to enhance component and owner stacks when aborting a prerender', async () => {
1587 + let rejectHangingPromise;
1588 +
1589 + async function makeHangingPromise() {
1590 + return new Promise((resolve, reject) => {
1591 + rejectHangingPromise = reject;
1592 + });
1593 + }
1594 +
1595 + async function getRoot() {
1596 + return {promise: makeHangingPromise()};
1597 + }
1598 +
1599 + let staticEndTime = -1;
1600 + const staticChunks = [];
1601 + const dynamicChunks = [];
1602 +
1603 + const serverAbortController = new AbortController();
1604 + await new Promise(resolve => {
1605 + setTimeout(async () => {
1606 + const stream = ReactServerDOMServer.renderToPipeableStream(
1607 + getRoot(),
1608 + webpackMap,
1609 + {
1610 + filterStackFrame,
1611 + onError(err) {
1612 + if (serverAbortController.signal.aborted) {
1613 + return;
1614 + }
1615 + console.error(err);
1616 + },
1617 + },
1618 + );
1619 + serverAbortController.signal.addEventListener(
1620 + 'abort',
1621 + () => {
1622 + stream.abort(serverAbortController.signal.reason);
1623 +
1624 + // Only reject the promise after the render is aborted
1625 + // so that it's no longer observable
1626 + rejectHangingPromise(
1627 + new Error(
1628 + 'Hanging promise was rejected after the prerender finished',
1629 + ),
1630 + );
1631 + },
1632 + {once: true},
1633 + );
1634 +
1635 + const passThrough = new Stream.PassThrough(streamOptions);
1636 + stream.pipe(passThrough);
1637 +
1638 + passThrough.on('data', chunk => {
1639 + if (staticEndTime < 0) {
1640 + staticChunks.push(chunk);
1641 + } else {
1642 + dynamicChunks.push(chunk);
1643 + }
1644 + });
1645 +
1646 + passThrough.on('end', resolve);
1647 + });
1648 + setTimeout(() => {
1649 + staticEndTime = performance.now() + performance.timeOrigin;
1650 + serverAbortController.abort();
1651 + });
1652 + });
1653 +
1654 + const clientAbortController = new AbortController();
1655 +
1656 + const serverStream = createReadableWithLateRelease(
1657 + staticChunks,
1658 + dynamicChunks,
1659 + clientAbortController.signal,
1660 + );
1661 +
1662 + const response = await ReactServerDOMClient.createFromNodeStream(
1663 + serverStream,
1664 + {
1665 + serverConsumerManifest: {
1666 + moduleMap: null,
1667 + moduleLoading: null,
1668 + },
1669 + },
1670 + {
1671 + // Debug info arriving after this end time will be ignored, e.g. the
1672 + // I/O info for the second dynamic data.
1673 + endTime: staticEndTime,
1674 + },
1675 + );
1676 +
1677 + const resolvedPromise = Promise.resolve('hello');
1678 + function ClientDynamic() {
1679 + use(resolvedPromise);
1680 + use(response.promise); // unresolved ReactPromise (becomes rejected when we abort)
1681 + }
1682 +
1683 + function ClientRoot() {
1684 + return React.createElement(
1685 + 'html',
1686 + null,
1687 + React.createElement(
1688 + 'body',
1689 + null,
1690 + React.createElement(
1691 + React.Suspense,
1692 + {fallback: 'Loading...'},
1693 + React.createElement(ClientDynamic),
1694 + ),
1695 + ),
1696 + );
1697 + }
1698 +
1699 + let ownerStack;
1700 + let componentStack;
1701 +
1702 + const {prelude} = await new Promise(resolve => {
1703 + let result;
1704 +
1705 + setTimeout(() => {
1706 + result = ReactDOMFizzStatic.prerenderToNodeStream(
1707 + React.createElement(ClientRoot),
1708 + {
1709 + signal: clientAbortController.signal,
1710 + onError(error, errorInfo) {
1711 + componentStack = errorInfo.componentStack;
1712 + ownerStack = React.captureOwnerStack
1713 + ? React.captureOwnerStack()
1714 + : null;
1715 + },
1716 + },
1717 + );
1718 + });
1719 +
1720 + setTimeout(() => {
1721 + clientAbortController.abort();
1722 + resolve(result);
1723 + });
1724 + });
1725 +
1726 + const prerenderHTML = await readResult(prelude);
1727 +
1728 + expect(prerenderHTML).toContain('Loading...');
1729 +
1730 + if (__DEV__) {
1731 + expect(
1732 + normalizeCodeLocInfo(componentStack, {preserveLocation: true}),
1733 + ).toBe(
1734 + '\n' +
1735 + ' in ClientDynamic (ReactFlightDOMNode-test.js:1679:9)\n' +
1736 + ' in Suspense\n' +
1737 + ' in body\n' +
1738 + ' in html\n' +
1739 + ' in ClientRoot',
1740 + );
1741 + } else {
1742 + expect(
1743 + normalizeCodeLocInfo(componentStack, {preserveLocation: true}),
1744 + ).toBe(
1745 + '\n' +
1746 + ' in ClientDynamic (ReactFlightDOMNode-test.js:1679:9)\n' +
1747 + ' in Suspense\n' +
1748 + ' in body\n' +
1749 + ' in html\n' +
1750 + ' in ClientRoot',
1751 + );
1752 + }
1753 +
1754 + if (__DEV__) {
1755 + expect(ignoreListStack(ownerStack)).toBe(
1756 + '\n' +
1757 + gate(flags =>
1758 + flags.enableAsyncDebugInfo
1759 + ? ' at ClientDynamic (./ReactFlightDOMNode-test.js:1680:9)\n'
1760 + : '',
1761 + ) +
1762 + ' at ClientRoot (./ReactFlightDOMNode-test.js:1693:21)',
1763 + );
1764 + } else {
1765 + expect(ownerStack).toBeNull();
1766 + }
1767 + });
1768 +
1769 function createReadableWithLateRelease(initialChunks, lateChunks, signal) {
1770 // Create a new Readable and push all initial chunks immediately.
1771 const readable = new Stream.Readable({...streamOptions, read() {}});
packages/react-server/src/ReactFizzThenable.js
+13 -2
@@ -257,7 +257,7 @@ export function ensureSuspendableThenableStateDEV(
257 const lastThenable = thenableState[thenableState.length - 1];
258 // Reset the last thenable back to pending.
259 switch (lastThenable.status) {
260 - case 'fulfilled':
260 + case 'fulfilled': {
261 const previousThenableValue = lastThenable.value;
262 // $FlowIgnore[method-unbinding] We rebind .then immediately.
263 const previousThenableThen = lastThenable.then.bind(lastThenable);
@@ -274,14 +274,25 @@ export function ensureSuspendableThenableStateDEV(
274 lastThenable.value = previousThenableValue;
275 lastThenable.status = 'fulfilled';
276 };
277 - case 'rejected':
277 + }
278 + case 'rejected': {
279 const previousThenableReason = lastThenable.reason;
280 + // $FlowIgnore[method-unbinding] We rebind .then immediately.
281 + const previousThenableThen = lastThenable.then.bind(lastThenable);
282 delete lastThenable.reason;
283 delete (lastThenable: any).status;
284 + // We'll call .then again if we resuspend. Since we potentially corrupted
285 + // the internal state of unknown classes, we need to diffuse the potential
286 + // crash by replacing the .then method with a noop.
287 + // $FlowFixMe[cannot-write] Custom userspace Thenables may not be but native Promises are.
288 + lastThenable.then = noop;
289 return () => {
290 + // $FlowFixMe[cannot-write] Custom userspace Thenables may not be but native Promises are.
291 + lastThenable.then = previousThenableThen;
292 lastThenable.reason = previousThenableReason;
293 lastThenable.status = 'rejected';
294 };
295 + }
296 }
297 return noop;
298 } else {