Skip to content

Commit fa6eab5

Browse files
authored
[Flight] Implement prerender (#30686)
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.
1 parent 50d2197 commit fa6eab5

File tree

59 files changed

+1174
-9
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+1174
-9
lines changed

fixtures/flight/__tests__/__e2e__/smoke.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ test('smoke test', async ({page}) => {
1616
await expect(page.getByTestId('promise-as-a-child-test')).toHaveText(
1717
'Promise as a child hydrates without errors: deferred text'
1818
);
19+
await expect(page.getByTestId('prerendered')).not.toBeAttached();
20+
21+
await expect(consoleErrors).toEqual([]);
22+
await expect(pageErrors).toEqual([]);
23+
24+
await page.goto('/prerender');
25+
await expect(page.getByTestId('prerendered')).toBeAttached();
1926

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

fixtures/flight/server/global.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ function request(options, body) {
8686
});
8787
}
8888

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

105+
const requestsPrerender = req.path === '/prerender';
106+
105107
const promiseForData = request(
106108
{
107109
host: '127.0.0.1',
108110
port: 3001,
109111
method: req.method,
110-
path: '/',
112+
path: requestsPrerender ? '/?prerender=1' : '/',
111113
headers: proxiedHeaders,
112114
},
113115
req
@@ -210,7 +212,10 @@ app.all('/', async function (req, res, next) {
210212
res.end();
211213
}
212214
}
213-
});
215+
}
216+
217+
app.all('/', renderApp);
218+
app.all('/prerender', renderApp);
214219

215220
if (process.env.NODE_ENV === 'development') {
216221
app.use(express.static('public'));

fixtures/flight/server/region.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,67 @@ async function renderApp(res, returnValue, formState) {
105105
pipe(res);
106106
}
107107

108+
async function prerenderApp(res, returnValue, formState) {
109+
const {prerenderToNodeStream} = await import(
110+
'react-server-dom-webpack/static'
111+
);
112+
// const m = require('../src/App.js');
113+
const m = await import('../src/App.js');
114+
115+
let moduleMap;
116+
let mainCSSChunks;
117+
if (process.env.NODE_ENV === 'development') {
118+
// Read the module map from the HMR server in development.
119+
moduleMap = await (
120+
await fetch('http://localhost:3000/react-client-manifest.json')
121+
).json();
122+
mainCSSChunks = (
123+
await (
124+
await fetch('http://localhost:3000/entrypoint-manifest.json')
125+
).json()
126+
).main.css;
127+
} else {
128+
// Read the module map from the static build in production.
129+
moduleMap = JSON.parse(
130+
await readFile(
131+
path.resolve(__dirname, `../build/react-client-manifest.json`),
132+
'utf8'
133+
)
134+
);
135+
mainCSSChunks = JSON.parse(
136+
await readFile(
137+
path.resolve(__dirname, `../build/entrypoint-manifest.json`),
138+
'utf8'
139+
)
140+
).main.css;
141+
}
142+
const App = m.default.default || m.default;
143+
const root = React.createElement(
144+
React.Fragment,
145+
null,
146+
// Prepend the App's tree with stylesheets required for this entrypoint.
147+
mainCSSChunks.map(filename =>
148+
React.createElement('link', {
149+
rel: 'stylesheet',
150+
href: filename,
151+
precedence: 'default',
152+
key: filename,
153+
})
154+
),
155+
React.createElement(App, {prerender: true})
156+
);
157+
// For client-invoked server actions we refresh the tree and return a return value.
158+
const payload = {root, returnValue, formState};
159+
const {prelude} = await prerenderToNodeStream(payload, moduleMap);
160+
prelude.pipe(res);
161+
}
162+
108163
app.get('/', async function (req, res) {
109-
await renderApp(res, null, null);
164+
if ('prerender' in req.query) {
165+
await prerenderApp(res, null, null);
166+
} else {
167+
await renderApp(res, null, null);
168+
}
110169
});
111170

112171
app.post('/', bodyParser.text(), async function (req, res) {

fixtures/flight/src/App.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const promisedText = new Promise(resolve =>
2323
setTimeout(() => resolve('deferred text'), 100)
2424
);
2525

26-
export default async function App() {
26+
export default async function App({prerender}) {
2727
const res = await fetch('http://localhost:3001/todos');
2828
const todos = await res.json();
2929
return (
@@ -35,6 +35,11 @@ export default async function App() {
3535
</head>
3636
<body>
3737
<Container>
38+
{prerender ? (
39+
<meta data-testid="prerendered" name="prerendered" content="true" />
40+
) : (
41+
<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" />
42+
)}
3843
<h1>{getServerState()}</h1>
3944
<React.Suspense fallback={null}>
4045
<div data-testid="promise-as-a-child-test">
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
'use strict';
2+
3+
throw new Error(
4+
'The React Server Writer cannot be used outside a react-server environment. ' +
5+
'You must configure Node.js using the `--conditions react-server` flag.'
6+
);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use strict';
2+
3+
var s;
4+
if (process.env.NODE_ENV === 'production') {
5+
s = require('./cjs/react-server-dom-esm-server.node.production.js');
6+
} else {
7+
s = require('./cjs/react-server-dom-esm-server.node.development.js');
8+
}
9+
10+
if (s.prerenderToNodeStream) {
11+
exports.prerenderToNodeStream = s.prerenderToNodeStream;
12+
}

packages/react-server-dom-esm/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"client.node.js",
1818
"server.js",
1919
"server.node.js",
20+
"static.js",
21+
"static.node.js",
2022
"cjs/",
2123
"esm/"
2224
],
@@ -33,6 +35,11 @@
3335
"default": "./server.js"
3436
},
3537
"./server.node": "./server.node.js",
38+
"./static": {
39+
"react-server": "./static.node.js",
40+
"default": "./static.js"
41+
},
42+
"./static.node": "./static.node.js",
3643
"./node-loader": "./esm/react-server-dom-esm-node-loader.production.js",
3744
"./src/*": "./src/*.js",
3845
"./package.json": "./package.json"

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import type {Busboy} from 'busboy';
1818
import type {Writable} from 'stream';
1919
import type {Thenable} from 'shared/ReactTypes';
2020

21+
import {Readable} from 'stream';
22+
2123
import {
2224
createRequest,
2325
startWork,
@@ -123,6 +125,80 @@ function renderToPipeableStream(
123125
},
124126
};
125127
}
128+
function createFakeWritable(readable: any): Writable {
129+
// The current host config expects a Writable so we create
130+
// a fake writable for now to push into the Readable.
131+
return ({
132+
write(chunk) {
133+
return readable.push(chunk);
134+
},
135+
end() {
136+
readable.push(null);
137+
},
138+
destroy(error) {
139+
readable.destroy(error);
140+
},
141+
}: any);
142+
}
143+
144+
type PrerenderOptions = {
145+
environmentName?: string | (() => string),
146+
filterStackFrame?: (url: string, functionName: string) => boolean,
147+
onError?: (error: mixed) => void,
148+
onPostpone?: (reason: string) => void,
149+
identifierPrefix?: string,
150+
temporaryReferences?: TemporaryReferenceSet,
151+
signal?: AbortSignal,
152+
};
153+
154+
type StaticResult = {
155+
prelude: Readable,
156+
};
157+
158+
function prerenderToNodeStream(
159+
model: ReactClientValue,
160+
moduleBasePath: ClientManifest,
161+
options?: PrerenderOptions,
162+
): Promise<StaticResult> {
163+
return new Promise((resolve, reject) => {
164+
const onFatalError = reject;
165+
function onAllReady() {
166+
const readable: Readable = new Readable({
167+
read() {
168+
startFlowing(request, writable);
169+
},
170+
});
171+
const writable = createFakeWritable(readable);
172+
resolve({prelude: readable});
173+
}
174+
175+
const request = createRequest(
176+
model,
177+
moduleBasePath,
178+
options ? options.onError : undefined,
179+
options ? options.identifierPrefix : undefined,
180+
options ? options.onPostpone : undefined,
181+
options ? options.temporaryReferences : undefined,
182+
__DEV__ && options ? options.environmentName : undefined,
183+
__DEV__ && options ? options.filterStackFrame : undefined,
184+
onAllReady,
185+
onFatalError,
186+
);
187+
if (options && options.signal) {
188+
const signal = options.signal;
189+
if (signal.aborted) {
190+
abort(request, (signal: any).reason);
191+
} else {
192+
const listener = () => {
193+
abort(request, (signal: any).reason);
194+
signal.removeEventListener('abort', listener);
195+
};
196+
signal.addEventListener('abort', listener);
197+
}
198+
}
199+
startWork(request);
200+
});
201+
}
126202

127203
function decodeReplyFromBusboy<T>(
128204
busboyStream: Busboy,
@@ -207,6 +283,7 @@ function decodeReply<T>(
207283

208284
export {
209285
renderToPipeableStream,
286+
prerenderToNodeStream,
210287
decodeReplyFromBusboy,
211288
decodeReply,
212289
decodeAction,

packages/react-server-dom-esm/src/server/react-flight-dom-server.node.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
export {
1111
renderToPipeableStream,
12+
prerenderToNodeStream,
1213
decodeReplyFromBusboy,
1314
decodeReply,
1415
decodeAction,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
export {
11+
renderToPipeableStream,
12+
decodeReplyFromBusboy,
13+
decodeReply,
14+
decodeAction,
15+
decodeFormState,
16+
registerServerReference,
17+
registerClientReference,
18+
createTemporaryReferenceSet,
19+
} from './ReactFlightDOMServerNode';

0 commit comments

Comments
 (0)