mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[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
This commit is contained in:
+573
-89
@@ -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<Chunk | PrecomputedChunk>,
|
||||
hoistableChunks: Array<Chunk | PrecomputedChunk>,
|
||||
|
||||
// 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<Resource>,
|
||||
fontPreloads: Set<Resource>,
|
||||
@@ -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('</script>');
|
||||
|
||||
// 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<string | BootstrapScriptDescriptor> | 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;
|
||||
|
||||
@@ -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<Chunk | PrecomputedChunk>,
|
||||
externalRuntimeScript: null | any,
|
||||
bootstrapChunks: Array<Chunk | PrecomputedChunk>,
|
||||
onHeaders: void | ((headers: HeadersDescriptor) => void),
|
||||
headers: null | {
|
||||
preconnects: string,
|
||||
fontPreloads: string,
|
||||
highImagePreloads: string,
|
||||
remainingCapacity: number,
|
||||
},
|
||||
resets: BaseRenderState['resets'],
|
||||
charsetChunks: Array<Chunk | PrecomputedChunk>,
|
||||
preconnectChunks: Array<Chunk | PrecomputedChunk>,
|
||||
importMapChunks: Array<Chunk | PrecomputedChunk>,
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Preloads />
|
||||
<img src="image2" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
ReactDOM.preload('font', {as: 'font'});
|
||||
ReactDOM.preload('imagepre', {as: 'image', fetchPriority: 'high'});
|
||||
ReactDOM.preconnect('pre', {crossOrigin: 'use-credentials'});
|
||||
ReactDOM.prefetchDNS('dns');
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<img src="image" />
|
||||
<Blocked />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
renderToPipeableStream(<App />, {onHeaders});
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
Link: `
|
||||
<pre>; rel=preconnect; crossorigin="use-credentials",
|
||||
<dns>; rel=dns-prefetch,
|
||||
<font>; rel=preload; as="font"; crossorigin="",
|
||||
<imagepre>; rel=preload; as="image"; fetchpriority="high",
|
||||
<image>; 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 (
|
||||
<html>
|
||||
<body>
|
||||
<img src="src" srcSet="srcset" sizes="sizes" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
renderToPipeableStream(<App />, {onHeaders});
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
Link: `
|
||||
<presrc>; rel=preload; as="image"; fetchpriority="high"; imagesrcset="presrcset"; imagesizes="presizes",
|
||||
<src>; 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 (
|
||||
<html>
|
||||
<body>
|
||||
<img src="src" srcSet="srcset" sizes="sizes" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
renderToPipeableStream(<App />, {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 (
|
||||
<html>
|
||||
<body>hello</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
renderToPipeableStream(<App />, {onHeaders, maxHeadersLength: 44});
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
Link: `
|
||||
<foo>; rel=preconnect,
|
||||
<bar>; 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 = {};
|
||||
|
||||
@@ -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 (
|
||||
<html>
|
||||
<body>hello</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
|
||||
onHeaders,
|
||||
});
|
||||
expect(headers).toEqual({
|
||||
Link: `
|
||||
<font>; rel=preload; as="font"; crossorigin="",
|
||||
<image>; 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 (
|
||||
<html>
|
||||
<body>hello</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
|
||||
onHeaders,
|
||||
});
|
||||
expect(headers).toEqual({
|
||||
Link: `
|
||||
<image>; rel=preload; as="image"; fetchpriority="high",
|
||||
<style>; rel=preload; as="style"
|
||||
`
|
||||
.replaceAll('\n', '')
|
||||
.trim(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.prelude.pipe(writable);
|
||||
});
|
||||
expect(getVisibleChildren(container)).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
global.ReadableStream =
|
||||
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
|
||||
global.TextEncoder = require('util').TextEncoder;
|
||||
global.TextDecoder = require('util').TextDecoder;
|
||||
|
||||
let React;
|
||||
let ReactDOM;
|
||||
@@ -1316,4 +1317,106 @@ describe('ReactDOMFizzStaticBrowser', () => {
|
||||
'</head><body><div>Hello</div></body></html>',
|
||||
);
|
||||
});
|
||||
|
||||
// @gate enablePostpone
|
||||
it('does not emit preloads during resume for Resources preloaded through onHeaders', async () => {
|
||||
let prerendering = true;
|
||||
|
||||
let hasLoaded = false;
|
||||
let resolve;
|
||||
const promise = new Promise(r => (resolve = r));
|
||||
function WaitIfResuming({children}) {
|
||||
if (!prerendering && !hasLoaded) {
|
||||
throw promise;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function Postpone() {
|
||||
if (prerendering) {
|
||||
React.unstable_postpone();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let headers;
|
||||
function onHeaders(x) {
|
||||
headers = x;
|
||||
}
|
||||
|
||||
function App() {
|
||||
ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
hello
|
||||
<Suspense fallback={null}>
|
||||
<WaitIfResuming>
|
||||
world
|
||||
<link rel="stylesheet" href="style" precedence="default" />
|
||||
</WaitIfResuming>
|
||||
</Suspense>
|
||||
<Postpone />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const prerendered = await ReactDOMFizzStatic.prerender(<App />, {
|
||||
onHeaders,
|
||||
});
|
||||
expect(prerendered.postponed).not.toBe(null);
|
||||
|
||||
prerendering = false;
|
||||
|
||||
expect(await readContent(prerendered.prelude)).toBe('');
|
||||
expect(headers).toEqual(
|
||||
new Headers({
|
||||
Link: `
|
||||
<image>; rel=preload; as="image"; fetchpriority="high",
|
||||
<style>; rel=preload; as="style"
|
||||
`
|
||||
.replaceAll('\n', '')
|
||||
.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
const content = await ReactDOMFizzServer.resume(
|
||||
<App />,
|
||||
JSON.parse(JSON.stringify(prerendered.postponed)),
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = content.getReader();
|
||||
let {value, done} = await reader.read();
|
||||
let result = decoder.decode(value, {stream: true});
|
||||
|
||||
expect(result).toBe(
|
||||
'<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$-->',
|
||||
);
|
||||
|
||||
await 1;
|
||||
hasLoaded = true;
|
||||
resolve();
|
||||
|
||||
while (true) {
|
||||
({value, done} = await reader.read());
|
||||
if (done) {
|
||||
result += decoder.decode(value);
|
||||
break;
|
||||
}
|
||||
result += decoder.decode(value, {stream: true});
|
||||
}
|
||||
|
||||
// We are mostly just trying to assert that no preload for our stylesheet was emitted
|
||||
// prior to sending the segment the stylesheet was for. This test is asserting this
|
||||
// because the boundary complete instruction is sent when we are writing the
|
||||
const instructionIndex = result.indexOf('$RC');
|
||||
expect(instructionIndex > -1).toBe(true);
|
||||
const slice = result.slice(0, instructionIndex + '$RC'.length);
|
||||
|
||||
expect(slice).toBe(
|
||||
'<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$--><div hidden id="S:1">world<!-- --></div><script>$RC',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-1
@@ -9,7 +9,10 @@
|
||||
|
||||
import type {PostponedState} from 'react-server/src/ReactFizzServer';
|
||||
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
|
||||
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {
|
||||
BootstrapScriptDescriptor,
|
||||
HeadersDescriptor,
|
||||
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {ImportMap} from '../shared/ReactDOMTypes';
|
||||
|
||||
import ReactVersion from 'shared/ReactVersion';
|
||||
@@ -44,6 +47,8 @@ type Options = {
|
||||
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
|
||||
importMap?: ImportMap,
|
||||
formState?: ReactFormState<any, any> | null,
|
||||
onHeaders?: (headers: Headers) => void,
|
||||
maxHeadersLength?: number,
|
||||
};
|
||||
|
||||
type ResumeOptions = {
|
||||
@@ -97,6 +102,15 @@ function renderToReadableStream(
|
||||
allReady.catch(() => {});
|
||||
reject(error);
|
||||
}
|
||||
|
||||
const onHeaders = options ? options.onHeaders : undefined;
|
||||
let onHeadersImpl;
|
||||
if (onHeaders) {
|
||||
onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
|
||||
onHeaders(new Headers(headersDescriptor));
|
||||
};
|
||||
}
|
||||
|
||||
const resumableState = createResumableState(
|
||||
options ? options.identifierPrefix : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
@@ -112,6 +126,8 @@ function renderToReadableStream(
|
||||
options ? options.bootstrapModules : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
options ? options.importMap : undefined,
|
||||
onHeadersImpl,
|
||||
options ? options.maxHeadersLength : undefined,
|
||||
),
|
||||
createRootFormatContext(options ? options.namespaceURI : undefined),
|
||||
options ? options.progressiveChunkSize : undefined,
|
||||
|
||||
+17
-1
@@ -8,7 +8,10 @@
|
||||
*/
|
||||
|
||||
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
|
||||
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {
|
||||
BootstrapScriptDescriptor,
|
||||
HeadersDescriptor,
|
||||
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {ImportMap} from '../shared/ReactDOMTypes';
|
||||
|
||||
import ReactVersion from 'shared/ReactVersion';
|
||||
@@ -41,6 +44,8 @@ type Options = {
|
||||
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
|
||||
importMap?: ImportMap,
|
||||
formState?: ReactFormState<any, any> | null,
|
||||
onHeaders?: (headers: Headers) => void,
|
||||
maxHeadersLength?: number,
|
||||
};
|
||||
|
||||
// TODO: Move to sub-classing ReadableStream.
|
||||
@@ -87,6 +92,15 @@ function renderToReadableStream(
|
||||
allReady.catch(() => {});
|
||||
reject(error);
|
||||
}
|
||||
|
||||
const onHeaders = options ? options.onHeaders : undefined;
|
||||
let onHeadersImpl;
|
||||
if (onHeaders) {
|
||||
onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
|
||||
onHeaders(new Headers(headersDescriptor));
|
||||
};
|
||||
}
|
||||
|
||||
const resumableState = createResumableState(
|
||||
options ? options.identifierPrefix : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
@@ -102,6 +116,8 @@ function renderToReadableStream(
|
||||
options ? options.bootstrapModules : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
options ? options.importMap : undefined,
|
||||
onHeadersImpl,
|
||||
options ? options.maxHeadersLength : undefined,
|
||||
),
|
||||
createRootFormatContext(options ? options.namespaceURI : undefined),
|
||||
options ? options.progressiveChunkSize : undefined,
|
||||
|
||||
+17
-1
@@ -9,7 +9,10 @@
|
||||
|
||||
import type {PostponedState} from 'react-server/src/ReactFizzServer';
|
||||
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
|
||||
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {
|
||||
BootstrapScriptDescriptor,
|
||||
HeadersDescriptor,
|
||||
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {ImportMap} from '../shared/ReactDOMTypes';
|
||||
|
||||
import ReactVersion from 'shared/ReactVersion';
|
||||
@@ -44,6 +47,8 @@ type Options = {
|
||||
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
|
||||
importMap?: ImportMap,
|
||||
formState?: ReactFormState<any, any> | null,
|
||||
onHeaders?: (headers: Headers) => void,
|
||||
maxHeadersLength?: number,
|
||||
};
|
||||
|
||||
type ResumeOptions = {
|
||||
@@ -97,6 +102,15 @@ function renderToReadableStream(
|
||||
allReady.catch(() => {});
|
||||
reject(error);
|
||||
}
|
||||
|
||||
const onHeaders = options ? options.onHeaders : undefined;
|
||||
let onHeadersImpl;
|
||||
if (onHeaders) {
|
||||
onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
|
||||
onHeaders(new Headers(headersDescriptor));
|
||||
};
|
||||
}
|
||||
|
||||
const resumableState = createResumableState(
|
||||
options ? options.identifierPrefix : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
@@ -112,6 +126,8 @@ function renderToReadableStream(
|
||||
options ? options.bootstrapModules : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
options ? options.importMap : undefined,
|
||||
onHeadersImpl,
|
||||
options ? options.maxHeadersLength : undefined,
|
||||
),
|
||||
createRootFormatContext(options ? options.namespaceURI : undefined),
|
||||
options ? options.progressiveChunkSize : undefined,
|
||||
|
||||
+10
-1
@@ -10,7 +10,10 @@
|
||||
import type {Request, PostponedState} from 'react-server/src/ReactFizzServer';
|
||||
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
|
||||
import type {Writable} from 'stream';
|
||||
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {
|
||||
BootstrapScriptDescriptor,
|
||||
HeadersDescriptor,
|
||||
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
|
||||
import type {ImportMap} from '../shared/ReactDOMTypes';
|
||||
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
startFlowing,
|
||||
stopFlowing,
|
||||
abort,
|
||||
prepareForStartFlowingIfBeforeAllReady,
|
||||
} from 'react-server/src/ReactFizzServer';
|
||||
|
||||
import {
|
||||
@@ -60,6 +64,8 @@ type Options = {
|
||||
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
|
||||
importMap?: ImportMap,
|
||||
formState?: ReactFormState<any, any> | null,
|
||||
onHeaders?: (headers: HeadersDescriptor) => void,
|
||||
maxHeadersLength?: number,
|
||||
};
|
||||
|
||||
type ResumeOptions = {
|
||||
@@ -94,6 +100,8 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
|
||||
options ? options.bootstrapModules : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
options ? options.importMap : undefined,
|
||||
options ? options.onHeaders : undefined,
|
||||
options ? options.maxHeadersLength : undefined,
|
||||
),
|
||||
createRootFormatContext(options ? options.namespaceURI : undefined),
|
||||
options ? options.progressiveChunkSize : undefined,
|
||||
@@ -122,6 +130,7 @@ function renderToPipeableStream(
|
||||
);
|
||||
}
|
||||
hasStartedFlowing = true;
|
||||
prepareForStartFlowingIfBeforeAllReady(request);
|
||||
startFlowing(request, destination);
|
||||
destination.on('drain', createDrainHandler(destination, request));
|
||||
destination.on(
|
||||
|
||||
+17
-1
@@ -8,7 +8,10 @@
|
||||
*/
|
||||
|
||||
import type {ReactNodeList} from 'shared/ReactTypes';
|
||||
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {
|
||||
BootstrapScriptDescriptor,
|
||||
HeadersDescriptor,
|
||||
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {PostponedState} from 'react-server/src/ReactFizzServer';
|
||||
import type {ImportMap} from '../shared/ReactDOMTypes';
|
||||
|
||||
@@ -41,6 +44,8 @@ type Options = {
|
||||
onPostpone?: (reason: string) => void,
|
||||
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
|
||||
importMap?: ImportMap,
|
||||
onHeaders?: (headers: Headers) => void,
|
||||
maxHeadersLength?: number,
|
||||
};
|
||||
|
||||
type StaticResult = {
|
||||
@@ -77,6 +82,15 @@ function prerender(
|
||||
};
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
const onHeaders = options ? options.onHeaders : undefined;
|
||||
let onHeadersImpl;
|
||||
if (onHeaders) {
|
||||
onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
|
||||
onHeaders(new Headers(headersDescriptor));
|
||||
};
|
||||
}
|
||||
|
||||
const resources = createResumableState(
|
||||
options ? options.identifierPrefix : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
@@ -92,6 +106,8 @@ function prerender(
|
||||
options ? options.bootstrapModules : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
options ? options.importMap : undefined,
|
||||
onHeadersImpl,
|
||||
options ? options.maxHeadersLength : undefined,
|
||||
),
|
||||
createRootFormatContext(options ? options.namespaceURI : undefined),
|
||||
options ? options.progressiveChunkSize : undefined,
|
||||
|
||||
+16
-1
@@ -8,7 +8,10 @@
|
||||
*/
|
||||
|
||||
import type {ReactNodeList} from 'shared/ReactTypes';
|
||||
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {
|
||||
BootstrapScriptDescriptor,
|
||||
HeadersDescriptor,
|
||||
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {PostponedState} from 'react-server/src/ReactFizzServer';
|
||||
import type {ImportMap} from '../shared/ReactDOMTypes';
|
||||
|
||||
@@ -41,6 +44,8 @@ type Options = {
|
||||
onPostpone?: (reason: string) => void,
|
||||
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
|
||||
importMap?: ImportMap,
|
||||
onHeaders?: (headers: Headers) => void,
|
||||
maxHeadersLength?: number,
|
||||
};
|
||||
|
||||
type StaticResult = {
|
||||
@@ -77,6 +82,14 @@ function prerender(
|
||||
};
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
const onHeaders = options ? options.onHeaders : undefined;
|
||||
let onHeadersImpl;
|
||||
if (onHeaders) {
|
||||
onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
|
||||
onHeaders(new Headers(headersDescriptor));
|
||||
};
|
||||
}
|
||||
const resources = createResumableState(
|
||||
options ? options.identifierPrefix : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
@@ -92,6 +105,8 @@ function prerender(
|
||||
options ? options.bootstrapModules : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
options ? options.importMap : undefined,
|
||||
onHeadersImpl,
|
||||
options ? options.maxHeadersLength : undefined,
|
||||
),
|
||||
createRootFormatContext(options ? options.namespaceURI : undefined),
|
||||
options ? options.progressiveChunkSize : undefined,
|
||||
|
||||
+8
-1
@@ -8,7 +8,10 @@
|
||||
*/
|
||||
|
||||
import type {ReactNodeList} from 'shared/ReactTypes';
|
||||
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {
|
||||
BootstrapScriptDescriptor,
|
||||
HeadersDescriptor,
|
||||
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
|
||||
import type {PostponedState} from 'react-server/src/ReactFizzServer';
|
||||
import type {ImportMap} from '../shared/ReactDOMTypes';
|
||||
|
||||
@@ -42,6 +45,8 @@ type Options = {
|
||||
onPostpone?: (reason: string) => void,
|
||||
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
|
||||
importMap?: ImportMap,
|
||||
onHeaders?: (headers: HeadersDescriptor) => void,
|
||||
maxHeadersLength?: number,
|
||||
};
|
||||
|
||||
type StaticResult = {
|
||||
@@ -101,6 +106,8 @@ function prerenderToNodeStream(
|
||||
options ? options.bootstrapModules : undefined,
|
||||
options ? options.unstable_externalRuntimeSrc : undefined,
|
||||
options ? options.importMap : undefined,
|
||||
options ? options.onHeaders : undefined,
|
||||
options ? options.maxHeadersLength : undefined,
|
||||
),
|
||||
createRootFormatContext(options ? options.namespaceURI : undefined),
|
||||
options ? options.progressiveChunkSize : undefined,
|
||||
|
||||
+6
-5
@@ -17,7 +17,7 @@ export type PreloadOptions = {
|
||||
integrity?: string,
|
||||
type?: string,
|
||||
nonce?: string,
|
||||
fetchPriority?: 'high' | 'low' | 'auto',
|
||||
fetchPriority?: FetchPriorityEnum,
|
||||
imageSrcSet?: string,
|
||||
imageSizes?: string,
|
||||
referrerPolicy?: string,
|
||||
@@ -34,7 +34,7 @@ export type PreinitOptions = {
|
||||
crossOrigin?: string,
|
||||
integrity?: string,
|
||||
nonce?: string,
|
||||
fetchPriority?: 'high' | 'low' | 'auto',
|
||||
fetchPriority?: FetchPriorityEnum,
|
||||
};
|
||||
export type PreinitModuleOptions = {
|
||||
as?: string,
|
||||
@@ -51,10 +51,11 @@ export type PreloadImplOptions = {
|
||||
integrity?: ?string,
|
||||
nonce?: ?string,
|
||||
type?: ?string,
|
||||
fetchPriority?: ?FetchPriorityEnum,
|
||||
fetchPriority?: ?string,
|
||||
referrerPolicy?: ?string,
|
||||
imageSrcSet?: ?string,
|
||||
imageSizes?: ?string,
|
||||
media?: ?string,
|
||||
};
|
||||
export type PreloadModuleImplOptions = {
|
||||
as?: ?string,
|
||||
@@ -65,12 +66,12 @@ export type PreloadModuleImplOptions = {
|
||||
export type PreinitStyleOptions = {
|
||||
crossOrigin?: ?CrossOriginEnum,
|
||||
integrity?: ?string,
|
||||
fetchPriority?: ?FetchPriorityEnum,
|
||||
fetchPriority?: ?string,
|
||||
};
|
||||
export type PreinitScriptOptions = {
|
||||
crossOrigin?: ?CrossOriginEnum,
|
||||
integrity?: ?string,
|
||||
fetchPriority?: ?FetchPriorityEnum,
|
||||
fetchPriority?: ?string,
|
||||
nonce?: ?string,
|
||||
};
|
||||
export type PreinitModuleScriptOptions = {
|
||||
|
||||
@@ -271,6 +271,7 @@ const ReactNoopServer = ReactFizzServer({
|
||||
setCurrentlyRenderingBoundaryResourcesTarget(resources: BoundaryResources) {},
|
||||
|
||||
prepareHostDispatcher() {},
|
||||
emitEarlyPreloads() {},
|
||||
});
|
||||
|
||||
type Options = {
|
||||
|
||||
+102
-20
@@ -77,6 +77,7 @@ import {
|
||||
pushFormStateMarkerIsMatching,
|
||||
pushFormStateMarkerIsNotMatching,
|
||||
resetResumableState,
|
||||
emitEarlyPreloads,
|
||||
} from './ReactFizzConfig';
|
||||
import {
|
||||
constructClassInstance,
|
||||
@@ -455,6 +456,7 @@ export function createPrerenderRequest(
|
||||
onShellError,
|
||||
onFatalError,
|
||||
onPostpone,
|
||||
undefined,
|
||||
);
|
||||
// Start tracking postponed holes during this render.
|
||||
request.trackedPostpones = {
|
||||
@@ -3016,8 +3018,7 @@ function erroredTask(
|
||||
|
||||
request.allPendingTasks--;
|
||||
if (request.allPendingTasks === 0) {
|
||||
const onAllReady = request.onAllReady;
|
||||
onAllReady();
|
||||
completeAll(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3165,9 +3166,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
|
||||
}
|
||||
request.pendingRootTasks--;
|
||||
if (request.pendingRootTasks === 0) {
|
||||
request.onShellError = noop;
|
||||
const onShellReady = request.onShellReady;
|
||||
onShellReady();
|
||||
completeShell(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3209,11 +3208,54 @@ function abortTask(task: Task, request: Request, error: mixed): void {
|
||||
|
||||
request.allPendingTasks--;
|
||||
if (request.allPendingTasks === 0) {
|
||||
const onAllReady = request.onAllReady;
|
||||
onAllReady();
|
||||
completeAll(request);
|
||||
}
|
||||
}
|
||||
|
||||
// I extracted this function out because we want to ensure we consistently emit preloads before
|
||||
// transitioning to the next request stage and this transition can happen in multiple places in this
|
||||
// implementation.
|
||||
function completeShell(request: Request) {
|
||||
if (request.trackedPostpones === null) {
|
||||
// We only emit early preloads on shell completion for renders. For prerenders
|
||||
// we wait for the entire Request to finish because we are not responding to a
|
||||
// live request and can wait for as much data as possible.
|
||||
|
||||
// we should only be calling completeShell when the shell is complete so we
|
||||
// just use a literal here
|
||||
const shellComplete = true;
|
||||
emitEarlyPreloads(
|
||||
request.renderState,
|
||||
request.resumableState,
|
||||
shellComplete,
|
||||
);
|
||||
}
|
||||
// We have completed the shell so the shell can't error anymore.
|
||||
request.onShellError = noop;
|
||||
const onShellReady = request.onShellReady;
|
||||
onShellReady();
|
||||
}
|
||||
|
||||
// I extracted this function out because we want to ensure we consistently emit preloads before
|
||||
// transitioning to the next request stage and this transition can happen in multiple places in this
|
||||
// implementation.
|
||||
function completeAll(request: Request) {
|
||||
// During a render the shell must be complete if the entire request is finished
|
||||
// however during a Prerender it is possible that the shell is incomplete because
|
||||
// it postponed. We cannot use rootPendingTasks in the prerender case because
|
||||
// those hit zero even when the shell postpones. Instead we look at the completedRootSegment
|
||||
const shellComplete =
|
||||
request.trackedPostpones === null
|
||||
? // Render, we assume it is completed
|
||||
true
|
||||
: // Prerender Request, we use the state of the root segment
|
||||
request.completedRootSegment === null ||
|
||||
request.completedRootSegment.status !== POSTPONED;
|
||||
emitEarlyPreloads(request.renderState, request.resumableState, shellComplete);
|
||||
const onAllReady = request.onAllReady;
|
||||
onAllReady();
|
||||
}
|
||||
|
||||
function queueCompletedSegment(
|
||||
boundary: SuspenseBoundary,
|
||||
segment: Segment,
|
||||
@@ -3254,10 +3296,7 @@ function finishedTask(
|
||||
}
|
||||
request.pendingRootTasks--;
|
||||
if (request.pendingRootTasks === 0) {
|
||||
// We have completed the shell so the shell can't error anymore.
|
||||
request.onShellError = noop;
|
||||
const onShellReady = request.onShellReady;
|
||||
onShellReady();
|
||||
completeShell(request);
|
||||
}
|
||||
} else {
|
||||
boundary.pendingTasks--;
|
||||
@@ -3313,10 +3352,7 @@ function finishedTask(
|
||||
|
||||
request.allPendingTasks--;
|
||||
if (request.allPendingTasks === 0) {
|
||||
// This needs to be called at the very end so that we can synchronously write the result
|
||||
// in the callback if needed.
|
||||
const onAllReady = request.onAllReady;
|
||||
onAllReady();
|
||||
completeAll(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3528,14 +3564,11 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
|
||||
);
|
||||
request.pendingRootTasks--;
|
||||
if (request.pendingRootTasks === 0) {
|
||||
request.onShellError = noop;
|
||||
const onShellReady = request.onShellReady;
|
||||
onShellReady();
|
||||
completeShell(request);
|
||||
}
|
||||
request.allPendingTasks--;
|
||||
if (request.allPendingTasks === 0) {
|
||||
const onAllReady = request.onAllReady;
|
||||
onAllReady();
|
||||
completeAll(request);
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
@@ -4056,6 +4089,33 @@ export function startWork(request: Request): void {
|
||||
} else {
|
||||
scheduleWork(() => performWork(request));
|
||||
}
|
||||
if (request.trackedPostpones === null) {
|
||||
// this is either a regular render or a resume. For regular render we want
|
||||
// to call emitEarlyPreloads after the first performWork because we want
|
||||
// are responding to a live request and need to balance sending something early
|
||||
// (i.e. don't want for the shell to finish) but we need something to send.
|
||||
// The only implementation of this is for DOM at the moment and during resumes nothing
|
||||
// actually emits but the code paths here are the same.
|
||||
// During a prerender we don't want to be too aggressive in emitting early preloads
|
||||
// because we aren't responding to a live request and we can wait for the prerender to
|
||||
// postpone before we emit anything.
|
||||
if (supportsRequestStorage) {
|
||||
scheduleWork(() =>
|
||||
requestStorage.run(
|
||||
request,
|
||||
enqueueEarlyPreloadsAfterInitialWork,
|
||||
request,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
scheduleWork(() => enqueueEarlyPreloadsAfterInitialWork(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueEarlyPreloadsAfterInitialWork(request: Request) {
|
||||
const shellComplete = request.pendingRootTasks === 0;
|
||||
emitEarlyPreloads(request.renderState, request.resumableState, shellComplete);
|
||||
}
|
||||
|
||||
function enqueueFlush(request: Request): void {
|
||||
@@ -4081,6 +4141,27 @@ function enqueueFlush(request: Request): void {
|
||||
}
|
||||
}
|
||||
|
||||
// This function is intented to only be called during the pipe function for the Node builds.
|
||||
// The reason we need this is because `renderToPipeableStream` is the only API which allows
|
||||
// you to start flowing before the shell is complete and we've had a chance to emit early
|
||||
// preloads already. This is really just defensive programming to ensure that we give hosts an
|
||||
// opportunity to flush early preloads before streaming begins in case they are in an environment
|
||||
// that only supports a single call to emitEarlyPreloads like the DOM renderers. It's unfortunate
|
||||
// to put this Node only function directly in ReactFizzServer but it'd be more ackward to factor it
|
||||
// by moving the implementation into ReactServerStreamConfigNode and even then we may not be able to
|
||||
// eliminate all the wasted branching.
|
||||
export function prepareForStartFlowingIfBeforeAllReady(request: Request) {
|
||||
const shellComplete =
|
||||
request.trackedPostpones === null
|
||||
? // Render Request, we define shell complete by the pending root tasks
|
||||
request.pendingRootTasks === 0
|
||||
: // Prerender Request, we define shell complete by completedRootSegemtn
|
||||
request.completedRootSegment === null
|
||||
? request.pendingRootTasks === 0
|
||||
: request.completedRootSegment.status !== POSTPONED;
|
||||
emitEarlyPreloads(request.renderState, request.resumableState, shellComplete);
|
||||
}
|
||||
|
||||
export function startFlowing(request: Request, destination: Destination): void {
|
||||
if (request.status === CLOSING) {
|
||||
request.status = CLOSED;
|
||||
@@ -4095,6 +4176,7 @@ export function startFlowing(request: Request, destination: Destination): void {
|
||||
return;
|
||||
}
|
||||
request.destination = destination;
|
||||
|
||||
try {
|
||||
flushCompletedQueues(request, destination);
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,6 +32,7 @@ export opaque type RenderState = mixed;
|
||||
export opaque type ResumableState = mixed;
|
||||
export opaque type BoundaryResources = mixed;
|
||||
export opaque type FormatContext = mixed;
|
||||
export opaque type HeadersDescriptor = mixed;
|
||||
export type {TransitionStatus};
|
||||
|
||||
export const isPrimaryRenderer = false;
|
||||
@@ -91,3 +92,4 @@ export const createBoundaryResources = $$$config.createBoundaryResources;
|
||||
export const setCurrentlyRenderingBoundaryResourcesTarget =
|
||||
$$$config.setCurrentlyRenderingBoundaryResourcesTarget;
|
||||
export const writeResourcesForBoundary = $$$config.writeResourcesForBoundary;
|
||||
export const emitEarlyPreloads = $$$config.emitEarlyPreloads;
|
||||
|
||||
@@ -117,6 +117,23 @@ export function checkPropStringCoercion(
|
||||
}
|
||||
}
|
||||
|
||||
export function checkOptionStringCoercion(
|
||||
value: mixed,
|
||||
propName: string,
|
||||
): void | string {
|
||||
if (__DEV__) {
|
||||
if (willCoercionThrow(value)) {
|
||||
console.error(
|
||||
'The provided `%s` option is an unsupported type %s.' +
|
||||
' This value must be coerced to a string before using it here.',
|
||||
propName,
|
||||
typeName(value),
|
||||
);
|
||||
return testStringCoercion(value); // throw (to help callers find troubleshooting comments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function checkCSSPropertyStringCoercion(
|
||||
value: mixed,
|
||||
propName: string,
|
||||
|
||||
@@ -484,5 +484,6 @@
|
||||
"496": "Only objects or functions can be passed to taintObjectReference. Try taintUniqueValue instead.",
|
||||
"497": "Only objects or functions can be passed to taintObjectReference.",
|
||||
"498": "Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.",
|
||||
"499": "Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported."
|
||||
}
|
||||
"499": "Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.",
|
||||
"500": "React expected a headers state to exist when emitEarlyPreloads was called but did not find it. This suggests emitEarlyPreloads was called more than once per request. This is a bug in React."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user