Skip to content

Commit 56389e8

Browse files
authored
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.
1 parent 0f216ae commit 56389e8

File tree

8 files changed

+250
-16
lines changed

8 files changed

+250
-16
lines changed

Diff for: packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js

+8
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ export function processModelChunk(
117117
return ['J', id, json];
118118
}
119119

120+
export function processReferenceChunk(
121+
request: Request,
122+
id: number,
123+
reference: string,
124+
): Chunk {
125+
return ['J', id, reference];
126+
}
127+
120128
export function processModuleChunk(
121129
request: Request,
122130
id: number,

Diff for: packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js

+16-2
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import {
1515
createRequest,
1616
startWork,
1717
startFlowing,
18+
abort,
1819
} from 'react-server/src/ReactFlightServer';
1920

2021
type Options = {
21-
onError?: (error: mixed) => void,
22-
context?: Array<[string, ServerContextJSONValue]>,
2322
identifierPrefix?: string,
23+
signal?: AbortSignal,
24+
context?: Array<[string, ServerContextJSONValue]>,
25+
onError?: (error: mixed) => void,
2426
};
2527

2628
function renderToReadableStream(
@@ -35,6 +37,18 @@ function renderToReadableStream(
3537
options ? options.context : undefined,
3638
options ? options.identifierPrefix : undefined,
3739
);
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+
}
3852
const stream = new ReadableStream(
3953
{
4054
type: 'bytes',

Diff for: packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js

+5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createRequest,
1717
startWork,
1818
startFlowing,
19+
abort,
1920
} from 'react-server/src/ReactFlightServer';
2021

2122
function createDrainHandler(destination, request) {
@@ -29,6 +30,7 @@ type Options = {
2930
};
3031

3132
type PipeableStream = {|
33+
abort(reason: mixed): void,
3234
pipe<T: Writable>(destination: T): T,
3335
|};
3436

@@ -58,6 +60,9 @@ function renderToPipeableStream(
5860
destination.on('drain', createDrainHandler(destination, request));
5961
return destination;
6062
},
63+
abort(reason: mixed) {
64+
abort(request, reason);
65+
},
6166
};
6267
}
6368

Diff for: packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

+67-9
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ let React;
3030
let ReactDOMClient;
3131
let ReactServerDOMWriter;
3232
let ReactServerDOMReader;
33+
let Suspense;
3334

3435
describe('ReactFlightDOM', () => {
3536
beforeEach(() => {
@@ -42,6 +43,7 @@ describe('ReactFlightDOM', () => {
4243
ReactDOMClient = require('react-dom/client');
4344
ReactServerDOMWriter = require('react-server-dom-webpack/writer.node.server');
4445
ReactServerDOMReader = require('react-server-dom-webpack');
46+
Suspense = React.Suspense;
4547
});
4648

4749
function getTestStream() {
@@ -92,6 +94,11 @@ describe('ReactFlightDOM', () => {
9294
}
9395
}
9496

97+
const theInfinitePromise = new Promise(() => {});
98+
function InfiniteSuspend() {
99+
throw theInfinitePromise;
100+
}
101+
95102
it('should resolve HTML using Node streams', async () => {
96103
function Text({children}) {
97104
return <span>{children}</span>;
@@ -133,8 +140,6 @@ describe('ReactFlightDOM', () => {
133140
});
134141

135142
it('should resolve the root', async () => {
136-
const {Suspense} = React;
137-
138143
// Model
139144
function Text({children}) {
140145
return <span>{children}</span>;
@@ -184,8 +189,6 @@ describe('ReactFlightDOM', () => {
184189
});
185190

186191
it('should not get confused by $', async () => {
187-
const {Suspense} = React;
188-
189192
// Model
190193
function RootModel() {
191194
return {text: '$1'};
@@ -220,8 +223,6 @@ describe('ReactFlightDOM', () => {
220223
});
221224

222225
it('should not get confused by @', async () => {
223-
const {Suspense} = React;
224-
225226
// Model
226227
function RootModel() {
227228
return {text: '@div'};
@@ -257,7 +258,6 @@ describe('ReactFlightDOM', () => {
257258

258259
it('should progressively reveal server components', async () => {
259260
let reportedErrors = [];
260-
const {Suspense} = React;
261261

262262
// Client Components
263263

@@ -460,8 +460,6 @@ describe('ReactFlightDOM', () => {
460460
});
461461

462462
it('should preserve state of client components on refetch', async () => {
463-
const {Suspense} = React;
464-
465463
// Client
466464

467465
function Page({response}) {
@@ -545,4 +543,64 @@ describe('ReactFlightDOM', () => {
545543
expect(inputB.tagName).toBe('INPUT');
546544
expect(inputB.value).toBe('goodbye');
547545
});
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+
});
548606
});

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

+71-3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ let ReactDOMClient;
2727
let ReactDOMServer;
2828
let ReactServerDOMWriter;
2929
let ReactServerDOMReader;
30+
let Suspense;
3031

3132
describe('ReactFlightDOMBrowser', () => {
3233
beforeEach(() => {
@@ -39,6 +40,7 @@ describe('ReactFlightDOMBrowser', () => {
3940
ReactDOMServer = require('react-dom/server.browser');
4041
ReactServerDOMWriter = require('react-server-dom-webpack/writer.browser.server');
4142
ReactServerDOMReader = require('react-server-dom-webpack');
43+
Suspense = React.Suspense;
4244
});
4345

4446
function moduleReference(moduleExport) {
@@ -108,6 +110,11 @@ describe('ReactFlightDOMBrowser', () => {
108110
return [DelayedText, _resolve, _reject];
109111
}
110112

113+
const theInfinitePromise = new Promise(() => {});
114+
function InfiniteSuspend() {
115+
throw theInfinitePromise;
116+
}
117+
111118
it('should resolve HTML using W3C streams', async () => {
112119
function Text({children}) {
113120
return <span>{children}</span>;
@@ -180,7 +187,6 @@ describe('ReactFlightDOMBrowser', () => {
180187

181188
it('should progressively reveal server components', async () => {
182189
let reportedErrors = [];
183-
const {Suspense} = React;
184190

185191
// Client Components
186192

@@ -356,8 +362,6 @@ describe('ReactFlightDOMBrowser', () => {
356362
});
357363

358364
it('should close the stream upon completion when rendering to W3C streams', async () => {
359-
const {Suspense} = React;
360-
361365
// Model
362366
function Text({children}) {
363367
return children;
@@ -512,4 +516,68 @@ describe('ReactFlightDOMBrowser', () => {
512516
const result = await readResult(ssrStream);
513517
expect(result).toEqual('<span>Client Component</span>');
514518
});
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+
});
515583
});

Diff for: packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js

+8
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ export function processModelChunk(
114114
return ['J', id, json];
115115
}
116116

117+
export function processReferenceChunk(
118+
request: Request,
119+
id: number,
120+
reference: string,
121+
): Chunk {
122+
return ['J', id, reference];
123+
}
124+
117125
export function processModuleChunk(
118126
request: Request,
119127
id: number,

0 commit comments

Comments
 (0)