Skip to content

Commit c891489

Browse files
committed
[Flight][Static] Implement halting a prerender behind enableHalt
enableHalt turns on a mode for flight prerenders where aborts are treated like infinitely stalled outcomes while still completing the prerender. For regular tasks we simply serialize the slot as a promise that never settles. For ReadableStream, Blob, and Async Iterators we just never advance the serialization so they remain unfinished when consumed on the client. When enableHalt is turned on aborts of prerenders will halt rather than error. The abort reason is forwarded to the upstream produces of the aforementioned async iterators, blobs, and ReadableStreams. In the future if we expose a signal that you can consume from within a render to cancel additional work the abort reason will also be forwarded there
1 parent fa6eab5 commit c891489

19 files changed

+856
-120
lines changed

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ import type {Thenable} from 'shared/ReactTypes';
2020

2121
import {Readable} from 'stream';
2222

23+
import {enableHalt} from 'shared/ReactFeatureFlags';
24+
2325
import {
2426
createRequest,
2527
startWork,
2628
startFlowing,
2729
stopFlowing,
2830
abort,
31+
halt,
2932
} from 'react-server/src/ReactFlightServer';
3033

3134
import {
@@ -187,10 +190,20 @@ function prerenderToNodeStream(
187190
if (options && options.signal) {
188191
const signal = options.signal;
189192
if (signal.aborted) {
190-
abort(request, (signal: any).reason);
193+
const reason = (signal: any).reason;
194+
if (enableHalt) {
195+
halt(request, reason);
196+
} else {
197+
abort(request, reason);
198+
}
191199
} else {
192200
const listener = () => {
193-
abort(request, (signal: any).reason);
201+
const reason = (signal: any).reason;
202+
if (enableHalt) {
203+
halt(request, reason);
204+
} else {
205+
abort(request, reason);
206+
}
194207
signal.removeEventListener('abort', listener);
195208
};
196209
signal.addEventListener('abort', listener);

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import type {Thenable} from 'shared/ReactTypes';
1212
import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler';
1313
import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';
1414

15+
import {enableHalt} from 'shared/ReactFeatureFlags';
16+
1517
import {
1618
createRequest,
1719
startWork,
1820
startFlowing,
1921
stopFlowing,
2022
abort,
23+
halt,
2124
} from 'react-server/src/ReactFlightServer';
2225

2326
import {
@@ -146,10 +149,20 @@ function prerender(
146149
if (options && options.signal) {
147150
const signal = options.signal;
148151
if (signal.aborted) {
149-
abort(request, (signal: any).reason);
152+
const reason = (signal: any).reason;
153+
if (enableHalt) {
154+
halt(request, reason);
155+
} else {
156+
abort(request, reason);
157+
}
150158
} else {
151159
const listener = () => {
152-
abort(request, (signal: any).reason);
160+
const reason = (signal: any).reason;
161+
if (enableHalt) {
162+
halt(request, reason);
163+
} else {
164+
abort(request, reason);
165+
}
153166
signal.removeEventListener('abort', listener);
154167
};
155168
signal.addEventListener('abort', listener);

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import type {Thenable} from 'shared/ReactTypes';
1212
import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler';
1313
import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';
1414

15+
import {enableHalt} from 'shared/ReactFeatureFlags';
16+
1517
import {
1618
createRequest,
1719
startWork,
1820
startFlowing,
1921
stopFlowing,
2022
abort,
23+
halt,
2124
} from 'react-server/src/ReactFlightServer';
2225

2326
import {
@@ -146,10 +149,20 @@ function prerender(
146149
if (options && options.signal) {
147150
const signal = options.signal;
148151
if (signal.aborted) {
149-
abort(request, (signal: any).reason);
152+
const reason = (signal: any).reason;
153+
if (enableHalt) {
154+
halt(request, reason);
155+
} else {
156+
abort(request, reason);
157+
}
150158
} else {
151159
const listener = () => {
152-
abort(request, (signal: any).reason);
160+
const reason = (signal: any).reason;
161+
if (enableHalt) {
162+
halt(request, reason);
163+
} else {
164+
abort(request, reason);
165+
}
153166
signal.removeEventListener('abort', listener);
154167
};
155168
signal.addEventListener('abort', listener);

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ import type {Thenable} from 'shared/ReactTypes';
2020

2121
import {Readable} from 'stream';
2222

23+
import {enableHalt} from 'shared/ReactFeatureFlags';
24+
2325
import {
2426
createRequest,
2527
startWork,
2628
startFlowing,
2729
stopFlowing,
2830
abort,
31+
halt,
2932
} from 'react-server/src/ReactFlightServer';
3033

3134
import {
@@ -189,10 +192,20 @@ function prerenderToNodeStream(
189192
if (options && options.signal) {
190193
const signal = options.signal;
191194
if (signal.aborted) {
192-
abort(request, (signal: any).reason);
195+
const reason = (signal: any).reason;
196+
if (enableHalt) {
197+
halt(request, reason);
198+
} else {
199+
abort(request, reason);
200+
}
193201
} else {
194202
const listener = () => {
195-
abort(request, (signal: any).reason);
203+
const reason = (signal: any).reason;
204+
if (enableHalt) {
205+
halt(request, reason);
206+
} else {
207+
abort(request, reason);
208+
}
196209
signal.removeEventListener('abort', listener);
197210
};
198211
signal.addEventListener('abort', listener);

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

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2722,4 +2722,138 @@ describe('ReactFlightDOM', () => {
27222722
await readInto(container, fizzReadable);
27232723
expect(getMeaningfulChildren(container)).toEqual(<div>hello world</div>);
27242724
});
2725+
2726+
// @gate enableHalt
2727+
it('serializes unfinished tasks with infinite promises when aborting a prerender', async () => {
2728+
let resolveGreeting;
2729+
const greetingPromise = new Promise(resolve => {
2730+
resolveGreeting = resolve;
2731+
});
2732+
2733+
function App() {
2734+
return (
2735+
<div>
2736+
<Suspense fallback="loading...">
2737+
<Greeting />
2738+
</Suspense>
2739+
</div>
2740+
);
2741+
}
2742+
2743+
async function Greeting() {
2744+
await greetingPromise;
2745+
return 'hello world';
2746+
}
2747+
2748+
const controller = new AbortController();
2749+
const {pendingResult} = await serverAct(async () => {
2750+
// destructure trick to avoid the act scope from awaiting the returned value
2751+
return {
2752+
pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream(
2753+
<App />,
2754+
webpackMap,
2755+
{
2756+
signal: controller.signal,
2757+
},
2758+
),
2759+
};
2760+
});
2761+
2762+
controller.abort();
2763+
resolveGreeting();
2764+
const {prelude} = await pendingResult;
2765+
2766+
const preludeWeb = Readable.toWeb(prelude);
2767+
const response = ReactServerDOMClient.createFromReadableStream(preludeWeb);
2768+
2769+
const {writable: fizzWritable, readable: fizzReadable} = getTestStream();
2770+
2771+
function ClientApp() {
2772+
return use(response);
2773+
}
2774+
2775+
const errors = [];
2776+
let abortFizz;
2777+
await serverAct(async () => {
2778+
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
2779+
React.createElement(ClientApp),
2780+
{
2781+
onError(error) {
2782+
errors.push(error);
2783+
},
2784+
},
2785+
);
2786+
pipe(fizzWritable);
2787+
abortFizz = abort;
2788+
});
2789+
2790+
await serverAct(() => {
2791+
abortFizz('boom');
2792+
});
2793+
2794+
expect(errors).toEqual(['boom']);
2795+
2796+
const container = document.createElement('div');
2797+
await readInto(container, fizzReadable);
2798+
expect(getMeaningfulChildren(container)).toEqual(<div>loading...</div>);
2799+
});
2800+
2801+
// @gate enableHalt
2802+
it('will leave async iterables in an incomplete state when halting', async () => {
2803+
let resolve;
2804+
const wait = new Promise(r => (resolve = r));
2805+
const errors = [];
2806+
2807+
const multiShotIterable = {
2808+
async *[Symbol.asyncIterator]() {
2809+
yield {hello: 'A'};
2810+
await wait;
2811+
yield {hi: 'B'};
2812+
return 'C';
2813+
},
2814+
};
2815+
2816+
const controller = new AbortController();
2817+
const {pendingResult} = await serverAct(() => {
2818+
return {
2819+
pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream(
2820+
{
2821+
multiShotIterable,
2822+
},
2823+
{},
2824+
{
2825+
onError(x) {
2826+
errors.push(x);
2827+
},
2828+
signal: controller.signal,
2829+
},
2830+
),
2831+
};
2832+
});
2833+
2834+
controller.abort();
2835+
await serverAct(() => resolve());
2836+
2837+
const {prelude} = await pendingResult;
2838+
2839+
const result = await ReactServerDOMClient.createFromReadableStream(
2840+
Readable.toWeb(prelude),
2841+
);
2842+
2843+
const iterator = result.multiShotIterable[Symbol.asyncIterator]();
2844+
2845+
expect(await iterator.next()).toEqual({
2846+
value: {hello: 'A'},
2847+
done: false,
2848+
});
2849+
2850+
const race = Promise.race([
2851+
iterator.next(),
2852+
new Promise(r => setTimeout(() => r('timeout'), 10)),
2853+
]);
2854+
2855+
await 1;
2856+
jest.advanceTimersByTime('100');
2857+
expect(await race).toBe('timeout');
2858+
});
27252859
});

0 commit comments

Comments
 (0)