Skip to content

Commit 3ebdd0e

Browse files
committed
Abort Flight
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 f796fa1 commit 3ebdd0e

8 files changed

Lines changed: 250 additions & 16 deletions

File tree

packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js

Lines changed: 8 additions & 0 deletions
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,

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

Lines changed: 16 additions & 2 deletions
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',

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

Lines changed: 5 additions & 0 deletions
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

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

Lines changed: 67 additions & 9 deletions
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
});

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

Lines changed: 71 additions & 3 deletions
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
});

packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js

Lines changed: 8 additions & 0 deletions
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)