@samitouri / QOS-React-1 / commits / d9e00f795b

Stop flowing and then abort if a stream is cancelled (#27405)

We currently abort a stream either it's explicitly told to abort (e.g. by an abortsignal). In this case we still finish writing what we have as well as instructions for the client about what happened so it can trigger fallback cases and log appropriately. We also abort a request if the stream itself cancels. E.g. if you can't write anymore. In this case we should not write anything to the outgoing stream since it's supposed to be closed already now. However, we should still abort the request so that more work isn't performed and so that we can log the reason for it to the onError callback. We should also not do any work after aborting. There we need to stop the "flow" of bytes - so I call stopFlowing in the cancel case before aborting. The tests were testing this case but we had changed the implementation to only start flowing at initial read (pull) instead of start like we used to. As a result, it was no longer covering this case. We have to call reader.read() in the tests to start the flow so that we need to cancel it. We also were missing a final assertion on the error logs and since we were tracking them explicitly the extra error was silenced.

Sebastian Markbåge committed Sep 22, 2023 at 15:16 UTC d9e00f795b77676fb14f2a3c6f421f48f73bec2a
14 files changed +125 -10
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+6 -1
@@ -342,7 +342,8 @@ describe('ReactDOMFizzServerBrowser', () => {
342 expect(isComplete).toBe(false);
343
344 const reader = stream.getReader();
345 - reader.cancel();
345 + await reader.read();
346 + await reader.cancel();
347
348 expect(errors).toEqual([
349 'The render was aborted by the server without a reason.',
@@ -355,6 +356,10 @@ describe('ReactDOMFizzServerBrowser', () => {
356
357 expect(rendered).toBe(false);
358 expect(isComplete).toBe(true);
359 +
360 + expect(errors).toEqual([
361 + 'The render was aborted by the server without a reason.',
362 + ]);
363 });
364
365 it('should stream large contents that might overlow individual buffers', async () => {
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+3
@@ -19,6 +19,7 @@ import {
19 resumeRequest,
20 startWork,
21 startFlowing,
22 + stopFlowing,
23 abort,
24 } from 'react-server/src/ReactFizzServer';
25
@@ -78,6 +79,7 @@ function renderToReadableStream(
79 startFlowing(request, controller);
80 },
81 cancel: (reason): ?Promise<void> => {
82 + stopFlowing(request);
83 abort(request);
84 },
85 },
@@ -158,6 +160,7 @@ function resume(
160 startFlowing(request, controller);
161 },
162 cancel: (reason): ?Promise<void> => {
163 + stopFlowing(request);
164 abort(request);
165 },
166 },
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+2
@@ -17,6 +17,7 @@ import {
17 createRequest,
18 startWork,
19 startFlowing,
20 + stopFlowing,
21 abort,
22 } from 'react-server/src/ReactFizzServer';
23
@@ -68,6 +69,7 @@ function renderToReadableStream(
69 startFlowing(request, controller);
70 },
71 cancel: (reason): ?Promise<void> => {
72 + stopFlowing(request);
73 abort(request);
74 },
75 },
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+3
@@ -19,6 +19,7 @@ import {
19 resumeRequest,
20 startWork,
21 startFlowing,
22 + stopFlowing,
23 abort,
24 } from 'react-server/src/ReactFizzServer';
25
@@ -78,6 +79,7 @@ function renderToReadableStream(
79 startFlowing(request, controller);
80 },
81 cancel: (reason): ?Promise<void> => {
82 + stopFlowing(request);
83 abort(request);
84 },
85 },
@@ -158,6 +160,7 @@ function resume(
160 startFlowing(request, controller);
161 },
162 cancel: (reason): ?Promise<void> => {
163 + stopFlowing(request);
164 abort(request);
165 },
166 },
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+11 -7
@@ -21,6 +21,7 @@ import {
21 resumeRequest,
22 startWork,
23 startFlowing,
24 + stopFlowing,
25 abort,
26 } from 'react-server/src/ReactFizzServer';
27
@@ -35,9 +36,12 @@ function createDrainHandler(destination: Destination, request: Request) {
36 return () => startFlowing(request, destination);
37 }
38
38 -function createAbortHandler(request: Request, reason: string) {
39 - // eslint-disable-next-line react-internal/prod-error-codes
40 - return () => abort(request, new Error(reason));
39 +function createCancelHandler(request: Request, reason: string) {
40 + return () => {
41 + stopFlowing(request);
42 + // eslint-disable-next-line react-internal/prod-error-codes
43 + abort(request, new Error(reason));
44 + };
45 }
46
47 type Options = {
@@ -122,14 +126,14 @@ function renderToPipeableStream(
126 destination.on('drain', createDrainHandler(destination, request));
127 destination.on(
128 'error',
125 - createAbortHandler(
129 + createCancelHandler(
130 request,
131 'The destination stream errored while writing data.',
132 ),
133 );
134 destination.on(
135 'close',
132 - createAbortHandler(request, 'The destination stream closed early.'),
136 + createCancelHandler(request, 'The destination stream closed early.'),
137 );
138 return destination;
139 },
@@ -180,14 +184,14 @@ function resumeToPipeableStream(
184 destination.on('drain', createDrainHandler(destination, request));
185 destination.on(
186 'error',
183 - createAbortHandler(
187 + createCancelHandler(
188 request,
189 'The destination stream errored while writing data.',
190 ),
191 );
192 destination.on(
193 'close',
190 - createAbortHandler(request, 'The destination stream closed early.'),
194 + createCancelHandler(request, 'The destination stream closed early.'),
195 );
196 return destination;
197 },
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
+5
@@ -18,6 +18,7 @@ import {
18 createPrerenderRequest,
19 startWork,
20 startFlowing,
21 + stopFlowing,
22 abort,
23 getPostponedState,
24 } from 'react-server/src/ReactFizzServer';
@@ -61,6 +62,10 @@ function prerender(
62 pull: (controller): ?Promise<void> => {
63 startFlowing(request, controller);
64 },
65 + cancel: (reason): ?Promise<void> => {
66 + stopFlowing(request);
67 + abort(request);
68 + },
69 },
70 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
71 {highWaterMark: 0},
packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
+5
@@ -18,6 +18,7 @@ import {
18 createPrerenderRequest,
19 startWork,
20 startFlowing,
21 + stopFlowing,
22 abort,
23 getPostponedState,
24 } from 'react-server/src/ReactFizzServer';
@@ -61,6 +62,10 @@ function prerender(
62 pull: (controller): ?Promise<void> => {
63 startFlowing(request, controller);
64 },
65 + cancel: (reason): ?Promise<void> => {
66 + stopFlowing(request);
67 + abort(request);
68 + },
69 },
70 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
71 {highWaterMark: 0},
packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
+2
@@ -22,6 +22,7 @@ import {
22 createRequest,
23 startWork,
24 startFlowing,
25 + stopFlowing,
26 abort,
27 } from 'react-server/src/ReactFlightServer';
28
@@ -90,6 +91,7 @@ function renderToPipeableStream(
91 return destination;
92 },
93 abort(reason: mixed) {
94 + stopFlowing(request);
95 abort(request, reason);
96 },
97 };
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+5 -1
@@ -16,6 +16,7 @@ import {
16 createRequest,
17 startWork,
18 startFlowing,
19 + stopFlowing,
20 abort,
21 } from 'react-server/src/ReactFlightServer';
22
@@ -78,7 +79,10 @@ function renderToReadableStream(
79 pull: (controller): ?Promise<void> => {
80 startFlowing(request, controller);
81 },
81 - cancel: (reason): ?Promise<void> => {},
82 + cancel: (reason): ?Promise<void> => {
83 + stopFlowing(request);
84 + abort(request, reason);
85 + },
86 },
87 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
88 {highWaterMark: 0},
packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+5 -1
@@ -16,6 +16,7 @@ import {
16 createRequest,
17 startWork,
18 startFlowing,
19 + stopFlowing,
20 abort,
21 } from 'react-server/src/ReactFlightServer';
22
@@ -78,7 +79,10 @@ function renderToReadableStream(
79 pull: (controller): ?Promise<void> => {
80 startFlowing(request, controller);
81 },
81 - cancel: (reason): ?Promise<void> => {},
82 + cancel: (reason): ?Promise<void> => {
83 + stopFlowing(request);
84 + abort(request, reason);
85 + },
86 },
87 // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
88 {highWaterMark: 0},
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+20
@@ -22,6 +22,7 @@ import {
22 createRequest,
23 startWork,
24 startFlowing,
25 + stopFlowing,
26 abort,
27 } from 'react-server/src/ReactFlightServer';
28
@@ -51,6 +52,14 @@ function createDrainHandler(destination: Destination, request: Request) {
52 return () => startFlowing(request, destination);
53 }
54
55 +function createCancelHandler(request: Request, reason: string) {
56 + return () => {
57 + stopFlowing(request);
58 + // eslint-disable-next-line react-internal/prod-error-codes
59 + abort(request, new Error(reason));
60 + };
61 +}
62 +
63 type Options = {
64 onError?: (error: mixed) => void,
65 onPostpone?: (reason: string) => void,
@@ -88,6 +97,17 @@ function renderToPipeableStream(
97 hasStartedFlowing = true;
98 startFlowing(request, destination);
99 destination.on('drain', createDrainHandler(destination, request));
100 + destination.on(
101 + 'error',
102 + createCancelHandler(
103 + request,
104 + 'The destination stream errored while writing data.',
105 + ),
106 + );
107 + destination.on(
108 + 'close',
109 + createCancelHandler(request, 'The destination stream closed early.'),
110 + );
111 return destination;
112 },
113 abort(reason: mixed) {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+49
@@ -1222,4 +1222,53 @@ describe('ReactFlightDOMBrowser', () => {
1222
1223 expect(postponed).toBe('testing postpone');
1224 });
1225 +
1226 + it('should not continue rendering after the reader cancels', async () => {
1227 + let hasLoaded = false;
1228 + let resolve;
1229 + let rendered = false;
1230 + const promise = new Promise(r => (resolve = r));
1231 + function Wait() {
1232 + if (!hasLoaded) {
1233 + throw promise;
1234 + }
1235 + rendered = true;
1236 + return 'Done';
1237 + }
1238 + const errors = [];
1239 + const stream = await ReactServerDOMServer.renderToReadableStream(
1240 + <div>
1241 + <Suspense fallback={<div>Loading</div>}>
1242 + <Wait />
1243 + </Suspense>
1244 + </div>,
1245 + null,
1246 + {
1247 + onError(x) {
1248 + errors.push(x.message);
1249 + },
1250 + },
1251 + );
1252 +
1253 + expect(rendered).toBe(false);
1254 +
1255 + const reader = stream.getReader();
1256 + await reader.read();
1257 + await reader.cancel();
1258 +
1259 + expect(errors).toEqual([
1260 + 'The render was aborted by the server without a reason.',
1261 + ]);
1262 +
1263 + hasLoaded = true;
1264 + resolve();
1265 +
1266 + await jest.runAllTimers();
1267 +
1268 + expect(rendered).toBe(false);
1269 +
1270 + expect(errors).toEqual([
1271 + 'The render was aborted by the server without a reason.',
1272 + ]);
1273 + });
1274 });
packages/react-server/src/ReactFizzServer.js
+4
@@ -3998,6 +3998,10 @@ export function startFlowing(request: Request, destination: Destination): void {
3998 }
3999 }
4000
4001 +export function stopFlowing(request: Request): void {
4002 + request.destination = null;
4003 +}
4004 +
4005 // This is called to early terminate a request. It puts all pending boundaries in client rendered state.
4006 export function abort(request: Request, reason: mixed): void {
4007 try {
packages/react-server/src/ReactFlightServer.js
+5
@@ -359,6 +359,7 @@ function serializeThenable(request: Request, thenable: Thenable<any>): number {
359 },
360 reason => {
361 newTask.status = ERRORED;
362 + request.abortableTasks.delete(newTask);
363 // TODO: We should ideally do this inside performWork so it's scheduled
364 const digest = logRecoverableError(request, reason);
365 emitErrorChunk(request, newTask.id, digest, reason);
@@ -1570,6 +1571,10 @@ export function startFlowing(request: Request, destination: Destination): void {
1571 }
1572 }
1573
1574 +export function stopFlowing(request: Request): void {
1575 + request.destination = null;
1576 +}
1577 +
1578 // This is called to early terminate a request. It creates an error at all pending tasks.
1579 export function abort(request: Request, reason: mixed): void {
1580 try {