Skip to content

Commit 2983249

Browse files
authored
[Fizz] implement onHeaders and headersLengthHint options (#27641)
Adds a new option to `react-dom/server` entrypoints. `onHeaders: (headers: Headers) => void` (non node envs) `onHeaders: (headers: { Link?: string }) => void` (node envs) When any `renderTo...` or `prerender...` function is called and this option is provided the supplied function will be called sometime on or before completion of the render with some preload link headers. When provided during a `renderTo...` the callback will usually be called after the first pass at work. The idea here is we want to get a set of headers to start the browser loading well before the shell is ready. We don't wait for the shell because if we did we may as well send the preloads as tags in the HTML. When provided during a `prerender...` the callback will be called after the entire prerender is complete. The idea here is we are not responding to a live request and it is preferable to capture as much as possible for preloading as Headers in case the prerender was unable to finish the shell. Currently the following resources are always preloaded as headers when the option is provided 1. prefetchDNS and preconnects 2. font preloads 3. high priority image preloads Additionally if we are providing headers when the shell is incomplete (regardless of whether it is render or prerender) we will also include any stylesheet Resources (ones with a precedence prop) There is a second option `maxHeadersLength?: number` which allows you to specify the maximum length of the header content in unicode code units. This is what you get when you read the length property of a string in javascript. It's improtant to note that this is not the same as the utf-8 byte length when these headers are serialized in a Response. The utf8 representation may be the same size, or larger but it will never be smaller. If you do not supply a `maxHeadersLength` we defaul to `2000`. This was chosen as half the value of the max headers length supported by commonly known web servers and CDNs. many browser and web server can support significantly more headers than this so you can use this option to increase the headers limit. You can also of course use it to be even more conservative. Again it is important to keep in mind there is no direct translation between the max length and the bytelength and so if you want to stay under a certain byte length you need to be potentially more aggressive in the maxHeadersLength you choose. Conceptually `onHeaders` could be called more than once as new headers are discovered however if we haven't started flushing yet but since most APIs for the server including the web standard Response only allow you to set headers once the current implementation will only call it one time
1 parent c897260 commit 2983249

18 files changed

+1148
-123
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 573 additions & 89 deletions
Large diffs are not rendered by default.

packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
*/
99

1010
import type {
11+
RenderState as BaseRenderState,
1112
ResumableState,
1213
BoundaryResources,
1314
StyleQueue,
1415
Resource,
16+
HeadersDescriptor,
1517
} from './ReactFizzConfigDOM';
1618

1719
import {
@@ -46,6 +48,14 @@ export type RenderState = {
4648
headChunks: null | Array<Chunk | PrecomputedChunk>,
4749
externalRuntimeScript: null | any,
4850
bootstrapChunks: Array<Chunk | PrecomputedChunk>,
51+
onHeaders: void | ((headers: HeadersDescriptor) => void),
52+
headers: null | {
53+
preconnects: string,
54+
fontPreloads: string,
55+
highImagePreloads: string,
56+
remainingCapacity: number,
57+
},
58+
resets: BaseRenderState['resets'],
4959
charsetChunks: Array<Chunk | PrecomputedChunk>,
5060
preconnectChunks: Array<Chunk | PrecomputedChunk>,
5161
importMapChunks: Array<Chunk | PrecomputedChunk>,
@@ -83,6 +93,7 @@ export function createRenderState(
8393
undefined,
8494
undefined,
8595
undefined,
96+
undefined,
8697
);
8798
return {
8899
// Keep this in sync with ReactFizzConfigDOM
@@ -94,6 +105,9 @@ export function createRenderState(
94105
headChunks: renderState.headChunks,
95106
externalRuntimeScript: renderState.externalRuntimeScript,
96107
bootstrapChunks: renderState.bootstrapChunks,
108+
onHeaders: renderState.onHeaders,
109+
headers: renderState.headers,
110+
resets: renderState.resets,
97111
charsetChunks: renderState.charsetChunks,
98112
preconnectChunks: renderState.preconnectChunks,
99113
importMapChunks: renderState.importMapChunks,
@@ -159,6 +173,7 @@ export {
159173
setCurrentlyRenderingBoundaryResourcesTarget,
160174
prepareHostDispatcher,
161175
resetResumableState,
176+
emitEarlyPreloads,
162177
} from './ReactFizzConfigDOM';
163178

164179
import escapeTextForBrowser from './escapeTextForBrowser';

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3690,6 +3690,157 @@ describe('ReactDOMFizzServer', () => {
36903690
);
36913691
});
36923692

3693+
it('provides headers after initial work if onHeaders option used', async () => {
3694+
let headers = null;
3695+
function onHeaders(x) {
3696+
headers = x;
3697+
}
3698+
3699+
function Preloads() {
3700+
ReactDOM.preload('font2', {as: 'font'});
3701+
ReactDOM.preload('imagepre2', {as: 'image', fetchPriority: 'high'});
3702+
ReactDOM.preconnect('pre2', {crossOrigin: 'use-credentials'});
3703+
ReactDOM.prefetchDNS('dns2');
3704+
}
3705+
3706+
function Blocked() {
3707+
readText('blocked');
3708+
return (
3709+
<>
3710+
<Preloads />
3711+
<img src="image2" />
3712+
</>
3713+
);
3714+
}
3715+
3716+
function App() {
3717+
ReactDOM.preload('font', {as: 'font'});
3718+
ReactDOM.preload('imagepre', {as: 'image', fetchPriority: 'high'});
3719+
ReactDOM.preconnect('pre', {crossOrigin: 'use-credentials'});
3720+
ReactDOM.prefetchDNS('dns');
3721+
return (
3722+
<html>
3723+
<body>
3724+
<img src="image" />
3725+
<Blocked />
3726+
</body>
3727+
</html>
3728+
);
3729+
}
3730+
3731+
await act(() => {
3732+
renderToPipeableStream(<App />, {onHeaders});
3733+
});
3734+
3735+
expect(headers).toEqual({
3736+
Link: `
3737+
<pre>; rel=preconnect; crossorigin="use-credentials",
3738+
<dns>; rel=dns-prefetch,
3739+
<font>; rel=preload; as="font"; crossorigin="",
3740+
<imagepre>; rel=preload; as="image"; fetchpriority="high",
3741+
<image>; rel=preload; as="image"
3742+
`
3743+
.replaceAll('\n', '')
3744+
.trim(),
3745+
});
3746+
});
3747+
3748+
it('encodes img srcset and sizes into preload header params', async () => {
3749+
let headers = null;
3750+
function onHeaders(x) {
3751+
headers = x;
3752+
}
3753+
3754+
function App() {
3755+
ReactDOM.preload('presrc', {
3756+
as: 'image',
3757+
fetchPriority: 'high',
3758+
imageSrcSet: 'presrcset',
3759+
imageSizes: 'presizes',
3760+
});
3761+
return (
3762+
<html>
3763+
<body>
3764+
<img src="src" srcSet="srcset" sizes="sizes" />
3765+
</body>
3766+
</html>
3767+
);
3768+
}
3769+
3770+
await act(() => {
3771+
renderToPipeableStream(<App />, {onHeaders});
3772+
});
3773+
3774+
expect(headers).toEqual({
3775+
Link: `
3776+
<presrc>; rel=preload; as="image"; fetchpriority="high"; imagesrcset="presrcset"; imagesizes="presizes",
3777+
<src>; rel=preload; as="image"; imagesrcset="srcset"; imagesizes="sizes"
3778+
`
3779+
.replaceAll('\n', '')
3780+
.trim(),
3781+
});
3782+
});
3783+
3784+
it('emits nothing for headers if you pipe before work begins', async () => {
3785+
let headers = null;
3786+
function onHeaders(x) {
3787+
headers = x;
3788+
}
3789+
3790+
function App() {
3791+
ReactDOM.preload('presrc', {
3792+
as: 'image',
3793+
fetchPriority: 'high',
3794+
imageSrcSet: 'presrcset',
3795+
imageSizes: 'presizes',
3796+
});
3797+
return (
3798+
<html>
3799+
<body>
3800+
<img src="src" srcSet="srcset" sizes="sizes" />
3801+
</body>
3802+
</html>
3803+
);
3804+
}
3805+
3806+
await act(() => {
3807+
renderToPipeableStream(<App />, {onHeaders}).pipe(writable);
3808+
});
3809+
3810+
expect(headers).toEqual({});
3811+
});
3812+
3813+
it('stops accumulating new headers once the maxHeadersLength limit is satisifed', async () => {
3814+
let headers = null;
3815+
function onHeaders(x) {
3816+
headers = x;
3817+
}
3818+
3819+
function App() {
3820+
ReactDOM.preconnect('foo');
3821+
ReactDOM.preconnect('bar');
3822+
ReactDOM.preconnect('baz');
3823+
return (
3824+
<html>
3825+
<body>hello</body>
3826+
</html>
3827+
);
3828+
}
3829+
3830+
await act(() => {
3831+
renderToPipeableStream(<App />, {onHeaders, maxHeadersLength: 44});
3832+
});
3833+
3834+
expect(headers).toEqual({
3835+
Link: `
3836+
<foo>; rel=preconnect,
3837+
<bar>; rel=preconnect
3838+
`
3839+
.replaceAll('\n', '')
3840+
.trim(),
3841+
});
3842+
});
3843+
36933844
describe('error escaping', () => {
36943845
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
36953846
window.__outlet = {};

packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
let JSDOM;
1414
let Stream;
1515
let React;
16+
let ReactDOM;
1617
let ReactDOMClient;
1718
let ReactDOMFizzStatic;
1819
let Suspense;
@@ -29,6 +30,7 @@ describe('ReactDOMFizzStatic', () => {
2930
jest.resetModules();
3031
JSDOM = require('jsdom').JSDOM;
3132
React = require('react');
33+
ReactDOM = require('react-dom');
3234
ReactDOMClient = require('react-dom/client');
3335
if (__EXPERIMENTAL__) {
3436
ReactDOMFizzStatic = require('react-dom/static');
@@ -262,4 +264,75 @@ describe('ReactDOMFizzStatic', () => {
262264
'hello world',
263265
]);
264266
});
267+
268+
// @gate experimental
269+
it('supports onHeaders', async () => {
270+
let headers;
271+
function onHeaders(x) {
272+
headers = x;
273+
}
274+
275+
function App() {
276+
ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
277+
ReactDOM.preload('font', {as: 'font'});
278+
return (
279+
<html>
280+
<body>hello</body>
281+
</html>
282+
);
283+
}
284+
285+
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
286+
onHeaders,
287+
});
288+
expect(headers).toEqual({
289+
Link: `
290+
<font>; rel=preload; as="font"; crossorigin="",
291+
<image>; rel=preload; as="image"; fetchpriority="high"
292+
`
293+
.replaceAll('\n', '')
294+
.trim(),
295+
});
296+
297+
await act(async () => {
298+
result.prelude.pipe(writable);
299+
});
300+
expect(getVisibleChildren(container)).toEqual('hello');
301+
});
302+
303+
// @gate experimental && enablePostpone
304+
it('includes stylesheet preloads in onHeaders when postponing in the Shell', async () => {
305+
let headers;
306+
function onHeaders(x) {
307+
headers = x;
308+
}
309+
310+
function App() {
311+
ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
312+
ReactDOM.preinit('style', {as: 'style'});
313+
React.unstable_postpone();
314+
return (
315+
<html>
316+
<body>hello</body>
317+
</html>
318+
);
319+
}
320+
321+
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
322+
onHeaders,
323+
});
324+
expect(headers).toEqual({
325+
Link: `
326+
<image>; rel=preload; as="image"; fetchpriority="high",
327+
<style>; rel=preload; as="style"
328+
`
329+
.replaceAll('\n', '')
330+
.trim(),
331+
});
332+
333+
await act(async () => {
334+
result.prelude.pipe(writable);
335+
});
336+
expect(getVisibleChildren(container)).toEqual(undefined);
337+
});
265338
});

0 commit comments

Comments
 (0)