@samitouri / QOS-React-2 / commits / f1af67e196

[Fizz] Do not allow abort reentrancy (#36574)

Aborting is a gate you can only pass through once. A request that is already aborting, already completed, or already fataled cannot be aborted a second time. Previously this was generally functionally true but you could contrive sequences where an onError would fire after a render fataled. This change makes it more explicit that this is not semantically correct by bailing out of abort if the request is in a status that cannot be aborted.

Josh Story committed May 30, 2026 at 08:32 UTC f1af67e19681ece7b49069446bb1457d6bdb318a
3 files changed +77 -10
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+40 -4
@@ -4003,10 +4003,46 @@ describe('ReactDOMFizzServer', () => {
4003 await act(() => pipe(testWritable));
4004 expect(didRender).toBe(false);
4005 expect(didFatal).toBe(didFatal);
4006 - expect(errors).toEqual([
4007 - 'boom',
4008 - 'The destination stream errored while writing data.',
4009 - ]);
4006 + expect(errors).toEqual(['boom']);
4007 + });
4008 +
4009 + it('does not report aborts after fatally erroring', async () => {
4010 + const promise = new Promise(() => {});
4011 + function AsyncComp() {
4012 + React.use(promise);
4013 + return 'Async';
4014 + }
4015 +
4016 + function ErrorComp() {
4017 + throw new Error('boom');
4018 + }
4019 +
4020 + const errors = [];
4021 + let abort;
4022 + await act(() => {
4023 + abort = renderToPipeableStream(
4024 + <div>
4025 + <Suspense fallback="loading...">
4026 + <AsyncComp />
4027 + </Suspense>
4028 + <ErrorComp />
4029 + </div>,
4030 + {
4031 + onError(error) {
4032 + errors.push(error.message);
4033 + },
4034 + onShellError() {},
4035 + },
4036 + ).abort;
4037 + });
4038 +
4039 + expect(errors).toEqual(['boom']);
4040 +
4041 + await act(() => {
4042 + abort(new Error('too late'));
4043 + });
4044 +
4045 + expect(errors).toEqual(['boom']);
4046 });
4047
4048 describe('error escaping', () => {
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+32 -4
@@ -237,6 +237,37 @@ describe('ReactDOMFizzServerNode', () => {
237 expect(reportedShellErrors).toEqual([theError]);
238 });
239
240 + it('should not report aborts after the shell has fatally errored', async () => {
241 + const reportedErrors = [];
242 + const reportedShellErrors = [];
243 + const {abort} = ReactDOMFizzServer.renderToPipeableStream(
244 + <div>
245 + <Suspense fallback="Loading">
246 + <InfiniteSuspend />
247 + </Suspense>
248 + <Throw />
249 + </div>,
250 + {
251 + onError(x) {
252 + reportedErrors.push(x);
253 + },
254 + onShellError(x) {
255 + reportedShellErrors.push(x);
256 + },
257 + },
258 + );
259 +
260 + await jest.runAllTimers();
261 +
262 + expect(reportedErrors).toEqual([theError]);
263 + expect(reportedShellErrors).toEqual([theError]);
264 +
265 + abort(new Error('too late'));
266 +
267 + expect(reportedErrors).toEqual([theError]);
268 + expect(reportedShellErrors).toEqual([theError]);
269 + });
270 +
271 it('should error the stream when an error is thrown inside a fallback', async () => {
272 const reportedErrors = [];
273 const reportedShellErrors = [];
@@ -263,10 +294,7 @@ describe('ReactDOMFizzServerNode', () => {
294
295 expect(output.error).toBe(theError);
296 expect(output.result).toBe('');
266 - expect(reportedErrors).toEqual([
267 - theError.message,
268 - 'The destination stream errored while writing data.',
269 - ]);
297 + expect(reportedErrors).toEqual([theError.message]);
298 expect(reportedShellErrors).toEqual([theError]);
299 });
300
packages/react-server/src/ReactFizzServer.js
+5 -2
@@ -6143,9 +6143,12 @@ export function stopFlowing(request: Request): void {
6143
6144 // This is called to early terminate a request. It puts all pending boundaries in client rendered state.
6145 export function abort(request: Request, reason: mixed): void {
6146 - if (request.status === OPEN || request.status === OPENING) {
6147 - request.status = ABORTING;
6146 + if (request.status !== OPEN && request.status !== OPENING) {
6147 + // Only requests that are not already complete or in the process of aborting
6148 + // can be aborted. in practice this makes abort callable at most once per render.
6149 + return;
6150 }
6151 + request.status = ABORTING;
6152
6153 try {
6154 const abortableTasks = request.abortableTasks;