Skip to content

Commit

Permalink
[Flight][Static] When prerendering serialize infinite promise when ab…
Browse files Browse the repository at this point in the history
…orting with no reason

When prerendering if you abort the prerender without a reason instead of erroring each remaining task complete it with a promise that never resolve

Unfortunately when you abort with an AbortSignal without a value the aborted reason is defaulted to an AbortError DOMexception. We test for this and basically just say that if you abort with an AbortError DOMException that is equivalent to aborting with nothing. Practically this should be fine because usually you abort with a specific reason.
  • Loading branch information
gnoff committed Aug 16, 2024
1 parent fa6eab5 commit 43e51e0
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 43e51e0

Please sign in to comment.