@samitouri / QOS-React / commits / 9baecbf02b

[Fizz] Avoid hanging when suspending after aborting while rendering (#34192)

This fixes an edge case where you abort the render while rendering a component that ends up Suspending. It technically only applied if you were deep enough to be inside `renderNode` and was not susceptible to hanging if the abort + suspending component was being tried inside retryRenderTask/retryReplaytask. The fix is to preempt the thenable checks in renderNode and check if the request is aborting and if so just bubble up to the task handler. The reason this hung before is a new task would get scheduled after we had aborted every other task (minus the currently rendering one). This led to a situation where the task count would not hit zero.

Josh Story committed Aug 12, 2025 at 16:46 UTC 9baecbf02b2b53746f259b77293288aa9d8968f8
2 files changed +57 -5
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+51 -3
@@ -94,9 +94,7 @@ describe('ReactDOMFizzServer', () => {
94 ReactDOM = require('react-dom');
95 ReactDOMClient = require('react-dom/client');
96 ReactDOMFizzServer = require('react-dom/server');
97 - if (__EXPERIMENTAL__) {
98 - ReactDOMFizzStatic = require('react-dom/static');
99 - }
97 + ReactDOMFizzStatic = require('react-dom/static');
98 Stream = require('stream');
99 Suspense = React.Suspense;
100 use = React.use;
@@ -10784,4 +10782,54 @@ Unfortunately that previous paragraph wasn't quite long enough so I'll continue
10782 // Instead we assert that we never emitted the fallback of the Suspense boundary around the body.
10783 expect(streamedContent).not.toContain(randomTag);
10784 });
10785 +
10786 + it('should be able to Suspend after aborting in the same component without hanging the render', async () => {
10787 + const controller = new AbortController();
10788 +
10789 + const promise1 = new Promise(() => {});
10790 + function AbortAndSuspend() {
10791 + controller.abort('boom');
10792 + return React.use(promise1);
10793 + }
10794 +
10795 + function App() {
10796 + return (
10797 + <html>
10798 + <body>
10799 + <Suspense fallback="loading...">
10800 + {/*
10801 + The particular code path that was problematic required the Suspend to happen in renderNode
10802 + rather than retryRenderTask so we render the aborting function inside a host component
10803 + intentionally here
10804 + */}
10805 + <div>
10806 + <AbortAndSuspend />
10807 + </div>
10808 + </Suspense>
10809 + </body>
10810 + </html>
10811 + );
10812 + }
10813 +
10814 + const errors = [];
10815 + await act(async () => {
10816 + const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
10817 + signal: controller.signal,
10818 + onError(e) {
10819 + errors.push(e);
10820 + },
10821 + });
10822 +
10823 + result.prelude.pipe(writable);
10824 + });
10825 +
10826 + expect(errors).toEqual(['boom']);
10827 +
10828 + expect(getVisibleChildren(document)).toEqual(
10829 + <html>
10830 + <head />
10831 + <body>loading...</body>
10832 + </html>,
10833 + );
10834 + });
10835 });
packages/react-server/src/ReactFizzServer.js
+6 -2
@@ -4155,7 +4155,9 @@ function renderNode(
4155 getSuspendedThenable()
4156 : thrownValue;
4157
4158 - if (typeof x === 'object' && x !== null) {
4158 + if (request.status === ABORTING) {
4159 + // We are aborting so we can just bubble up to the task by falling through
4160 + } else if (typeof x === 'object' && x !== null) {
4161 // $FlowFixMe[method-unbinding]
4162 if (typeof x.then === 'function') {
4163 const wakeable: Wakeable = (x: any);
@@ -4254,7 +4256,9 @@ function renderNode(
4256 getSuspendedThenable()
4257 : thrownValue;
4258
4257 - if (typeof x === 'object' && x !== null) {
4259 + if (request.status === ABORTING) {
4260 + // We are aborting so we can just bubble up to the task by falling through
4261 + } else if (typeof x === 'object' && x !== null) {
4262 // $FlowFixMe[method-unbinding]
4263 if (typeof x.then === 'function') {
4264 const wakeable: Wakeable = (x: any);