From 2983249dd2bb1a295f27939e36ab0de9e4bfab76 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 7 Nov 2023 10:16:33 -0800 Subject: [PATCH] [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 --- .../src/server/ReactFizzConfigDOM.js | 662 +++++++++++++++--- .../src/server/ReactFizzConfigDOMLegacy.js | 15 + .../src/__tests__/ReactDOMFizzServer-test.js | 151 ++++ .../src/__tests__/ReactDOMFizzStatic-test.js | 73 ++ .../ReactDOMFizzStaticBrowser-test.js | 103 +++ .../src/server/ReactDOMFizzServerBrowser.js | 18 +- .../src/server/ReactDOMFizzServerBun.js | 18 +- .../src/server/ReactDOMFizzServerEdge.js | 18 +- .../src/server/ReactDOMFizzServerNode.js | 11 +- .../src/server/ReactDOMFizzStaticBrowser.js | 18 +- .../src/server/ReactDOMFizzStaticEdge.js | 17 +- .../src/server/ReactDOMFizzStaticNode.js | 9 +- .../react-dom/src/shared/ReactDOMTypes.js | 11 +- .../src/ReactNoopServer.js | 1 + packages/react-server/src/ReactFizzServer.js | 122 +++- .../src/forks/ReactFizzConfig.custom.js | 2 + packages/shared/CheckStringCoercion.js | 17 + scripts/error-codes/codes.json | 5 +- 18 files changed, 1148 insertions(+), 123 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index d3bac386bd..e8f9295d92 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -22,6 +22,7 @@ import { checkHtmlStringCoercion, checkCSSPropertyStringCoercion, checkAttributeStringCoercion, + checkOptionStringCoercion, } from 'shared/CheckStringCoercion'; import {Children} from 'react'; @@ -65,6 +66,7 @@ import {validateProperties as validateARIAProperties} from '../shared/ReactDOMIn import {validateProperties as validateInputProperties} from '../shared/ReactDOMNullInputValuePropHook'; import {validateProperties as validateUnknownProperties} from '../shared/ReactDOMUnknownPropertyHook'; import warnValidStyle from '../shared/warnValidStyle'; +import {getCrossOriginString} from '../shared/crossOriginStrings'; import escapeTextForBrowser from './escapeTextForBrowser'; import hyphenateStyleName from '../shared/hyphenateStyleName'; @@ -101,6 +103,12 @@ export function prepareHostDispatcher() { ReactDOMCurrentDispatcher.current = ReactDOMServerDispatcher; } +// We make every property of the descriptor optional because it is not a contract that +// the headers provided by onHeaders has any particular header types. +export type HeadersDescriptor = { + Link?: string, +}; + // Used to distinguish these contexts from ones used in other renderers. // E.g. this can be used to distinguish legacy renderers from this modern one. export const isPrimaryRenderer = true; @@ -147,6 +155,34 @@ export type RenderState = { preloadChunks: Array, hoistableChunks: Array, + // Headers queues for Resources that can flush early + onHeaders: void | ((headers: HeadersDescriptor) => void), + headers: null | { + preconnects: string, + fontPreloads: string, + highImagePreloads: string, + remainingCapacity: number, + }, + resets: { + // corresponds to ResumableState.unknownResources["font"] + font: { + [href: string]: Preloaded, + }, + // the rest correspond to ResumableState[<...>Resources] + dns: {[key: string]: Exists}, + connect: { + default: {[key: string]: Exists}, + anonymous: {[key: string]: Exists}, + credentials: {[key: string]: Exists}, + }, + image: { + [key: string]: Preloaded, + }, + style: { + [key: string]: Exists | Preloaded | PreloadedWithCredentials, + }, + }, + // Flushing queues for Resource dependencies preconnects: Set, fontPreloads: Set, @@ -188,7 +224,7 @@ type Preloaded = []; // it seems that browsers do not treat this as part of the http cache key and does not affect // which connection is used. type PreloadedWithCredentials = [ - /* crossOrigin */ ?string, + /* crossOrigin */ ?CrossOriginEnum, /* integrity */ ?string, ]; @@ -298,6 +334,15 @@ const importMapScriptStart = stringToPrecomputedChunk( ); const importMapScriptEnd = stringToPrecomputedChunk(''); +// Since we store headers as strings we deal with their length in utf16 code units +// rather than visual characters or the utf8 encoding that is used for most binary +// serialization. Some common HTTP servers only allow for headers to be 4kB in length. +// We choose a default length that is likely to be well under this already limited length however +// pathological cases may still cause the utf-8 encoding of the headers to approach this limit. +// It should also be noted that this maximum is a soft maximum. we have not reached the limit we will +// allow one more header to be captured which means in practice if the limit is approached it will be exceeded +const DEFAULT_HEADERS_CAPACITY_IN_UTF16_CODE_UNITS = 2000; + // Allows us to keep track of what we've already written so we can refer back to it. // if passed externalRuntimeConfig and the enableFizzExternalRuntime feature flag // is set, the server will send instructions via data attributes (instead of inline scripts) @@ -309,6 +354,8 @@ export function createRenderState( bootstrapModules: $ReadOnlyArray | void, externalRuntimeConfig: string | BootstrapScriptDescriptor | void, importMap: ImportMap | void, + onHeaders: void | ((headers: HeadersDescriptor) => void), + maxHeadersLength: void | number, ): RenderState { const inlineScriptWithNonce = nonce === undefined @@ -373,6 +420,27 @@ export function createRenderState( ); importMapChunks.push(importMapScriptEnd); } + if (__DEV__) { + if (onHeaders && typeof maxHeadersLength === 'number') { + if (maxHeadersLength <= 0) { + console.error( + 'React expected a positive non-zero `maxHeadersLength` option but found %s instead. When using the `onHeaders` option you may supply an optional `maxHeadersLength` option as well however, when setting this value to zero or less no headers will be captured.', + maxHeadersLength === 0 ? 'zero' : maxHeadersLength, + ); + } + } + } + const headers = onHeaders + ? { + preconnects: '', + fontPreloads: '', + highImagePreloads: '', + remainingCapacity: + typeof maxHeadersLength === 'number' + ? maxHeadersLength + : DEFAULT_HEADERS_CAPACITY_IN_UTF16_CODE_UNITS, + } + : null; const renderState: RenderState = { placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'), segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), @@ -384,11 +452,26 @@ export function createRenderState( externalRuntimeScript: externalRuntimeScript, bootstrapChunks: bootstrapChunks, + onHeaders, + headers, + resets: { + font: {}, + dns: {}, + connect: { + default: {}, + anonymous: {}, + credentials: {}, + }, + image: {}, + style: {}, + }, + charsetChunks: [], preconnectChunks: [], importMapChunks, preloadChunks: [], hoistableChunks: [], + // cleared on flush preconnects: new Set(), fontPreloads: new Set(), @@ -535,6 +618,7 @@ export function resumeRenderState( undefined, undefined, undefined, + undefined, ); } @@ -585,15 +669,13 @@ export function resetResumableState( resumableState.nextFormID = 0; resumableState.hasBody = false; resumableState.hasHtml = false; - resumableState.unknownResources = {}; - resumableState.dnsResources = {}; - resumableState.connectResources = { - default: {}, - anonymous: {}, - credentials: {}, + resumableState.unknownResources = { + font: renderState.resets.font, }; - resumableState.imageResources = {}; - resumableState.styleResources = {}; + resumableState.dnsResources = renderState.resets.dns; + resumableState.connectResources = renderState.resets.connect; + resumableState.imageResources = renderState.resets.image; + resumableState.styleResources = renderState.resets.style; resumableState.scriptResources = {}; resumableState.moduleUnknownResources = {}; resumableState.moduleScriptResources = {}; @@ -2636,36 +2718,85 @@ function pushImg( } else if (!resumableState.imageResources.hasOwnProperty(key)) { // We must construct a new preload resource resumableState.imageResources[key] = PRELOAD_NO_CREDS; - resource = []; - pushLinkImpl( - resource, - ({ - rel: 'preload', - as: 'image', - // There is a bug in Safari where imageSrcSet is not respected on preload links - // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image. - // This harms older browers that do not support imageSrcSet by making their preloads not work - // but this population is shrinking fast and is already small so we accept this tradeoff. - href: srcSet ? undefined : src, - imageSrcSet: srcSet, - imageSizes: sizes, - crossOrigin: props.crossOrigin, + const crossOrigin = getCrossOriginString(props.crossOrigin); + + const headers = renderState.headers; + let header; + if ( + headers && + headers.remainingCapacity > 0 && + // this is a hueristic similar to capping element preloads to 10 unless explicitly + // fetchPriority="high". We use length here which means it will fit fewer images when + // the urls are long and more when short. arguably byte size is a better hueristic because + // it directly translates to how much we send down before content is actually seen. + // We could unify the counts and also make it so the total is tracked regardless of + // flushing output but since the headers are likely to be go earlier than content + // they don't really conflict so for now I've kept them separate + (props.fetchPriority === 'high' || + headers.highImagePreloads.length < 500) && + // We manually construct the options for the preload only from strings. We don't want to pollute + // the params list with arbitrary props and if we copied everything over as it we might get + // coercion errors. We have checks for this in Dev but it seems safer to just only accept values + // that are strings + ((header = getPreloadAsHeader(src, 'image', { + imageSrcSet: props.srcSet, + imageSizes: props.sizes, + crossOrigin, integrity: props.integrity, + nonce: props.nonce, type: props.type, fetchPriority: props.fetchPriority, - referrerPolicy: props.referrerPolicy, - }: PreloadProps), - ); - if ( - props.fetchPriority === 'high' || - renderState.highImagePreloads.size < 10 + referrerPolicy: props.refererPolicy, + })), + // We always consume the header length since once we find one header that doesn't fit + // we assume all the rest won't as well. This is to avoid getting into a situation + // where we have a very small remaining capacity but no headers will ever fit and we end + // up constantly trying to see if the next resource might make it. In the future we can + // make this behavior different between render and prerender since in the latter case + // we are less sensitive to the current requests runtime per and more sensitive to maximizing + // headers. + (headers.remainingCapacity -= header.length) >= 2) ) { - renderState.highImagePreloads.add(resource); + // If we postpone in the shell we will still emit this preload so we track + // it to make sure we don't reset it. + renderState.resets.image[key] = PRELOAD_NO_CREDS; + if (headers.highImagePreloads) { + headers.highImagePreloads += ', '; + } + // $FlowFixMe[unsafe-addition]: we assign header during the if condition + headers.highImagePreloads += header; } else { - renderState.bulkPreloads.add(resource); - // We can bump the priority up if the same img is rendered later - // with fetchPriority="high" - promotablePreloads.set(key, resource); + resource = []; + pushLinkImpl( + resource, + ({ + rel: 'preload', + as: 'image', + // There is a bug in Safari where imageSrcSet is not respected on preload links + // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image. + // This harms older browers that do not support imageSrcSet by making their preloads not work + // but this population is shrinking fast and is already small so we accept this tradeoff. + href: srcSet ? undefined : src, + imageSrcSet: srcSet, + imageSizes: sizes, + crossOrigin: crossOrigin, + integrity: props.integrity, + type: props.type, + fetchPriority: props.fetchPriority, + referrerPolicy: props.referrerPolicy, + }: PreloadProps), + ); + if ( + props.fetchPriority === 'high' || + renderState.highImagePreloads.size < 10 + ) { + renderState.highImagePreloads.add(resource); + } else { + renderState.bulkPreloads.add(resource); + // We can bump the priority up if the same img is rendered later + // with fetchPriority="high" + promotablePreloads.set(key, resource); + } } } } @@ -5036,12 +5167,14 @@ type PreloadProps = PreloadAsProps | PreloadModuleProps; type ScriptProps = { async: true, src: string, + crossOrigin?: ?CrossOriginEnum, [string]: mixed, }; type ModuleScriptProps = { async: true, src: string, type: 'module', + crossOrigin?: ?CrossOriginEnum, [string]: mixed, }; @@ -5051,6 +5184,13 @@ type StylesheetProps = { rel: 'stylesheet', href: string, 'data-precedence': string, + crossOrigin?: ?CrossOriginEnum, + integrity?: ?string, + nonce?: ?string, + type?: ?string, + fetchPriority?: ?string, + referrerPolicy?: ?string, + media?: ?string, [string]: mixed, }; type StylesheetResource = { @@ -5118,10 +5258,37 @@ function prefetchDNS(href: string) { if (typeof href === 'string' && href) { const key = getResourceKey(href); if (!resumableState.dnsResources.hasOwnProperty(key)) { - const resource: Resource = []; resumableState.dnsResources[key] = EXISTS; - pushLinkImpl(resource, ({href, rel: 'dns-prefetch'}: PreconnectProps)); - renderState.preconnects.add(resource); + + const headers = renderState.headers; + let header; + if ( + headers && + headers.remainingCapacity > 0 && + // Compute the header since we might be able to fit it in the max length + ((header = getPrefetchDNSAsHeader(href)), + // We always consume the header length since once we find one header that doesn't fit + // we assume all the rest won't as well. This is to avoid getting into a situation + // where we have a very small remaining capacity but no headers will ever fit and we end + // up constantly trying to see if the next resource might make it. In the future we can + // make this behavior different between render and prerender since in the latter case + // we are less sensitive to the current requests runtime per and more sensitive to maximizing + // headers. + (headers.remainingCapacity -= header.length) >= 2) + ) { + // Store this as resettable in case we are prerendering and postpone in the Shell + renderState.resets.dns[key] = EXISTS; + if (headers.preconnects) { + headers.preconnects += ', '; + } + // $FlowFixMe[unsafe-addition]: we assign header during the if condition + headers.preconnects += header; + } else { + // Encode as element + const resource: Resource = []; + pushLinkImpl(resource, ({href, rel: 'dns-prefetch'}: PreconnectProps)); + renderState.preconnects.add(resource); + } } flushResources(request); } @@ -5144,21 +5311,47 @@ function preconnect(href: string, crossOrigin: ?CrossOriginEnum) { const renderState = getRenderState(request); if (typeof href === 'string' && href) { - const resources = + const bucket = crossOrigin === 'use-credentials' - ? resumableState.connectResources.credentials + ? 'credentials' : typeof crossOrigin === 'string' - ? resumableState.connectResources.anonymous - : resumableState.connectResources.default; + ? 'anonymous' + : 'default'; const key = getResourceKey(href); - if (!resources.hasOwnProperty(key)) { - const resource: Resource = []; - resources[key] = EXISTS; - pushLinkImpl( - resource, - ({rel: 'preconnect', href, crossOrigin}: PreconnectProps), - ); - renderState.preconnects.add(resource); + if (!resumableState.connectResources[bucket].hasOwnProperty(key)) { + resumableState.connectResources[bucket][key] = EXISTS; + + const headers = renderState.headers; + let header; + if ( + headers && + headers.remainingCapacity > 0 && + // Compute the header since we might be able to fit it in the max length + ((header = getPreconnectAsHeader(href, crossOrigin)), + // We always consume the header length since once we find one header that doesn't fit + // we assume all the rest won't as well. This is to avoid getting into a situation + // where we have a very small remaining capacity but no headers will ever fit and we end + // up constantly trying to see if the next resource might make it. In the future we can + // make this behavior different between render and prerender since in the latter case + // we are less sensitive to the current requests runtime per and more sensitive to maximizing + // headers. + (headers.remainingCapacity -= header.length) >= 2) + ) { + // Store this in resettableState in case we are prerending and postpone in the Shell + renderState.resets.connect[bucket][key] = EXISTS; + if (headers.preconnects) { + headers.preconnects += ', '; + } + // $FlowFixMe[unsafe-addition]: we assign header during the if condition + headers.preconnects += header; + } else { + const resource: Resource = []; + pushLinkImpl( + resource, + ({rel: 'preconnect', href, crossOrigin}: PreconnectProps), + ); + renderState.preconnects.add(resource); + } } flushResources(request); } @@ -5194,29 +5387,61 @@ function preload(href: string, as: string, options?: ?PreloadImplOptions) { return; } resumableState.imageResources[key] = PRELOAD_NO_CREDS; - const resource = ([]: Resource); - pushLinkImpl( - resource, - Object.assign( - ({ - rel: 'preload', - // There is a bug in Safari where imageSrcSet is not respected on preload links - // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image. - // This harms older browers that do not support imageSrcSet by making their preloads not work - // but this population is shrinking fast and is already small so we accept this tradeoff. - href: imageSrcSet ? undefined : href, - as, - }: PreloadAsProps), - options, - ), - ); - if (fetchPriority === 'high') { - renderState.highImagePreloads.add(resource); + + const headers = renderState.headers; + let header: string; + if ( + headers && + headers.remainingCapacity > 0 && + fetchPriority === 'high' && + // Compute the header since we might be able to fit it in the max length + ((header = getPreloadAsHeader(href, as, options)), + // We always consume the header length since once we find one header that doesn't fit + // we assume all the rest won't as well. This is to avoid getting into a situation + // where we have a very small remaining capacity but no headers will ever fit and we end + // up constantly trying to see if the next resource might make it. In the future we can + // make this behavior different between render and prerender since in the latter case + // we are less sensitive to the current requests runtime per and more sensitive to maximizing + // headers. + (headers.remainingCapacity -= header.length) >= 2) + ) { + // If we postpone in the shell we will still emit a preload as a header so we + // track this to make sure we don't reset it. + renderState.resets.image[key] = PRELOAD_NO_CREDS; + if (headers.highImagePreloads) { + headers.highImagePreloads += ', '; + } + // $FlowFixMe[unsafe-addition]: we assign header during the if condition + headers.highImagePreloads += header; } else { - renderState.bulkPreloads.add(resource); - // Stash the resource in case we need to promote it to higher priority - // when an img tag is rendered - renderState.preloads.images.set(key, resource); + // If we don't have headers to write to we have to encode as elements to flush in the head + // When we have imageSrcSet the browser probably cannot load the right version from headers + // (this should be verified by testing). For now we assume these need to go in the head + // as elements even if headers are available. + const resource = ([]: Resource); + pushLinkImpl( + resource, + Object.assign( + ({ + rel: 'preload', + // There is a bug in Safari where imageSrcSet is not respected on preload links + // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image. + // This harms older browers that do not support imageSrcSet by making their preloads not work + // but this population is shrinking fast and is already small so we accept this tradeoff. + href: imageSrcSet ? undefined : href, + as, + }: PreloadAsProps), + options, + ), + ); + if (fetchPriority === 'high') { + renderState.highImagePreloads.add(resource); + } else { + renderState.bulkPreloads.add(resource); + // Stash the resource in case we need to promote it to higher priority + // when an img tag is rendered + renderState.preloads.images.set(key, resource); + } } break; } @@ -5276,25 +5501,55 @@ function preload(href: string, as: string, options?: ?PreloadImplOptions) { resources = ({}: ResumableState['unknownResources']['asType']); resumableState.unknownResources[as] = resources; } - const resource = ([]: Resource); - const props = Object.assign( - ({ - rel: 'preload', - href, - as, - }: PreloadAsProps), - options, - ); - switch (as) { - case 'font': - renderState.fontPreloads.add(resource); - break; - // intentional fall through - default: - renderState.bulkPreloads.add(resource); - } - pushLinkImpl(resource, props); resources[key] = PRELOAD_NO_CREDS; + + const headers = renderState.headers; + let header; + if ( + headers && + headers.remainingCapacity > 0 && + as === 'font' && + // We compute the header here because we might be able to fit it in the max length + ((header = getPreloadAsHeader(href, as, options)), + // We always consume the header length since once we find one header that doesn't fit + // we assume all the rest won't as well. This is to avoid getting into a situation + // where we have a very small remaining capacity but no headers will ever fit and we end + // up constantly trying to see if the next resource might make it. In the future we can + // make this behavior different between render and prerender since in the latter case + // we are less sensitive to the current requests runtime per and more sensitive to maximizing + // headers. + (headers.remainingCapacity -= header.length) >= 2) + ) { + // If we postpone in the shell we will still emit this preload so we + // track it here to prevent it from being reset. + renderState.resets.font[key] = PRELOAD_NO_CREDS; + if (headers.fontPreloads) { + headers.fontPreloads += ', '; + } + // $FlowFixMe[unsafe-addition]: we assign header during the if condition + headers.fontPreloads += header; + } else { + // We either don't have headers or we are preloading something that does + // not warrant elevated priority so we encode as an element. + const resource = ([]: Resource); + const props = Object.assign( + ({ + rel: 'preload', + href, + as, + }: PreloadAsProps), + options, + ); + pushLinkImpl(resource, props); + switch (as) { + case 'font': + renderState.fontPreloads.add(resource); + break; + // intentional fall through + default: + renderState.bulkPreloads.add(resource); + } + } } } // If we got this far we created a new resource @@ -5683,6 +5938,138 @@ function adoptPreloadCredentials( if (target.integrity == null) target.integrity = preloadState[1]; } +function getPrefetchDNSAsHeader(href: string): string { + const escapedHref = escapeHrefForLinkHeaderURLContext(href); + return `<${escapedHref}>; rel=dns-prefetch`; +} + +function getPreconnectAsHeader( + href: string, + crossOrigin?: ?CrossOriginEnum, +): string { + const escapedHref = escapeHrefForLinkHeaderURLContext(href); + let value = `<${escapedHref}>; rel=preconnect`; + if (typeof crossOrigin === 'string') { + const escapedCrossOrigin = escapeStringForLinkHeaderQuotedParamValueContext( + crossOrigin, + 'crossOrigin', + ); + value += `; crossorigin="${escapedCrossOrigin}"`; + } + return value; +} + +function getPreloadAsHeader( + href: string, + as: string, + params: ?PreloadImplOptions, +): string { + const escapedHref = escapeHrefForLinkHeaderURLContext(href); + const escapedAs = escapeStringForLinkHeaderQuotedParamValueContext(as, 'as'); + let value = `<${escapedHref}>; rel=preload; as="${escapedAs}"`; + for (const paramName in params) { + if (hasOwnProperty.call(params, paramName)) { + const paramValue = params[paramName]; + if (typeof paramValue === 'string') { + value += `; ${paramName.toLowerCase()}="${escapeStringForLinkHeaderQuotedParamValueContext( + paramValue, + paramName, + )}"`; + } + } + } + return value; +} + +function getStylesheetPreloadAsHeader(stylesheet: StylesheetResource): string { + const props = stylesheet.props; + const preloadOptions: PreloadImplOptions = { + crossOrigin: props.crossOrigin, + integrity: props.integrity, + nonce: props.nonce, + type: props.type, + fetchPriority: props.fetchPriority, + referrerPolicy: props.referrerPolicy, + media: props.media, + }; + return getPreloadAsHeader(props.href, 'style', preloadOptions); +} + +// This escaping function is only safe to use for href values being written into +// a "Link" header in between `<` and `>` characters. The primary concern with the href is +// to escape the bounding characters as well as new lines. This is unsafe to use in any other +// context +const regexForHrefInLinkHeaderURLContext = /[<>\r\n]/g; +function escapeHrefForLinkHeaderURLContext(hrefInput: string): string { + if (__DEV__) { + checkAttributeStringCoercion(hrefInput, 'href'); + } + const coercedHref = '' + hrefInput; + return coercedHref.replace( + regexForHrefInLinkHeaderURLContext, + escapeHrefForLinkHeaderURLContextReplacer, + ); +} +function escapeHrefForLinkHeaderURLContextReplacer(match: string): string { + switch (match) { + case '<': + return '%3C'; + case '>': + return '%3E'; + case '\n': + return '%0A'; + case '\r': + return '%0D'; + default: { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'escapeLinkHrefForHeaderContextReplacer encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React', + ); + } + } +} + +// This escaping function is only safe to use for quoted param values in an HTTP header. +// It is unsafe to use for any value not inside quote marks in parater value position. +const regexForLinkHeaderQuotedParamValueContext = /["';,\r\n]/g; +function escapeStringForLinkHeaderQuotedParamValueContext( + value: string, + name: string, +): string { + if (__DEV__) { + checkOptionStringCoercion(value, name); + } + const coerced = '' + value; + return coerced.replace( + regexForLinkHeaderQuotedParamValueContext, + escapeStringForLinkHeaderQuotedParamValueContextReplacer, + ); +} +function escapeStringForLinkHeaderQuotedParamValueContextReplacer( + match: string, +): string { + switch (match) { + case '"': + return '%22'; + case "'": + return '%27'; + case ';': + return '%3B'; + case ',': + return '%2C'; + case '\n': + return '%0A'; + case '\r': + return '%0D'; + default: { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'escapeStringForLinkHeaderQuotedParamValueContextReplacer encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React', + ); + } + } +} + function hoistStyleQueueDependency( this: BoundaryResources, styleQueue: StyleQueue, @@ -5711,5 +6098,102 @@ export function hoistResources( } } +// This function is called at various times depending on whether we are rendering +// or prerendering. In this implementation we only actually emit headers once and +// subsequent calls are ignored. We track whether the request has a completed shell +// to determine whether we will follow headers with a flush including stylesheets. +// In the context of prerrender we don't have a completed shell when the request finishes +// with a postpone in the shell. In the context of a render we don't have a completed shell +// if this is called before the shell finishes rendering which usually will happen anytime +// anything suspends in the shell. +export function emitEarlyPreloads( + renderState: RenderState, + resumableState: ResumableState, + shellComplete: boolean, +): void { + const onHeaders = renderState.onHeaders; + if (onHeaders) { + const headers = renderState.headers; + if (headers) { + let linkHeader = headers.preconnects; + if (headers.fontPreloads) { + if (linkHeader) { + linkHeader += ', '; + } + linkHeader += headers.fontPreloads; + } + if (headers.highImagePreloads) { + if (linkHeader) { + linkHeader += ', '; + } + linkHeader += headers.highImagePreloads; + } + + if (!shellComplete) { + // We use raw iterators because we want to be able to halt iteration + // We could refactor renderState to store these dually in arrays to + // make this more efficient at the cost of additional memory and + // write overhead. However this code only runs once per request so + // for now I consider this sufficient. + const queueIter = renderState.styles.values(); + outer: for ( + let queueStep = queueIter.next(); + headers.remainingCapacity > 0 && !queueStep.done; + queueStep = queueIter.next() + ) { + const sheets = queueStep.value.sheets; + const sheetIter = sheets.values(); + for ( + let sheetStep = sheetIter.next(); + headers.remainingCapacity > 0 && !sheetStep.done; + sheetStep = sheetIter.next() + ) { + const sheet = sheetStep.value; + const props = sheet.props; + const key = getResourceKey(props.href); + + const header = getStylesheetPreloadAsHeader(sheet); + // We mutate the capacity b/c we don't want to keep checking if later headers will fit. + // This means that a particularly long header might close out the header queue where later + // headers could still fit. We could in the future alter the behavior here based on prerender vs render + // since during prerender we aren't as concerned with pure runtime performance. + if ((headers.remainingCapacity -= header.length) >= 2) { + renderState.resets.style[key] = PRELOAD_NO_CREDS; + if (linkHeader) { + linkHeader += ', '; + } + linkHeader += header; + + // We already track that the resource exists in resumableState however + // if the resumableState resets because we postponed in the shell + // which is what is happening in this branch if we are prerendering + // then we will end up resetting the resumableState. When it resets we + // want to record the fact that this stylesheet was already preloaded + renderState.resets.style[key] = + typeof props.crossOrigin === 'string' || + typeof props.integrity === 'string' + ? [props.crossOrigin, props.integrity] + : PRELOAD_NO_CREDS; + } else { + break outer; + } + } + } + } + if (linkHeader) { + onHeaders({ + Link: linkHeader, + }); + } else { + // We still call this with no headers because a user may be using it as a signal that + // it React will not provide any headers + onHeaders({}); + } + renderState.headers = null; + return; + } + } +} + export type TransitionStatus = FormStatus; export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 58ece3d8c5..72b8b1e07a 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -8,10 +8,12 @@ */ import type { + RenderState as BaseRenderState, ResumableState, BoundaryResources, StyleQueue, Resource, + HeadersDescriptor, } from './ReactFizzConfigDOM'; import { @@ -46,6 +48,14 @@ export type RenderState = { headChunks: null | Array, externalRuntimeScript: null | any, bootstrapChunks: Array, + onHeaders: void | ((headers: HeadersDescriptor) => void), + headers: null | { + preconnects: string, + fontPreloads: string, + highImagePreloads: string, + remainingCapacity: number, + }, + resets: BaseRenderState['resets'], charsetChunks: Array, preconnectChunks: Array, importMapChunks: Array, @@ -83,6 +93,7 @@ export function createRenderState( undefined, undefined, undefined, + undefined, ); return { // Keep this in sync with ReactFizzConfigDOM @@ -94,6 +105,9 @@ export function createRenderState( headChunks: renderState.headChunks, externalRuntimeScript: renderState.externalRuntimeScript, bootstrapChunks: renderState.bootstrapChunks, + onHeaders: renderState.onHeaders, + headers: renderState.headers, + resets: renderState.resets, charsetChunks: renderState.charsetChunks, preconnectChunks: renderState.preconnectChunks, importMapChunks: renderState.importMapChunks, @@ -159,6 +173,7 @@ export { setCurrentlyRenderingBoundaryResourcesTarget, prepareHostDispatcher, resetResumableState, + emitEarlyPreloads, } from './ReactFizzConfigDOM'; import escapeTextForBrowser from './escapeTextForBrowser'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 2caf486625..6ed04b64a5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -3690,6 +3690,157 @@ describe('ReactDOMFizzServer', () => { ); }); + it('provides headers after initial work if onHeaders option used', async () => { + let headers = null; + function onHeaders(x) { + headers = x; + } + + function Preloads() { + ReactDOM.preload('font2', {as: 'font'}); + ReactDOM.preload('imagepre2', {as: 'image', fetchPriority: 'high'}); + ReactDOM.preconnect('pre2', {crossOrigin: 'use-credentials'}); + ReactDOM.prefetchDNS('dns2'); + } + + function Blocked() { + readText('blocked'); + return ( + <> + + + + ); + } + + function App() { + ReactDOM.preload('font', {as: 'font'}); + ReactDOM.preload('imagepre', {as: 'image', fetchPriority: 'high'}); + ReactDOM.preconnect('pre', {crossOrigin: 'use-credentials'}); + ReactDOM.prefetchDNS('dns'); + return ( + + + + + + + ); + } + + await act(() => { + renderToPipeableStream(, {onHeaders}); + }); + + expect(headers).toEqual({ + Link: ` +
; rel=preconnect; crossorigin="use-credentials",
+ ; rel=dns-prefetch,
+ ; rel=preload; as="font"; crossorigin="",
+ ; rel=preload; as="image"; fetchpriority="high",
+ ; rel=preload; as="image"
+`
+        .replaceAll('\n', '')
+        .trim(),
+    });
+  });
+
+  it('encodes img srcset and sizes into preload header params', async () => {
+    let headers = null;
+    function onHeaders(x) {
+      headers = x;
+    }
+
+    function App() {
+      ReactDOM.preload('presrc', {
+        as: 'image',
+        fetchPriority: 'high',
+        imageSrcSet: 'presrcset',
+        imageSizes: 'presizes',
+      });
+      return (
+        
+          
+            
+          
+        
+      );
+    }
+
+    await act(() => {
+      renderToPipeableStream(, {onHeaders});
+    });
+
+    expect(headers).toEqual({
+      Link: `
+; rel=preload; as="image"; fetchpriority="high"; imagesrcset="presrcset"; imagesizes="presizes",
+ ; rel=preload; as="image"; imagesrcset="srcset"; imagesizes="sizes"
+`
+        .replaceAll('\n', '')
+        .trim(),
+    });
+  });
+
+  it('emits nothing for headers if you pipe before work begins', async () => {
+    let headers = null;
+    function onHeaders(x) {
+      headers = x;
+    }
+
+    function App() {
+      ReactDOM.preload('presrc', {
+        as: 'image',
+        fetchPriority: 'high',
+        imageSrcSet: 'presrcset',
+        imageSizes: 'presizes',
+      });
+      return (
+        
+          
+            
+          
+        
+      );
+    }
+
+    await act(() => {
+      renderToPipeableStream(, {onHeaders}).pipe(writable);
+    });
+
+    expect(headers).toEqual({});
+  });
+
+  it('stops accumulating new headers once the maxHeadersLength limit is satisifed', async () => {
+    let headers = null;
+    function onHeaders(x) {
+      headers = x;
+    }
+
+    function App() {
+      ReactDOM.preconnect('foo');
+      ReactDOM.preconnect('bar');
+      ReactDOM.preconnect('baz');
+      return (
+        
+          hello
+        
+      );
+    }
+
+    await act(() => {
+      renderToPipeableStream(, {onHeaders, maxHeadersLength: 44});
+    });
+
+    expect(headers).toEqual({
+      Link: `
+; rel=preconnect,
+ ; rel=preconnect
+`
+        .replaceAll('\n', '')
+        .trim(),
+    });
+  });
+
   describe('error escaping', () => {
     it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
       window.__outlet = {};
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
index f71d18e3c9..49afa26f65 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
@@ -13,6 +13,7 @@
 let JSDOM;
 let Stream;
 let React;
+let ReactDOM;
 let ReactDOMClient;
 let ReactDOMFizzStatic;
 let Suspense;
@@ -29,6 +30,7 @@ describe('ReactDOMFizzStatic', () => {
     jest.resetModules();
     JSDOM = require('jsdom').JSDOM;
     React = require('react');
+    ReactDOM = require('react-dom');
     ReactDOMClient = require('react-dom/client');
     if (__EXPERIMENTAL__) {
       ReactDOMFizzStatic = require('react-dom/static');
@@ -262,4 +264,75 @@ describe('ReactDOMFizzStatic', () => {
       'hello world',
     ]);
   });
+
+  // @gate experimental
+  it('supports onHeaders', async () => {
+    let headers;
+    function onHeaders(x) {
+      headers = x;
+    }
+
+    function App() {
+      ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
+      ReactDOM.preload('font', {as: 'font'});
+      return (
+        
+          hello
+        
+      );
+    }
+
+    const result = await ReactDOMFizzStatic.prerenderToNodeStream(, {
+      onHeaders,
+    });
+    expect(headers).toEqual({
+      Link: `
+; rel=preload; as="font"; crossorigin="",
+ ; rel=preload; as="image"; fetchpriority="high"
+`
+        .replaceAll('\n', '')
+        .trim(),
+    });
+
+    await act(async () => {
+      result.prelude.pipe(writable);
+    });
+    expect(getVisibleChildren(container)).toEqual('hello');
+  });
+
+  // @gate experimental && enablePostpone
+  it('includes stylesheet preloads in onHeaders when postponing in the Shell', async () => {
+    let headers;
+    function onHeaders(x) {
+      headers = x;
+    }
+
+    function App() {
+      ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
+      ReactDOM.preinit('style', {as: 'style'});
+      React.unstable_postpone();
+      return (
+        
+          hello
+        
+      );
+    }
+
+    const result = await ReactDOMFizzStatic.prerenderToNodeStream(, {
+      onHeaders,
+    });
+    expect(headers).toEqual({
+      Link: `
+; rel=preload; as="image"; fetchpriority="high",
+