Skip to content

Commit d9e00f7

Browse files
authored
Stop flowing and then abort if a stream is cancelled (facebook#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.
1 parent f9d75e3 commit d9e00f7

14 files changed

+125
-10
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,8 @@ describe('ReactDOMFizzServerBrowser', () => {
342342
expect(isComplete).toBe(false);
343343

344344
const reader = stream.getReader();
345-
reader.cancel();
345+
await reader.read();
346+
await reader.cancel();
346347

347348
expect(errors).toEqual([
348349
'The render was aborted by the server without a reason.',
@@ -355,6 +356,10 @@ describe('ReactDOMFizzServerBrowser', () => {
355356

356357
expect(rendered).toBe(false);
357358
expect(isComplete).toBe(true);
359+
360+
expect(errors).toEqual([
361+
'The render was aborted by the server without a reason.',
362+
]);
358363
});
359364

360365
it('should stream large contents that might overlow individual buffers', async () => {

packages/react-dom/src/server/ReactDOMFizzServerBrowser.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
resumeRequest,
2020
startWork,
2121
startFlowing,
22+
stopFlowing,
2223
abort,
2324
} from 'react-server/src/ReactFizzServer';
2425

@@ -78,6 +79,7 @@ function renderToReadableStream(
7879
startFlowing(request, controller);
7980
},
8081
cancel: (reason): ?Promise<void> => {
82+
stopFlowing(request);
8183
abort(request);
8284
},
8385
},
@@ -158,6 +160,7 @@ function resume(
158160
startFlowing(request, controller);
159161
},
160162
cancel: (reason): ?Promise<void> => {
163+
stopFlowing(request);
161164
abort(request);
162165
},
163166
},

packages/react-dom/src/server/ReactDOMFizzServerBun.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
createRequest,
1818
startWork,
1919
startFlowing,
20+
stopFlowing,
2021
abort,
2122
} from 'react-server/src/ReactFizzServer';
2223

@@ -68,6 +69,7 @@ function renderToReadableStream(
6869
startFlowing(request, controller);
6970
},
7071
cancel: (reason): ?Promise<void> => {
72+
stopFlowing(request);
7173
abort(request);
7274
},
7375
},

packages/react-dom/src/server/ReactDOMFizzServerEdge.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
resumeRequest,
2020
startWork,
2121
startFlowing,
22+
stopFlowing,
2223
abort,
2324
} from 'react-server/src/ReactFizzServer';
2425

@@ -78,6 +79,7 @@ function renderToReadableStream(
7879
startFlowing(request, controller);
7980
},
8081
cancel: (reason): ?Promise<void> => {
82+
stopFlowing(request);
8183
abort(request);
8284
},
8385
},
@@ -158,6 +160,7 @@ function resume(
158160
startFlowing(request, controller);
159161
},
160162
cancel: (reason): ?Promise<void> => {
163+
stopFlowing(request);
161164
abort(request);
162165
},
163166
},

packages/react-dom/src/server/ReactDOMFizzServerNode.js

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
resumeRequest,
2222
startWork,
2323
startFlowing,
24+
stopFlowing,
2425
abort,
2526
} from 'react-server/src/ReactFizzServer';
2627

@@ -35,9 +36,12 @@ function createDrainHandler(destination: Destination, request: Request) {
3536
return () => startFlowing(request, destination);
3637
}
3738

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+
};
4145
}
4246

4347
type Options = {
@@ -122,14 +126,14 @@ function renderToPipeableStream(
122126
destination.on('drain', createDrainHandler(destination, request));
123127
destination.on(
124128
'error',
125-
createAbortHandler(
129+
createCancelHandler(
126130
request,
127131
'The destination stream errored while writing data.',
128132
),
129133
);
130134
destination.on(
131135
'close',
132-
createAbortHandler(request, 'The destination stream closed early.'),
136+
createCancelHandler(request, 'The destination stream closed early.'),
133137
);
134138
return destination;
135139
},
@@ -180,14 +184,14 @@ function resumeToPipeableStream(
180184
destination.on('drain', createDrainHandler(destination, request));
181185
destination.on(
182186
'error',
183-
createAbortHandler(
187+
createCancelHandler(
184188
request,
185189
'The destination stream errored while writing data.',
186190
),
187191
);
188192
destination.on(
189193
'close',
190-
createAbortHandler(request, 'The destination stream closed early.'),
194+
createCancelHandler(request, 'The destination stream closed early.'),
191195
);
192196
return destination;
193197
},

packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
createPrerenderRequest,
1919
startWork,
2020
startFlowing,
21+
stopFlowing,
2122
abort,
2223
getPostponedState,
2324
} from 'react-server/src/ReactFizzServer';
@@ -61,6 +62,10 @@ function prerender(
6162
pull: (controller): ?Promise<void> => {
6263
startFlowing(request, controller);
6364
},
65+
cancel: (reason): ?Promise<void> => {
66+
stopFlowing(request);
67+
abort(request);
68+
},
6469
},
6570
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
6671
{highWaterMark: 0},

packages/react-dom/src/server/ReactDOMFizzStaticEdge.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
createPrerenderRequest,
1919
startWork,
2020
startFlowing,
21+
stopFlowing,
2122
abort,
2223
getPostponedState,
2324
} from 'react-server/src/ReactFizzServer';
@@ -61,6 +62,10 @@ function prerender(
6162
pull: (controller): ?Promise<void> => {
6263
startFlowing(request, controller);
6364
},
65+
cancel: (reason): ?Promise<void> => {
66+
stopFlowing(request);
67+
abort(request);
68+
},
6469
},
6570
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
6671
{highWaterMark: 0},

packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
createRequest,
2323
startWork,
2424
startFlowing,
25+
stopFlowing,
2526
abort,
2627
} from 'react-server/src/ReactFlightServer';
2728

@@ -90,6 +91,7 @@ function renderToPipeableStream(
9091
return destination;
9192
},
9293
abort(reason: mixed) {
94+
stopFlowing(request);
9395
abort(request, reason);
9496
},
9597
};

packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createRequest,
1717
startWork,
1818
startFlowing,
19+
stopFlowing,
1920
abort,
2021
} from 'react-server/src/ReactFlightServer';
2122

@@ -78,7 +79,10 @@ function renderToReadableStream(
7879
pull: (controller): ?Promise<void> => {
7980
startFlowing(request, controller);
8081
},
81-
cancel: (reason): ?Promise<void> => {},
82+
cancel: (reason): ?Promise<void> => {
83+
stopFlowing(request);
84+
abort(request, reason);
85+
},
8286
},
8387
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
8488
{highWaterMark: 0},

packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createRequest,
1717
startWork,
1818
startFlowing,
19+
stopFlowing,
1920
abort,
2021
} from 'react-server/src/ReactFlightServer';
2122

@@ -78,7 +79,10 @@ function renderToReadableStream(
7879
pull: (controller): ?Promise<void> => {
7980
startFlowing(request, controller);
8081
},
81-
cancel: (reason): ?Promise<void> => {},
82+
cancel: (reason): ?Promise<void> => {
83+
stopFlowing(request);
84+
abort(request, reason);
85+
},
8286
},
8387
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
8488
{highWaterMark: 0},

packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
createRequest,
2323
startWork,
2424
startFlowing,
25+
stopFlowing,
2526
abort,
2627
} from 'react-server/src/ReactFlightServer';
2728

@@ -51,6 +52,14 @@ function createDrainHandler(destination: Destination, request: Request) {
5152
return () => startFlowing(request, destination);
5253
}
5354

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+
5463
type Options = {
5564
onError?: (error: mixed) => void,
5665
onPostpone?: (reason: string) => void,
@@ -88,6 +97,17 @@ function renderToPipeableStream(
8897
hasStartedFlowing = true;
8998
startFlowing(request, destination);
9099
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+
);
91111
return destination;
92112
},
93113
abort(reason: mixed) {

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,4 +1222,53 @@ describe('ReactFlightDOMBrowser', () => {
12221222

12231223
expect(postponed).toBe('testing postpone');
12241224
});
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+
});
12251274
});

packages/react-server/src/ReactFizzServer.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3998,6 +3998,10 @@ export function startFlowing(request: Request, destination: Destination): void {
39983998
}
39993999
}
40004000

4001+
export function stopFlowing(request: Request): void {
4002+
request.destination = null;
4003+
}
4004+
40014005
// This is called to early terminate a request. It puts all pending boundaries in client rendered state.
40024006
export function abort(request: Request, reason: mixed): void {
40034007
try {

packages/react-server/src/ReactFlightServer.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ function serializeThenable(request: Request, thenable: Thenable<any>): number {
359359
},
360360
reason => {
361361
newTask.status = ERRORED;
362+
request.abortableTasks.delete(newTask);
362363
// TODO: We should ideally do this inside performWork so it's scheduled
363364
const digest = logRecoverableError(request, reason);
364365
emitErrorChunk(request, newTask.id, digest, reason);
@@ -1570,6 +1571,10 @@ export function startFlowing(request: Request, destination: Destination): void {
15701571
}
15711572
}
15721573

1574+
export function stopFlowing(request: Request): void {
1575+
request.destination = null;
1576+
}
1577+
15731578
// This is called to early terminate a request. It creates an error at all pending tasks.
15741579
export function abort(request: Request, reason: mixed): void {
15751580
try {

0 commit comments

Comments
 (0)