Skip to content

Commit

Permalink
[Fizz] Add resumeAndPrerender to Static Rendering (#30950)
Browse files Browse the repository at this point in the history
This is only in the same experimental exports as `resume`. Useful with
Postpone/Halt.

We already have `prerender()` to create a partial tree with postponed
state. We also have `resume()` to dynamically resume such a tree.

This lets you do a new prerender by resuming an already existing
postponed state. Basically creating a chain of preludes. The next
prelude would include the scripts to patch up the document.

This mostly just works since both prerender and resume are already
implemented using the same code so we just enable both at the root. I'm
sure we'll find some edge cases since this wasn't considered when it was
first written but so far I've only found an unrelated existing bug with
`keyPath` fixed here.
  • Loading branch information
sebmarkbage authored Sep 12, 2024
1 parent bb6b86e commit 4735220
Show file tree
Hide file tree
Showing 14 changed files with 349 additions and 8 deletions.
1 change: 1 addition & 0 deletions packages/react-dom/npm/static.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') {

exports.version = s.version;
exports.prerender = s.prerender;
exports.resumeAndPrerender = s.resumeAndPrerender;
1 change: 1 addition & 0 deletions packages/react-dom/npm/static.edge.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') {

exports.version = s.version;
exports.prerender = s.prerender;
exports.resumeAndPrerender = s.resumeAndPrerender;
1 change: 1 addition & 0 deletions packages/react-dom/npm/static.node.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ if (process.env.NODE_ENV === 'production') {

exports.version = s.version;
exports.prerenderToNodeStream = s.prerenderToNodeStream;
exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream;
86 changes: 86 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1758,4 +1758,90 @@ describe('ReactDOMFizzStaticBrowser', () => {
await readIntoContainer(dynamic);
expect(getVisibleChildren(container)).toEqual('hello');
});

// @gate enableHalt
it('can resume render of a prerender', async () => {
const errors = [];

let resolveA;
const promiseA = new Promise(r => (resolveA = r));
let resolveB;
const promiseB = new Promise(r => (resolveB = r));

async function ComponentA() {
await promiseA;
return (
<Suspense fallback="Loading B">
<ComponentB />
</Suspense>
);
}

async function ComponentB() {
await promiseB;
return 'Hello';
}

function App() {
return (
<Suspense fallback="Loading A">
<ComponentA />
</Suspense>
);
}

const controller = new AbortController();
let pendingResult;
await serverAct(async () => {
pendingResult = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
});
});

controller.abort();

const prerendered = await pendingResult;
const postponedState = JSON.stringify(prerendered.postponed);

await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual('Loading A');

await resolveA();

expect(prerendered.postponed).not.toBe(null);

const controller2 = new AbortController();
await serverAct(async () => {
pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
<App />,
JSON.parse(postponedState),
{
signal: controller2.signal,
onError(x) {
errors.push(x.message);
},
},
);
});

controller2.abort();

const prerendered2 = await pendingResult;
const postponedState2 = JSON.stringify(prerendered2.postponed);

await readIntoContainer(prerendered2.prelude);
expect(getVisibleChildren(container)).toEqual('Loading B');

await resolveB();

const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState2)),
);

await readIntoContainer(dynamic);
expect(getVisibleChildren(container)).toEqual('Hello');
});
});
73 changes: 72 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import ReactVersion from 'shared/ReactVersion';

import {
createPrerenderRequest,
resumeAndPrerenderRequest,
startWork,
startFlowing,
stopFlowing,
Expand All @@ -33,6 +34,7 @@ import {
import {
createResumableState,
createRenderState,
resumeRenderState,
createRootFormatContext,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';

Expand Down Expand Up @@ -141,4 +143,73 @@ function prerender(
});
}

export {prerender, ReactVersion as version};
type ResumeOptions = {
nonce?: string,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

function resumeAndPrerender(
children: ReactNodeList,
postponedState: PostponedState,
options?: ResumeOptions,
): Promise<StaticResult> {
return new Promise((resolve, reject) => {
const onFatalError = reject;

function onAllReady() {
const stream = new ReadableStream(
{
type: 'bytes',
pull: (controller): ?Promise<void> => {
startFlowing(request, controller);
},
cancel: (reason): ?Promise<void> => {
stopFlowing(request);
abort(request, reason);
},
},
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
{highWaterMark: 0},
);

const result = {
postponed: getPostponedState(request),
prelude: stream,
};
resolve(result);
}

const request = resumeAndPrerenderRequest(
children,
postponedState,
resumeRenderState(
postponedState.resumableState,
options ? options.nonce : undefined,
),
options ? options.onError : undefined,
onAllReady,
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
if (signal.aborted) {
abort(request, (signal: any).reason);
} else {
const listener = () => {
abort(request, (signal: any).reason);
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
}
}
startWork(request);
});
}

export {prerender, resumeAndPrerender, ReactVersion as version};
73 changes: 72 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import ReactVersion from 'shared/ReactVersion';

import {
createPrerenderRequest,
resumeAndPrerenderRequest,
startWork,
startFlowing,
stopFlowing,
Expand All @@ -33,6 +34,7 @@ import {
import {
createResumableState,
createRenderState,
resumeRenderState,
createRootFormatContext,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';

Expand Down Expand Up @@ -140,4 +142,73 @@ function prerender(
});
}

export {prerender, ReactVersion as version};
type ResumeOptions = {
nonce?: string,
signal?: AbortSignal,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
};

function resumeAndPrerender(
children: ReactNodeList,
postponedState: PostponedState,
options?: ResumeOptions,
): Promise<StaticResult> {
return new Promise((resolve, reject) => {
const onFatalError = reject;

function onAllReady() {
const stream = new ReadableStream(
{
type: 'bytes',
pull: (controller): ?Promise<void> => {
startFlowing(request, controller);
},
cancel: (reason): ?Promise<void> => {
stopFlowing(request);
abort(request, reason);
},
},
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
{highWaterMark: 0},
);

const result = {
postponed: getPostponedState(request),
prelude: stream,
};
resolve(result);
}

const request = resumeAndPrerenderRequest(
children,
postponedState,
resumeRenderState(
postponedState.resumableState,
options ? options.nonce : undefined,
),
options ? options.onError : undefined,
onAllReady,
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
if (signal.aborted) {
abort(request, (signal: any).reason);
} else {
const listener = () => {
abort(request, (signal: any).reason);
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
}
}
startWork(request);
});
}

export {prerender, resumeAndPrerender, ReactVersion as version};
67 changes: 66 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzStaticNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import ReactVersion from 'shared/ReactVersion';

import {
createPrerenderRequest,
resumeAndPrerenderRequest,
startWork,
startFlowing,
abort,
Expand All @@ -34,6 +35,7 @@ import {
import {
createResumableState,
createRenderState,
resumeRenderState,
createRootFormatContext,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';

Expand Down Expand Up @@ -141,4 +143,67 @@ function prerenderToNodeStream(
});
}

export {prerenderToNodeStream, ReactVersion as version};
type ResumeOptions = {
nonce?: string,
signal?: AbortSignal,
onError?: (error: mixed, errorInfo: ErrorInfo) => ?string,
onPostpone?: (reason: string, postponeInfo: PostponeInfo) => void,
};

function resumeAndPrerenderToNodeStream(
children: ReactNodeList,
postponedState: PostponedState,
options?: ResumeOptions,
): Promise<StaticResult> {
return new Promise((resolve, reject) => {
const onFatalError = reject;

function onAllReady() {
const readable: Readable = new Readable({
read() {
startFlowing(request, writable);
},
});
const writable = createFakeWritable(readable);

const result = {
postponed: getPostponedState(request),
prelude: readable,
};
resolve(result);
}
const request = resumeAndPrerenderRequest(
children,
postponedState,
resumeRenderState(
postponedState.resumableState,
options ? options.nonce : undefined,
),
options ? options.onError : undefined,
onAllReady,
undefined,
undefined,
onFatalError,
options ? options.onPostpone : undefined,
);
if (options && options.signal) {
const signal = options.signal;
if (signal.aborted) {
abort(request, (signal: any).reason);
} else {
const listener = () => {
abort(request, (signal: any).reason);
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
}
}
startWork(request);
});
}

export {
prerenderToNodeStream,
resumeAndPrerenderToNodeStream,
ReactVersion as version,
};
2 changes: 1 addition & 1 deletion packages/react-dom/src/server/react-dom-server.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
*/

export * from './ReactDOMFizzServerBrowser.js';
export {prerender} from './ReactDOMFizzStaticBrowser.js';
export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticBrowser.js';
2 changes: 1 addition & 1 deletion packages/react-dom/src/server/react-dom-server.edge.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
*/

export * from './ReactDOMFizzServerEdge.js';
export {prerender} from './ReactDOMFizzStaticEdge.js';
export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticEdge.js';
5 changes: 4 additions & 1 deletion packages/react-dom/src/server/react-dom-server.node.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@
*/

export * from './ReactDOMFizzServerNode.js';
export {prerenderToNodeStream} from './ReactDOMFizzStaticNode.js';
export {
prerenderToNodeStream,
resumeAndPrerenderToNodeStream,
} from './ReactDOMFizzStaticNode.js';
Loading

0 comments on commit 4735220

Please sign in to comment.