Skip to content

Commit

Permalink
[Flight][Static] Implement halting a prerender behind enableHalt
Browse files Browse the repository at this point in the history
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
  • Loading branch information
gnoff committed Aug 16, 2024
1 parent fa6eab5 commit 1227997
Show file tree
Hide file tree
Showing 19 changed files with 848 additions and 108 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ import type {Thenable} from 'shared/ReactTypes';

import {Readable} from 'stream';

import {enableHalt} from 'shared/ReactFeatureFlags';

import {
createRequest,
startWork,
startFlowing,
stopFlowing,
abort,
halt,
} from 'react-server/src/ReactFlightServer';

import {
Expand Down Expand Up @@ -187,10 +190,20 @@ function prerenderToNodeStream(
if (options && options.signal) {
const signal = options.signal;
if (signal.aborted) {
abort(request, (signal: any).reason);
const reason = (signal: any).reason;
if (enableHalt) {
halt(request, reason);
} else {
abort(request, reason);
}
} else {
const listener = () => {
abort(request, (signal: any).reason);
const reason = (signal: any).reason;
if (enableHalt) {
halt(request, reason);
} else {
abort(request, reason);
}
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import type {Thenable} from 'shared/ReactTypes';
import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler';
import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';

import {enableHalt} from 'shared/ReactFeatureFlags';

import {
createRequest,
startWork,
startFlowing,
stopFlowing,
abort,
halt,
} from 'react-server/src/ReactFlightServer';

import {
Expand Down Expand Up @@ -146,10 +149,20 @@ function prerender(
if (options && options.signal) {
const signal = options.signal;
if (signal.aborted) {
abort(request, (signal: any).reason);
const reason = (signal: any).reason;
if (enableHalt) {
halt(request, reason);
} else {
abort(request, reason);
}
} else {
const listener = () => {
abort(request, (signal: any).reason);
const reason = (signal: any).reason;
if (enableHalt) {
halt(request, reason);
} else {
abort(request, reason);
}
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import type {Thenable} from 'shared/ReactTypes';
import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler';
import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig';

import {enableHalt} from 'shared/ReactFeatureFlags';

import {
createRequest,
startWork,
startFlowing,
stopFlowing,
abort,
halt,
} from 'react-server/src/ReactFlightServer';

import {
Expand Down Expand Up @@ -146,10 +149,20 @@ function prerender(
if (options && options.signal) {
const signal = options.signal;
if (signal.aborted) {
abort(request, (signal: any).reason);
const reason = (signal: any).reason;
if (enableHalt) {
halt(request, reason);
} else {
abort(request, reason);
}
} else {
const listener = () => {
abort(request, (signal: any).reason);
const reason = (signal: any).reason;
if (enableHalt) {
halt(request, reason);
} else {
abort(request, reason);
}
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ import type {Thenable} from 'shared/ReactTypes';

import {Readable} from 'stream';

import {enableHalt} from 'shared/ReactFeatureFlags';

import {
createRequest,
startWork,
startFlowing,
stopFlowing,
abort,
halt,
} from 'react-server/src/ReactFlightServer';

import {
Expand Down Expand Up @@ -189,10 +192,20 @@ function prerenderToNodeStream(
if (options && options.signal) {
const signal = options.signal;
if (signal.aborted) {
abort(request, (signal: any).reason);
const reason = (signal: any).reason;
if (enableHalt) {
halt(request, reason);
} else {
abort(request, reason);
}
} else {
const listener = () => {
abort(request, (signal: any).reason);
const reason = (signal: any).reason;
if (enableHalt) {
halt(request, reason);
} else {
abort(request, reason);
}
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
Expand Down
139 changes: 139 additions & 0 deletions packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2722,4 +2722,143 @@ describe('ReactFlightDOM', () => {
await readInto(container, fizzReadable);
expect(getMeaningfulChildren(container)).toEqual(<div>hello world</div>);
});

// @gate enableHalt
it('serializes unfinished tasks with infinite promises when aborting a prerender without a reason', async () => {
let resolveGreeting;
const greetingPromise = new Promise(resolve => {
resolveGreeting = resolve;
});

function App() {
return (
<div>
<Suspense fallback="loading...">
<Greeting />
</Suspense>
</div>
);
}

async function Greeting() {
await greetingPromise;
return 'hello world';
}

const controller = new AbortController();
const {pendingResult} = await serverAct(async () => {
// destructure trick to avoid the act scope from awaiting the returned value
return {
pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream(
<App />,
webpackMap,
{
signal: controller.signal,
},
),
};
});

controller.abort();
resolveGreeting();
const {prelude} = await pendingResult;

const preludeWeb = Readable.toWeb(prelude);
const response = ReactServerDOMClient.createFromReadableStream(preludeWeb);

const {writable: fizzWritable, readable: fizzReadable} = getTestStream();

function ClientApp() {
return use(response);
}

const shellErrors = [];
let abortFizz;
await serverAct(async () => {
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
React.createElement(ClientApp),
{
onShellError(error) {
shellErrors.push(error.message);
},
},
);
pipe(fizzWritable);
abortFizz = abort;
});

await serverAct(() => {
try {
React.unstable_postpone('abort reason');
} catch (reason) {
abortFizz(reason);
}
});

expect(shellErrors).toEqual([]);

const container = document.createElement('div');
await readInto(container, fizzReadable);
expect(getMeaningfulChildren(container)).toEqual(<div>loading...</div>);
});

// @gate enableHalt
it('will leave async iterables in an incomplete state when halting', async () => {
let resolve;
const wait = new Promise(r => (resolve = r));
const errors = [];

const multiShotIterable = {
async *[Symbol.asyncIterator]() {
yield {hello: 'A'};
await wait;
yield {hi: 'B'};
return 'C';
},
};

const controller = new AbortController();
const {pendingResult} = await serverAct(() => {
return {
pendingResult: ReactServerDOMStaticServer.prerenderToNodeStream(
{
multiShotIterable,
},
{},
{
onError(x) {
errors.push(x);
return x;
},
signal: controller.signal,
},
),
};
});

controller.abort();
await serverAct(() => resolve());

const {prelude} = await pendingResult;

const result = await ReactServerDOMClient.createFromReadableStream(
Readable.toWeb(prelude),
);

const iterator = result.multiShotIterable[Symbol.asyncIterator]();

expect(await iterator.next()).toEqual({
value: {hello: 'A'},
done: false,
});

const race = Promise.race([
iterator.next(),
new Promise(r => setTimeout(() => r('timeout'), 0)),
]);

await 1;
jest.advanceTimersByTime('100');
expect(await race).toBe('timeout');
});
});
Loading

0 comments on commit 1227997

Please sign in to comment.