Abort Flight (#24754)
Add aborting to the Flight Server. This encodes the reason as an "error" row that gets thrown client side. These are still exposed in prod which is a follow up we'll still have to do to encode them as digests instead. The error is encoded once and then referenced by each row that needs to be updated.
Sebastian Markbåge committed
Jun 19, 2022 at 11:05 UTC
56389e81ffe17c97f3137f18cc6446a4b2c956de
8 files changed
+250
-16
packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js
+8
@@ -117,6 +117,14 @@ export function processModelChunk(
117
return ['J', id, json];
118
}
119
120
+export function processReferenceChunk(
121
+ request: Request,
122
+ id: number,
123
+ reference: string,
124
+): Chunk {
125
+ return ['J', id, reference];
126
+}
127
+
128
export function processModuleChunk(
129
request: Request,
130
id: number,
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+16
-2
@@ -15,12 +15,14 @@ import {
15
createRequest,
16
startWork,
17
startFlowing,
18
+ abort,
19
} from 'react-server/src/ReactFlightServer';
20
21
type Options = {
21
- onError?: (error: mixed) => void,
22
- context?: Array<[string, ServerContextJSONValue]>,
22
identifierPrefix?: string,
23
+ signal?: AbortSignal,
24
+ context?: Array<[string, ServerContextJSONValue]>,
25
+ onError?: (error: mixed) => void,
26
};
27
28
function renderToReadableStream(
@@ -35,6 +37,18 @@ function renderToReadableStream(
37
options ? options.context : undefined,
38
options ? options.identifierPrefix : undefined,
39
);
40
+ if (options && options.signal) {
41
+ const signal = options.signal;
42
+ if (signal.aborted) {
43
+ abort(request, (signal: any).reason);
44
+ } else {
45
+ const listener = () => {
46
+ abort(request, (signal: any).reason);
47
+ signal.removeEventListener('abort', listener);
48
+ };
49
+ signal.addEventListener('abort', listener);
50
+ }
51
+ }
52
const stream = new ReadableStream(
53
{
54
type: 'bytes',
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+5
@@ -16,6 +16,7 @@ import {
16
createRequest,
17
startWork,
18
startFlowing,
19
+ abort,
20
} from 'react-server/src/ReactFlightServer';
21
22
function createDrainHandler(destination, request) {
@@ -29,6 +30,7 @@ type Options = {
30
};
31
32
type PipeableStream = {|
33
+ abort(reason: mixed): void,
34
pipe<T: Writable>(destination: T): T,
35
|};
36
@@ -58,6 +60,9 @@ function renderToPipeableStream(
60
destination.on('drain', createDrainHandler(destination, request));
61
return destination;
62
},
63
+ abort(reason: mixed) {
64
+ abort(request, reason);
65
+ },
66
};
67
}
68
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+67
-9
@@ -30,6 +30,7 @@ let React;
30
let ReactDOMClient;
31
let ReactServerDOMWriter;
32
let ReactServerDOMReader;
33
+let Suspense;
34
35
describe('ReactFlightDOM', () => {
36
beforeEach(() => {
@@ -42,6 +43,7 @@ describe('ReactFlightDOM', () => {
43
ReactDOMClient = require('react-dom/client');
44
ReactServerDOMWriter = require('react-server-dom-webpack/writer.node.server');
45
ReactServerDOMReader = require('react-server-dom-webpack');
46
+ Suspense = React.Suspense;
47
});
48
49
function getTestStream() {
@@ -92,6 +94,11 @@ describe('ReactFlightDOM', () => {
94
}
95
}
96
97
+ const theInfinitePromise = new Promise(() => {});
98
+ function InfiniteSuspend() {
99
+ throw theInfinitePromise;
100
+ }
101
+
102
it('should resolve HTML using Node streams', async () => {
103
function Text({children}) {
104
return <span>{children}</span>;
@@ -133,8 +140,6 @@ describe('ReactFlightDOM', () => {
140
});
141
142
it('should resolve the root', async () => {
136
- const {Suspense} = React;
137
-
143
// Model
144
function Text({children}) {
145
return <span>{children}</span>;
@@ -184,8 +189,6 @@ describe('ReactFlightDOM', () => {
189
});
190
191
it('should not get confused by $', async () => {
187
- const {Suspense} = React;
188
-
192
// Model
193
function RootModel() {
194
return {text: '$1'};
@@ -220,8 +223,6 @@ describe('ReactFlightDOM', () => {
223
});
224
225
it('should not get confused by @', async () => {
223
- const {Suspense} = React;
224
-
226
// Model
227
function RootModel() {
228
return {text: '@div'};
@@ -257,7 +258,6 @@ describe('ReactFlightDOM', () => {
258
259
it('should progressively reveal server components', async () => {
260
let reportedErrors = [];
260
- const {Suspense} = React;
261
262
// Client Components
263
@@ -460,8 +460,6 @@ describe('ReactFlightDOM', () => {
460
});
461
462
it('should preserve state of client components on refetch', async () => {
463
- const {Suspense} = React;
464
-
463
// Client
464
465
function Page({response}) {
@@ -545,4 +543,64 @@ describe('ReactFlightDOM', () => {
543
expect(inputB.tagName).toBe('INPUT');
544
expect(inputB.value).toBe('goodbye');
545
});
546
+
547
+ it('should be able to complete after aborting and throw the reason client-side', async () => {
548
+ const reportedErrors = [];
549
+
550
+ class ErrorBoundary extends React.Component {
551
+ state = {hasError: false, error: null};
552
+ static getDerivedStateFromError(error) {
553
+ return {
554
+ hasError: true,
555
+ error,
556
+ };
557
+ }
558
+ render() {
559
+ if (this.state.hasError) {
560
+ return this.props.fallback(this.state.error);
561
+ }
562
+ return this.props.children;
563
+ }
564
+ }
565
+
566
+ const {writable, readable} = getTestStream();
567
+ const {pipe, abort} = ReactServerDOMWriter.renderToPipeableStream(
568
+ <div>
569
+ <InfiniteSuspend />
570
+ </div>,
571
+ webpackMap,
572
+ {
573
+ onError(x) {
574
+ reportedErrors.push(x);
575
+ },
576
+ },
577
+ );
578
+ pipe(writable);
579
+ const response = ReactServerDOMReader.createFromReadableStream(readable);
580
+
581
+ const container = document.createElement('div');
582
+ const root = ReactDOMClient.createRoot(container);
583
+
584
+ function App({res}) {
585
+ return res.readRoot();
586
+ }
587
+
588
+ await act(async () => {
589
+ root.render(
590
+ <ErrorBoundary fallback={e => <p>{e.message}</p>}>
591
+ <Suspense fallback={<p>(loading)</p>}>
592
+ <App res={response} />
593
+ </Suspense>
594
+ </ErrorBoundary>,
595
+ );
596
+ });
597
+ expect(container.innerHTML).toBe('<p>(loading)</p>');
598
+
599
+ await act(async () => {
600
+ abort('for reasons');
601
+ });
602
+ expect(container.innerHTML).toBe('<p>Error: for reasons</p>');
603
+
604
+ expect(reportedErrors).toEqual(['for reasons']);
605
+ });
606
});
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+71
-3
@@ -27,6 +27,7 @@ let ReactDOMClient;
27
let ReactDOMServer;
28
let ReactServerDOMWriter;
29
let ReactServerDOMReader;
30
+let Suspense;
31
32
describe('ReactFlightDOMBrowser', () => {
33
beforeEach(() => {
@@ -39,6 +40,7 @@ describe('ReactFlightDOMBrowser', () => {
40
ReactDOMServer = require('react-dom/server.browser');
41
ReactServerDOMWriter = require('react-server-dom-webpack/writer.browser.server');
42
ReactServerDOMReader = require('react-server-dom-webpack');
43
+ Suspense = React.Suspense;
44
});
45
46
function moduleReference(moduleExport) {
@@ -108,6 +110,11 @@ describe('ReactFlightDOMBrowser', () => {
110
return [DelayedText, _resolve, _reject];
111
}
112
113
+ const theInfinitePromise = new Promise(() => {});
114
+ function InfiniteSuspend() {
115
+ throw theInfinitePromise;
116
+ }
117
+
118
it('should resolve HTML using W3C streams', async () => {
119
function Text({children}) {
120
return <span>{children}</span>;
@@ -180,7 +187,6 @@ describe('ReactFlightDOMBrowser', () => {
187
188
it('should progressively reveal server components', async () => {
189
let reportedErrors = [];
183
- const {Suspense} = React;
190
191
// Client Components
192
@@ -356,8 +362,6 @@ describe('ReactFlightDOMBrowser', () => {
362
});
363
364
it('should close the stream upon completion when rendering to W3C streams', async () => {
359
- const {Suspense} = React;
360
-
365
// Model
366
function Text({children}) {
367
return children;
@@ -512,4 +516,68 @@ describe('ReactFlightDOMBrowser', () => {
516
const result = await readResult(ssrStream);
517
expect(result).toEqual('<span>Client Component</span>');
518
});
519
+
520
+ it('should be able to complete after aborting and throw the reason client-side', async () => {
521
+ const reportedErrors = [];
522
+
523
+ class ErrorBoundary extends React.Component {
524
+ state = {hasError: false, error: null};
525
+ static getDerivedStateFromError(error) {
526
+ return {
527
+ hasError: true,
528
+ error,
529
+ };
530
+ }
531
+ render() {
532
+ if (this.state.hasError) {
533
+ return this.props.fallback(this.state.error);
534
+ }
535
+ return this.props.children;
536
+ }
537
+ }
538
+
539
+ const controller = new AbortController();
540
+ const stream = ReactServerDOMWriter.renderToReadableStream(
541
+ <div>
542
+ <InfiniteSuspend />
543
+ </div>,
544
+ webpackMap,
545
+ {
546
+ signal: controller.signal,
547
+ onError(x) {
548
+ reportedErrors.push(x);
549
+ },
550
+ },
551
+ );
552
+ const response = ReactServerDOMReader.createFromReadableStream(stream);
553
+
554
+ const container = document.createElement('div');
555
+ const root = ReactDOMClient.createRoot(container);
556
+
557
+ function App({res}) {
558
+ return res.readRoot();
559
+ }
560
+
561
+ await act(async () => {
562
+ root.render(
563
+ <ErrorBoundary fallback={e => <p>{e.message}</p>}>
564
+ <Suspense fallback={<p>(loading)</p>}>
565
+ <App res={response} />
566
+ </Suspense>
567
+ </ErrorBoundary>,
568
+ );
569
+ });
570
+ expect(container.innerHTML).toBe('<p>(loading)</p>');
571
+
572
+ await act(async () => {
573
+ // @TODO this is a hack to work around lack of support for abortSignal.reason in node
574
+ // The abort call itself should set this property but since we are testing in node we
575
+ // set it here manually
576
+ controller.signal.reason = 'for reasons';
577
+ controller.abort('for reasons');
578
+ });
579
+ expect(container.innerHTML).toBe('<p>Error: for reasons</p>');
580
+
581
+ expect(reportedErrors).toEqual(['for reasons']);
582
+ });
583
});
packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js
+8
@@ -114,6 +114,14 @@ export function processModelChunk(
114
return ['J', id, json];
115
}
116
117
+export function processReferenceChunk(
118
+ request: Request,
119
+ id: number,
120
+ reference: string,
121
+): Chunk {
122
+ return ['J', id, reference];
123
+}
124
+
125
export function processModuleChunk(
126
request: Request,
127
id: number,
packages/react-server/src/ReactFlightServer.js
+65
-2
@@ -34,6 +34,7 @@ import {
34
processProviderChunk,
35
processSymbolChunk,
36
processErrorChunk,
37
+ processReferenceChunk,
38
resolveModuleMetaData,
39
getModuleKey,
40
isModuleReference,
@@ -86,8 +87,14 @@ export type ReactModel =
87
88
type ReactModelObject = {+[key: string]: ReactModel};
89
90
+const PENDING = 0;
91
+const COMPLETED = 1;
92
+const ABORTED = 3;
93
+const ERRORED = 4;
94
+
95
type Task = {
96
id: number,
97
+ status: 0 | 1 | 3 | 4,
98
model: ReactModel,
99
ping: () => void,
100
context: ContextSnapshot,
@@ -101,6 +108,7 @@ export type Request = {
108
cache: Map<Function, mixed>,
109
nextChunkId: number,
110
pendingChunks: number,
111
+ abortableTasks: Set<Task>,
112
pingedTasks: Array<Task>,
113
completedModuleChunks: Array<Chunk>,
114
completedJSONChunks: Array<Chunk>,
@@ -132,6 +140,7 @@ export function createRequest(
140
context?: Array<[string, ServerContextJSONValue]>,
141
identifierPrefix?: string,
142
): Request {
143
+ const abortSet: Set<Task> = new Set();
144
const pingedTasks = [];
145
const request = {
146
status: OPEN,
@@ -141,6 +150,7 @@ export function createRequest(
150
cache: new Map(),
151
nextChunkId: 0,
152
pendingChunks: 0,
153
+ abortableTasks: abortSet,
154
pingedTasks: pingedTasks,
155
completedModuleChunks: [],
156
completedJSONChunks: [],
@@ -157,7 +167,7 @@ export function createRequest(
167
};
168
request.pendingChunks++;
169
const rootContext = createRootContext(context);
160
- const rootTask = createTask(request, model, rootContext);
170
+ const rootTask = createTask(request, model, rootContext, abortSet);
171
pingedTasks.push(rootTask);
172
return request;
173
}
@@ -263,14 +273,17 @@ function createTask(
273
request: Request,
274
model: ReactModel,
275
context: ContextSnapshot,
276
+ abortSet: Set<Task>,
277
): Task {
278
const id = request.nextChunkId++;
279
const task = {
280
id,
281
+ status: PENDING,
282
model,
283
context,
284
ping: () => pingTask(request, task),
285
};
286
+ abortSet.add(task);
287
return task;
288
}
289
@@ -520,7 +533,12 @@ export function resolveModelToJSON(
533
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
534
// Something suspended, we'll need to create a new task and resolve it later.
535
request.pendingChunks++;
523
- const newTask = createTask(request, value, getActiveContext());
536
+ const newTask = createTask(
537
+ request,
538
+ value,
539
+ getActiveContext(),
540
+ request.abortableTasks,
541
+ );
542
const ping = newTask.ping;
543
x.then(ping, ping);
544
return serializeByRefID(newTask.id);
@@ -791,6 +809,10 @@ function emitProviderChunk(
809
}
810
811
function retryTask(request: Request, task: Task): void {
812
+ if (task.status !== PENDING) {
813
+ // We completed this by other means before we had a chance to retry it.
814
+ return;
815
+ }
816
switchContext(task.context);
817
try {
818
let value = task.model;
@@ -814,6 +836,8 @@ function retryTask(request: Request, task: Task): void {
836
}
837
const processedChunk = processModelChunk(request, task.id, value);
838
request.completedJSONChunks.push(processedChunk);
839
+ request.abortableTasks.delete(task);
840
+ task.status = COMPLETED;
841
} catch (x) {
842
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
843
// Something suspended again, let's pick it back up later.
@@ -821,6 +845,8 @@ function retryTask(request: Request, task: Task): void {
845
x.then(ping, ping);
846
return;
847
} else {
848
+ request.abortableTasks.delete(task);
849
+ task.status = ERRORED;
850
logRecoverableError(request, x);
851
// This errored, we need to serialize this error to the
852
emitErrorChunk(request, task.id, x);
@@ -855,6 +881,15 @@ function performWork(request: Request): void {
881
}
882
}
883
884
+function abortTask(task: Task, request: Request, errorId: number): void {
885
+ task.status = ABORTED;
886
+ // Instead of emitting an error per task.id, we emit a model that only
887
+ // has a single value referencing the error.
888
+ const ref = serializeByValueID(errorId);
889
+ const processedChunk = processReferenceChunk(request, task.id, ref);
890
+ request.completedJSONChunks.push(processedChunk);
891
+}
892
+
893
function flushCompletedChunks(
894
request: Request,
895
destination: Destination,
@@ -942,6 +977,34 @@ export function startFlowing(request: Request, destination: Destination): void {
977
}
978
}
979
980
+// This is called to early terminate a request. It creates an error at all pending tasks.
981
+export function abort(request: Request, reason: mixed): void {
982
+ try {
983
+ const abortableTasks = request.abortableTasks;
984
+ if (abortableTasks.size > 0) {
985
+ // We have tasks to abort. We'll emit one error row and then emit a reference
986
+ // to that row from every row that's still remaining.
987
+ const error =
988
+ reason === undefined
989
+ ? new Error('The render was aborted by the server without a reason.')
990
+ : reason;
991
+
992
+ logRecoverableError(request, error);
993
+ request.pendingChunks++;
994
+ const errorId = request.nextChunkId++;
995
+ emitErrorChunk(request, errorId, error);
996
+ abortableTasks.forEach(task => abortTask(task, request, errorId));
997
+ abortableTasks.clear();
998
+ }
999
+ if (request.destination !== null) {
1000
+ flushCompletedChunks(request, request.destination);
1001
+ }
1002
+ } catch (error) {
1003
+ logRecoverableError(request, error);
1004
+ fatalError(request, error);
1005
+ }
1006
+}
1007
+
1008
function importServerContexts(
1009
contexts?: Array<[string, ServerContextJSONValue]>,
1010
) {
packages/react-server/src/ReactFlightServerConfigStream.js
+10
@@ -99,6 +99,16 @@ export function processModelChunk(
99
return stringToChunk(row);
100
}
101
102
+export function processReferenceChunk(
103
+ request: Request,
104
+ id: number,
105
+ reference: string,
106
+): Chunk {
107
+ const json = stringify(reference);
108
+ const row = serializeRowHeader('J', id) + json + '\n';
109
+ return stringToChunk(row);
110
+}
111
+
112
export function processModuleChunk(
113
request: Request,
114
id: number,