Skip to content

Commit 9c5faec

Browse files
sebmarkbagezhengjitf
authored andcommitted
[Fizz] Expose a method to abort a pending request (facebook#21027)
* Track all suspended work while it's still pending This allows us to abort work and put everything into client rendered mode if we don't want to wait for further I/O. It also allows us to cancel fallbacks if we complete the main content before the fallback. * Expose abort API to the browser streams Since this API already returns a value, we need to use destructuring to expose more options. * Add a test including the client actually client rendering it * Use AbortSignal option for W3C streams instead of external control * Clean up listener after it's used once
1 parent e8b2645 commit 9c5faec

File tree

9 files changed

+275
-19
lines changed

9 files changed

+275
-19
lines changed

fixtures/fizz-ssr-browser/index.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ <h1>Fizz Example</h1>
2020
<script src="../../build/node_modules/react-dom/umd/react-dom-unstable-fizz.browser.development.js"></script>
2121
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
2222
<script type="text/babel">
23-
let stream = ReactDOMFizzServer.renderToReadableStream(<body>Success</body>);
23+
let controller = new AbortController();
24+
let stream = ReactDOMFizzServer.renderToReadableStream(<body>Success</body>, {
25+
signal: controller.signal,
26+
});
2427
let response = new Response(stream, {
2528
headers: {'Content-Type': 'text/html'},
2629
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@babel/preset-react": "^7.10.4",
3737
"@babel/traverse": "^7.11.0",
3838
"@mattiasbuelens/web-streams-polyfill": "^0.3.2",
39+
"abort-controller": "^3.0.0",
3940
"art": "0.10.1",
4041
"babel-eslint": "^10.0.3",
4142
"babel-plugin-syntax-trailing-function-commas": "^6.5.0",

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,55 @@ describe('ReactDOMFizzServer', () => {
263263
// Now it's hydrated.
264264
expect(ref.current).toBe(h1);
265265
});
266+
267+
// @gate experimental
268+
it('client renders a boundary if it does not resolve before aborting', async () => {
269+
function App() {
270+
return (
271+
<div>
272+
<Suspense fallback="Loading...">
273+
<h1>
274+
<AsyncText text="Hello" />
275+
</h1>
276+
</Suspense>
277+
</div>
278+
);
279+
}
280+
281+
let controls;
282+
await act(async () => {
283+
controls = ReactDOMFizzServer.pipeToNodeWritable(<App />, writable);
284+
});
285+
286+
// We're still showing a fallback.
287+
288+
// Attempt to hydrate the content.
289+
const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
290+
root.render(<App />);
291+
Scheduler.unstable_flushAll();
292+
293+
// We're still loading because we're waiting for the server to stream more content.
294+
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
295+
296+
// We abort the server response.
297+
await act(async () => {
298+
controls.abort();
299+
});
300+
301+
// We still can't render it on the client.
302+
Scheduler.unstable_flushAll();
303+
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
304+
305+
// We now resolve it on the client.
306+
resolveText('Hello');
307+
308+
Scheduler.unstable_flushAll();
309+
310+
// The client rendered HTML is now in place.
311+
expect(getVisibleChildren(container)).toEqual(
312+
<div>
313+
<h1>Hello</h1>
314+
</div>,
315+
);
316+
});
266317
});

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// Polyfills for test environment
1313
global.ReadableStream = require('@mattiasbuelens/web-streams-polyfill/ponyfill/es6').ReadableStream;
1414
global.TextEncoder = require('util').TextEncoder;
15+
global.AbortController = require('abort-controller');
1516

1617
let React;
1718
let ReactDOMFizzServer;
@@ -110,4 +111,22 @@ describe('ReactDOMFizzServer', () => {
110111
const result = await readResult(stream);
111112
expect(result).toContain('Loading');
112113
});
114+
115+
// @gate experimental
116+
it('should be able to complete by aborting even if the promise never resolves', async () => {
117+
const controller = new AbortController();
118+
const stream = ReactDOMFizzServer.renderToReadableStream(
119+
<div>
120+
<Suspense fallback={<div>Loading</div>}>
121+
<InfiniteSuspend />
122+
</Suspense>
123+
</div>,
124+
{signal: controller.signal},
125+
);
126+
127+
controller.abort();
128+
129+
const result = await readResult(stream);
130+
expect(result).toContain('Loading');
131+
});
113132
});

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,54 @@ describe('ReactDOMFizzServer', () => {
115115
expect(output.error).toBe(undefined);
116116
expect(output.result).toContain('Loading');
117117
});
118+
119+
// @gate experimental
120+
it('should not attempt to render the fallback if the main content completes first', async () => {
121+
const {writable, output, completed} = getTestWritable();
122+
123+
let renderedFallback = false;
124+
function Fallback() {
125+
renderedFallback = true;
126+
return 'Loading...';
127+
}
128+
function Content() {
129+
return 'Hi';
130+
}
131+
ReactDOMFizzServer.pipeToNodeWritable(
132+
<Suspense fallback={<Fallback />}>
133+
<Content />
134+
</Suspense>,
135+
writable,
136+
);
137+
138+
await completed;
139+
140+
expect(output.result).toContain('Hi');
141+
expect(output.result).not.toContain('Loading');
142+
expect(renderedFallback).toBe(false);
143+
});
144+
145+
// @gate experimental
146+
it('should be able to complete by aborting even if the promise never resolves', async () => {
147+
const {writable, output, completed} = getTestWritable();
148+
const {abort} = ReactDOMFizzServer.pipeToNodeWritable(
149+
<div>
150+
<Suspense fallback={<div>Loading</div>}>
151+
<InfiniteSuspend />
152+
</Suspense>
153+
</div>,
154+
writable,
155+
);
156+
157+
jest.runAllTimers();
158+
159+
expect(output.result).toContain('Loading');
160+
161+
abort();
162+
163+
await completed;
164+
165+
expect(output.error).toBe(undefined);
166+
expect(output.result).toContain('Loading');
167+
});
118168
});

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,26 @@ import {
1313
createRequest,
1414
startWork,
1515
startFlowing,
16+
abort,
1617
} from 'react-server/src/ReactFizzServer';
1718

18-
function renderToReadableStream(children: ReactNodeList): ReadableStream {
19+
type Options = {
20+
signal?: AbortSignal,
21+
};
22+
23+
function renderToReadableStream(
24+
children: ReactNodeList,
25+
options?: Options,
26+
): ReadableStream {
1927
let request;
28+
if (options && options.signal) {
29+
const signal = options.signal;
30+
const listener = () => {
31+
abort(request);
32+
signal.removeEventListener('abort', listener);
33+
};
34+
signal.addEventListener('abort', listener);
35+
}
2036
return new ReadableStream({
2137
start(controller) {
2238
request = createRequest(children, controller);

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,31 @@ import {
1414
createRequest,
1515
startWork,
1616
startFlowing,
17+
abort,
1718
} from 'react-server/src/ReactFizzServer';
1819

1920
function createDrainHandler(destination, request) {
2021
return () => startFlowing(request);
2122
}
2223

24+
type Controls = {
25+
// Cancel any pending I/O and put anything remaining into
26+
// client rendered mode.
27+
abort(): void,
28+
};
29+
2330
function pipeToNodeWritable(
2431
children: ReactNodeList,
2532
destination: Writable,
26-
): void {
33+
): Controls {
2734
const request = createRequest(children, destination);
2835
destination.on('drain', createDrainHandler(destination, request));
2936
startWork(request);
37+
return {
38+
abort() {
39+
abort(request);
40+
},
41+
};
3042
}
3143

3244
export {pipeToNodeWritable};

packages/react-noop-renderer/src/ReactNoopServer.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ function render(children: React$Element<any>): Destination {
216216
placeholders: new Map(),
217217
segments: new Map(),
218218
stack: [],
219+
abort() {
220+
ReactNoopServer.abort(request);
221+
},
219222
};
220223
const request = ReactNoopServer.createRequest(children, destination);
221224
ReactNoopServer.startWork(request);

0 commit comments

Comments
 (0)