Skip to content

Commit

Permalink
[Flight] Implement prerender
Browse files Browse the repository at this point in the history
Prerendering in flight is similar to prerendering in Fizz. Instead of receiving a result (the stream) immediately a promise is returned which resolves to the stream when the prerender is complete. The promise will reject if the flight render fatally errors otherwise it will resolve when the render is completed or is aborted.
  • Loading branch information
gnoff committed Aug 15, 2024
1 parent 8b08ee0 commit 0a55ade
Show file tree
Hide file tree
Showing 59 changed files with 1,174 additions and 9 deletions.
7 changes: 7 additions & 0 deletions fixtures/flight/__tests__/__e2e__/smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ test('smoke test', async ({page}) => {
await expect(page.getByTestId('promise-as-a-child-test')).toHaveText(
'Promise as a child hydrates without errors: deferred text'
);
await expect(page.getByTestId('prerendered')).not.toBeAttached();

await expect(consoleErrors).toEqual([]);
await expect(pageErrors).toEqual([]);

await page.goto('/prerender');
await expect(page.getByTestId('prerendered')).toBeAttached();

await expect(consoleErrors).toEqual([]);
await expect(pageErrors).toEqual([]);
Expand Down
11 changes: 8 additions & 3 deletions fixtures/flight/server/global.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function request(options, body) {
});
}

app.all('/', async function (req, res, next) {
async function renderApp(req, res, next) {
// Proxy the request to the regional server.
const proxiedHeaders = {
'X-Forwarded-Host': req.hostname,
Expand All @@ -102,12 +102,14 @@ app.all('/', async function (req, res, next) {
proxiedHeaders['Content-type'] = req.get('Content-type');
}

const requestsPrerender = req.path === '/prerender';

const promiseForData = request(
{
host: '127.0.0.1',
port: 3001,
method: req.method,
path: '/',
path: requestsPrerender ? '/?prerender=1' : '/',
headers: proxiedHeaders,
},
req
Expand Down Expand Up @@ -210,7 +212,10 @@ app.all('/', async function (req, res, next) {
res.end();
}
}
});
}

app.all('/', renderApp);
app.all('/prerender', renderApp);

if (process.env.NODE_ENV === 'development') {
app.use(express.static('public'));
Expand Down
61 changes: 60 additions & 1 deletion fixtures/flight/server/region.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,67 @@ async function renderApp(res, returnValue, formState) {
pipe(res);
}

async function prerenderApp(res, returnValue, formState) {
const {prerenderToNodeStream} = await import(
'react-server-dom-webpack/static'
);
// const m = require('../src/App.js');
const m = await import('../src/App.js');

let moduleMap;
let mainCSSChunks;
if (process.env.NODE_ENV === 'development') {
// Read the module map from the HMR server in development.
moduleMap = await (
await fetch('http://localhost:3000/react-client-manifest.json')
).json();
mainCSSChunks = (
await (
await fetch('http://localhost:3000/entrypoint-manifest.json')
).json()
).main.css;
} else {
// Read the module map from the static build in production.
moduleMap = JSON.parse(
await readFile(
path.resolve(__dirname, `../build/react-client-manifest.json`),
'utf8'
)
);
mainCSSChunks = JSON.parse(
await readFile(
path.resolve(__dirname, `../build/entrypoint-manifest.json`),
'utf8'
)
).main.css;
}
const App = m.default.default || m.default;
const root = React.createElement(
React.Fragment,
null,
// Prepend the App's tree with stylesheets required for this entrypoint.
mainCSSChunks.map(filename =>
React.createElement('link', {
rel: 'stylesheet',
href: filename,
precedence: 'default',
key: filename,
})
),
React.createElement(App, {prerender: true})
);
// For client-invoked server actions we refresh the tree and return a return value.
const payload = {root, returnValue, formState};
const {prelude} = await prerenderToNodeStream(payload, moduleMap);
prelude.pipe(res);
}

app.get('/', async function (req, res) {
await renderApp(res, null, null);
if ('prerender' in req.query) {
await prerenderApp(res, null, null);
} else {
await renderApp(res, null, null);
}
});

app.post('/', bodyParser.text(), async function (req, res) {
Expand Down
7 changes: 6 additions & 1 deletion fixtures/flight/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const promisedText = new Promise(resolve =>
setTimeout(() => resolve('deferred text'), 100)
);

export default async function App() {
export default async function App({prerender}) {
const res = await fetch('http://localhost:3001/todos');
const todos = await res.json();
return (
Expand All @@ -35,6 +35,11 @@ export default async function App() {
</head>
<body>
<Container>
{prerender ? (
<meta data-testid="prerendered" name="prerendered" content="true" />
) : (
<meta content="when not prerendering we render this meta tag. When prerendering you will expect to see this tag and the one with data-testid=prerendered because we SSR one and hydrate the other" />
)}
<h1>{getServerState()}</h1>
<React.Suspense fallback={null}>
<div data-testid="promise-as-a-child-test">
Expand Down
6 changes: 6 additions & 0 deletions packages/react-server-dom-esm/npm/static.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict';

throw new Error(
'The React Server Writer cannot be used outside a react-server environment. ' +
'You must configure Node.js using the `--conditions react-server` flag.'
);
12 changes: 12 additions & 0 deletions packages/react-server-dom-esm/npm/static.node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';

var s;
if (process.env.NODE_ENV === 'production') {
s = require('./cjs/react-server-dom-esm-server.node.production.js');
} else {
s = require('./cjs/react-server-dom-esm-server.node.development.js');
}

if (s.prerenderToNodeStream) {
exports.prerenderToNodeStream = s.prerenderToNodeStream;
}
7 changes: 7 additions & 0 deletions packages/react-server-dom-esm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"client.node.js",
"server.js",
"server.node.js",
"static.js",
"static.node.js",
"cjs/",
"esm/"
],
Expand All @@ -33,6 +35,11 @@
"default": "./server.js"
},
"./server.node": "./server.node.js",
"./static": {
"react-server": "./static.node.js",
"default": "./static.js"
},
"./static.node": "./static.node.js",
"./node-loader": "./esm/react-server-dom-esm-node-loader.production.js",
"./src/*": "./src/*.js",
"./package.json": "./package.json"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {Busboy} from 'busboy';
import type {Writable} from 'stream';
import type {Thenable} from 'shared/ReactTypes';

import {Readable} from 'stream';

import {
createRequest,
startWork,
Expand Down Expand Up @@ -123,6 +125,80 @@ function renderToPipeableStream(
},
};
}
function createFakeWritable(readable: any): Writable {
// The current host config expects a Writable so we create
// a fake writable for now to push into the Readable.
return ({
write(chunk) {
return readable.push(chunk);
},
end() {
readable.push(null);
},
destroy(error) {
readable.destroy(error);
},
}: any);
}

type PrerenderOptions = {
environmentName?: string | (() => string),
filterStackFrame?: (url: string, functionName: string) => boolean,
onError?: (error: mixed) => void,
onPostpone?: (reason: string) => void,
identifierPrefix?: string,
temporaryReferences?: TemporaryReferenceSet,
signal?: AbortSignal,
};

type StaticResult = {
prelude: Readable,
};

function prerenderToNodeStream(
model: ReactClientValue,
moduleBasePath: ClientManifest,
options?: PrerenderOptions,
): 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);
resolve({prelude: readable});
}

const request = createRequest(
model,
moduleBasePath,
options ? options.onError : undefined,
options ? options.identifierPrefix : undefined,
options ? options.onPostpone : undefined,
options ? options.temporaryReferences : undefined,
__DEV__ && options ? options.environmentName : undefined,
__DEV__ && options ? options.filterStackFrame : undefined,
onAllReady,
onFatalError,
);
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);
});
}

function decodeReplyFromBusboy<T>(
busboyStream: Busboy,
Expand Down Expand Up @@ -207,6 +283,7 @@ function decodeReply<T>(

export {
renderToPipeableStream,
prerenderToNodeStream,
decodeReplyFromBusboy,
decodeReply,
decodeAction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

export {
renderToPipeableStream,
prerenderToNodeStream,
decodeReplyFromBusboy,
decodeReply,
decodeAction,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

export {
renderToPipeableStream,
decodeReplyFromBusboy,
decodeReply,
decodeAction,
decodeFormState,
registerServerReference,
registerClientReference,
createTemporaryReferenceSet,
} from './ReactFlightDOMServerNode';
13 changes: 13 additions & 0 deletions packages/react-server-dom-esm/static.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

throw new Error(
'The React Server cannot be used outside a react-server environment. ' +
'You must configure Node.js using the `--conditions react-server` flag.',
);
10 changes: 10 additions & 0 deletions packages/react-server-dom-esm/static.node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

export {prerenderToNodeStream} from './src/server/react-flight-dom-server.node';
12 changes: 12 additions & 0 deletions packages/react-server-dom-turbopack/npm/static.browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';

var s;
if (process.env.NODE_ENV === 'production') {
s = require('./cjs/react-server-dom-turbopack-server.browser.production.js');
} else {
s = require('./cjs/react-server-dom-turbopack-server.browser.development.js');
}

if (s.prerender) {
exports.prerender = s.prerender;
}
12 changes: 12 additions & 0 deletions packages/react-server-dom-turbopack/npm/static.edge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';

var s;
if (process.env.NODE_ENV === 'production') {
s = require('./cjs/react-server-dom-turbopack-server.edge.production.js');
} else {
s = require('./cjs/react-server-dom-turbopack-server.edge.development.js');
}

if (s.prerender) {
exports.prerender = s.prerender;
}
6 changes: 6 additions & 0 deletions packages/react-server-dom-turbopack/npm/static.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict';

throw new Error(
'The React Server Writer cannot be used outside a react-server environment. ' +
'You must configure Node.js using the `--conditions react-server` flag.'
);
12 changes: 12 additions & 0 deletions packages/react-server-dom-turbopack/npm/static.node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';

var s;
if (process.env.NODE_ENV === 'production') {
s = require('./cjs/react-server-dom-turbopack-server.node.production.js');
} else {
s = require('./cjs/react-server-dom-turbopack-server.node.development.js');
}

if (s.prerenderToNodeStream) {
exports.prerenderToNodeStream = s.prerenderToNodeStream;
}
12 changes: 12 additions & 0 deletions packages/react-server-dom-turbopack/npm/static.node.unbundled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';

var s;
if (process.env.NODE_ENV === 'production') {
s = require('./cjs/react-server-dom-turbopack-server.node.unbundled.production.js');
} else {
s = require('./cjs/react-server-dom-turbopack-server.node.unbundled.development.js');
}

if (s.prerenderToNodeStream) {
exports.prerenderToNodeStream = s.prerenderToNodeStream;
}
Loading

0 comments on commit 0a55ade

Please sign in to comment.