@samitouri / QOS-React-1 / commits / 2983249dd2

[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

Josh Story committed Nov 7, 2023 at 10:16 UTC 2983249dd2bb1a295f27939e36ab0de9e4bfab76
18 files changed +1148 -123
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+573 -89
@@ -22,6 +22,7 @@ import {
22 checkHtmlStringCoercion,
23 checkCSSPropertyStringCoercion,
24 checkAttributeStringCoercion,
25 + checkOptionStringCoercion,
26 } from 'shared/CheckStringCoercion';
27
28 import {Children} from 'react';
@@ -65,6 +66,7 @@ import {validateProperties as validateARIAProperties} from '../shared/ReactDOMIn
66 import {validateProperties as validateInputProperties} from '../shared/ReactDOMNullInputValuePropHook';
67 import {validateProperties as validateUnknownProperties} from '../shared/ReactDOMUnknownPropertyHook';
68 import warnValidStyle from '../shared/warnValidStyle';
69 +import {getCrossOriginString} from '../shared/crossOriginStrings';
70
71 import escapeTextForBrowser from './escapeTextForBrowser';
72 import hyphenateStyleName from '../shared/hyphenateStyleName';
@@ -101,6 +103,12 @@ export function prepareHostDispatcher() {
103 ReactDOMCurrentDispatcher.current = ReactDOMServerDispatcher;
104 }
105
106 +// We make every property of the descriptor optional because it is not a contract that
107 +// the headers provided by onHeaders has any particular header types.
108 +export type HeadersDescriptor = {
109 + Link?: string,
110 +};
111 +
112 // Used to distinguish these contexts from ones used in other renderers.
113 // E.g. this can be used to distinguish legacy renderers from this modern one.
114 export const isPrimaryRenderer = true;
@@ -147,6 +155,34 @@ export type RenderState = {
155 preloadChunks: Array<Chunk | PrecomputedChunk>,
156 hoistableChunks: Array<Chunk | PrecomputedChunk>,
157
158 + // Headers queues for Resources that can flush early
159 + onHeaders: void | ((headers: HeadersDescriptor) => void),
160 + headers: null | {
161 + preconnects: string,
162 + fontPreloads: string,
163 + highImagePreloads: string,
164 + remainingCapacity: number,
165 + },
166 + resets: {
167 + // corresponds to ResumableState.unknownResources["font"]
168 + font: {
169 + [href: string]: Preloaded,
170 + },
171 + // the rest correspond to ResumableState[<...>Resources]
172 + dns: {[key: string]: Exists},
173 + connect: {
174 + default: {[key: string]: Exists},
175 + anonymous: {[key: string]: Exists},
176 + credentials: {[key: string]: Exists},
177 + },
178 + image: {
179 + [key: string]: Preloaded,
180 + },
181 + style: {
182 + [key: string]: Exists | Preloaded | PreloadedWithCredentials,
183 + },
184 + },
185 +
186 // Flushing queues for Resource dependencies
187 preconnects: Set<Resource>,
188 fontPreloads: Set<Resource>,
@@ -188,7 +224,7 @@ type Preloaded = [];
224 // it seems that browsers do not treat this as part of the http cache key and does not affect
225 // which connection is used.
226 type PreloadedWithCredentials = [
191 - /* crossOrigin */ ?string,
227 + /* crossOrigin */ ?CrossOriginEnum,
228 /* integrity */ ?string,
229 ];
230
@@ -298,6 +334,15 @@ const importMapScriptStart = stringToPrecomputedChunk(
334 );
335 const importMapScriptEnd = stringToPrecomputedChunk('</script>');
336
337 +// Since we store headers as strings we deal with their length in utf16 code units
338 +// rather than visual characters or the utf8 encoding that is used for most binary
339 +// serialization. Some common HTTP servers only allow for headers to be 4kB in length.
340 +// We choose a default length that is likely to be well under this already limited length however
341 +// pathological cases may still cause the utf-8 encoding of the headers to approach this limit.
342 +// It should also be noted that this maximum is a soft maximum. we have not reached the limit we will
343 +// allow one more header to be captured which means in practice if the limit is approached it will be exceeded
344 +const DEFAULT_HEADERS_CAPACITY_IN_UTF16_CODE_UNITS = 2000;
345 +
346 // Allows us to keep track of what we've already written so we can refer back to it.
347 // if passed externalRuntimeConfig and the enableFizzExternalRuntime feature flag
348 // is set, the server will send instructions via data attributes (instead of inline scripts)
@@ -309,6 +354,8 @@ export function createRenderState(
354 bootstrapModules: $ReadOnlyArray<string | BootstrapScriptDescriptor> | void,
355 externalRuntimeConfig: string | BootstrapScriptDescriptor | void,
356 importMap: ImportMap | void,
357 + onHeaders: void | ((headers: HeadersDescriptor) => void),
358 + maxHeadersLength: void | number,
359 ): RenderState {
360 const inlineScriptWithNonce =
361 nonce === undefined
@@ -373,6 +420,27 @@ export function createRenderState(
420 );
421 importMapChunks.push(importMapScriptEnd);
422 }
423 + if (__DEV__) {
424 + if (onHeaders && typeof maxHeadersLength === 'number') {
425 + if (maxHeadersLength <= 0) {
426 + console.error(
427 + '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.',
428 + maxHeadersLength === 0 ? 'zero' : maxHeadersLength,
429 + );
430 + }
431 + }
432 + }
433 + const headers = onHeaders
434 + ? {
435 + preconnects: '',
436 + fontPreloads: '',
437 + highImagePreloads: '',
438 + remainingCapacity:
439 + typeof maxHeadersLength === 'number'
440 + ? maxHeadersLength
441 + : DEFAULT_HEADERS_CAPACITY_IN_UTF16_CODE_UNITS,
442 + }
443 + : null;
444 const renderState: RenderState = {
445 placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
446 segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
@@ -384,11 +452,26 @@ export function createRenderState(
452 externalRuntimeScript: externalRuntimeScript,
453 bootstrapChunks: bootstrapChunks,
454
455 + onHeaders,
456 + headers,
457 + resets: {
458 + font: {},
459 + dns: {},
460 + connect: {
461 + default: {},
462 + anonymous: {},
463 + credentials: {},
464 + },
465 + image: {},
466 + style: {},
467 + },
468 +
469 charsetChunks: [],
470 preconnectChunks: [],
471 importMapChunks,
472 preloadChunks: [],
473 hoistableChunks: [],
474 +
475 // cleared on flush
476 preconnects: new Set(),
477 fontPreloads: new Set(),
@@ -535,6 +618,7 @@ export function resumeRenderState(
618 undefined,
619 undefined,
620 undefined,
621 + undefined,
622 );
623 }
624
@@ -585,15 +669,13 @@ export function resetResumableState(
669 resumableState.nextFormID = 0;
670 resumableState.hasBody = false;
671 resumableState.hasHtml = false;
588 - resumableState.unknownResources = {};
589 - resumableState.dnsResources = {};
590 - resumableState.connectResources = {
591 - default: {},
592 - anonymous: {},
593 - credentials: {},
672 + resumableState.unknownResources = {
673 + font: renderState.resets.font,
674 };
595 - resumableState.imageResources = {};
596 - resumableState.styleResources = {};
675 + resumableState.dnsResources = renderState.resets.dns;
676 + resumableState.connectResources = renderState.resets.connect;
677 + resumableState.imageResources = renderState.resets.image;
678 + resumableState.styleResources = renderState.resets.style;
679 resumableState.scriptResources = {};
680 resumableState.moduleUnknownResources = {};
681 resumableState.moduleScriptResources = {};
@@ -2636,36 +2718,85 @@ function pushImg(
2718 } else if (!resumableState.imageResources.hasOwnProperty(key)) {
2719 // We must construct a new preload resource
2720 resumableState.imageResources[key] = PRELOAD_NO_CREDS;
2639 - resource = [];
2640 - pushLinkImpl(
2641 - resource,
2642 - ({
2643 - rel: 'preload',
2644 - as: 'image',
2645 - // There is a bug in Safari where imageSrcSet is not respected on preload links
2646 - // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image.
2647 - // This harms older browers that do not support imageSrcSet by making their preloads not work
2648 - // but this population is shrinking fast and is already small so we accept this tradeoff.
2649 - href: srcSet ? undefined : src,
2650 - imageSrcSet: srcSet,
2651 - imageSizes: sizes,
2652 - crossOrigin: props.crossOrigin,
2721 + const crossOrigin = getCrossOriginString(props.crossOrigin);
2722 +
2723 + const headers = renderState.headers;
2724 + let header;
2725 + if (
2726 + headers &&
2727 + headers.remainingCapacity > 0 &&
2728 + // this is a hueristic similar to capping element preloads to 10 unless explicitly
2729 + // fetchPriority="high". We use length here which means it will fit fewer images when
2730 + // the urls are long and more when short. arguably byte size is a better hueristic because
2731 + // it directly translates to how much we send down before content is actually seen.
2732 + // We could unify the counts and also make it so the total is tracked regardless of
2733 + // flushing output but since the headers are likely to be go earlier than content
2734 + // they don't really conflict so for now I've kept them separate
2735 + (props.fetchPriority === 'high' ||
2736 + headers.highImagePreloads.length < 500) &&
2737 + // We manually construct the options for the preload only from strings. We don't want to pollute
2738 + // the params list with arbitrary props and if we copied everything over as it we might get
2739 + // coercion errors. We have checks for this in Dev but it seems safer to just only accept values
2740 + // that are strings
2741 + ((header = getPreloadAsHeader(src, 'image', {
2742 + imageSrcSet: props.srcSet,
2743 + imageSizes: props.sizes,
2744 + crossOrigin,
2745 integrity: props.integrity,
2746 + nonce: props.nonce,
2747 type: props.type,
2748 fetchPriority: props.fetchPriority,
2656 - referrerPolicy: props.referrerPolicy,
2657 - }: PreloadProps),
2658 - );
2659 - if (
2660 - props.fetchPriority === 'high' ||
2661 - renderState.highImagePreloads.size < 10
2749 + referrerPolicy: props.refererPolicy,
2750 + })),
2751 + // We always consume the header length since once we find one header that doesn't fit
2752 + // we assume all the rest won't as well. This is to avoid getting into a situation
2753 + // where we have a very small remaining capacity but no headers will ever fit and we end
2754 + // up constantly trying to see if the next resource might make it. In the future we can
2755 + // make this behavior different between render and prerender since in the latter case
2756 + // we are less sensitive to the current requests runtime per and more sensitive to maximizing
2757 + // headers.
2758 + (headers.remainingCapacity -= header.length) >= 2)
2759 ) {
2663 - renderState.highImagePreloads.add(resource);
2760 + // If we postpone in the shell we will still emit this preload so we track
2761 + // it to make sure we don't reset it.
2762 + renderState.resets.image[key] = PRELOAD_NO_CREDS;
2763 + if (headers.highImagePreloads) {
2764 + headers.highImagePreloads += ', ';
2765 + }
2766 + // $FlowFixMe[unsafe-addition]: we assign header during the if condition
2767 + headers.highImagePreloads += header;
2768 } else {
2665 - renderState.bulkPreloads.add(resource);
2666 - // We can bump the priority up if the same img is rendered later
2667 - // with fetchPriority="high"
2668 - promotablePreloads.set(key, resource);
2769 + resource = [];
2770 + pushLinkImpl(
2771 + resource,
2772 + ({
2773 + rel: 'preload',
2774 + as: 'image',
2775 + // There is a bug in Safari where imageSrcSet is not respected on preload links
2776 + // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image.
2777 + // This harms older browers that do not support imageSrcSet by making their preloads not work
2778 + // but this population is shrinking fast and is already small so we accept this tradeoff.
2779 + href: srcSet ? undefined : src,
2780 + imageSrcSet: srcSet,
2781 + imageSizes: sizes,
2782 + crossOrigin: crossOrigin,
2783 + integrity: props.integrity,
2784 + type: props.type,
2785 + fetchPriority: props.fetchPriority,
2786 + referrerPolicy: props.referrerPolicy,
2787 + }: PreloadProps),
2788 + );
2789 + if (
2790 + props.fetchPriority === 'high' ||
2791 + renderState.highImagePreloads.size < 10
2792 + ) {
2793 + renderState.highImagePreloads.add(resource);
2794 + } else {
2795 + renderState.bulkPreloads.add(resource);
2796 + // We can bump the priority up if the same img is rendered later
2797 + // with fetchPriority="high"
2798 + promotablePreloads.set(key, resource);
2799 + }
2800 }
2801 }
2802 }
@@ -5036,12 +5167,14 @@ type PreloadProps = PreloadAsProps | PreloadModuleProps;
5167 type ScriptProps = {
5168 async: true,
5169 src: string,
5170 + crossOrigin?: ?CrossOriginEnum,
5171 [string]: mixed,
5172 };
5173 type ModuleScriptProps = {
5174 async: true,
5175 src: string,
5176 type: 'module',
5177 + crossOrigin?: ?CrossOriginEnum,
5178 [string]: mixed,
5179 };
5180
@@ -5051,6 +5184,13 @@ type StylesheetProps = {
5184 rel: 'stylesheet',
5185 href: string,
5186 'data-precedence': string,
5187 + crossOrigin?: ?CrossOriginEnum,
5188 + integrity?: ?string,
5189 + nonce?: ?string,
5190 + type?: ?string,
5191 + fetchPriority?: ?string,
5192 + referrerPolicy?: ?string,
5193 + media?: ?string,
5194 [string]: mixed,
5195 };
5196 type StylesheetResource = {
@@ -5118,10 +5258,37 @@ function prefetchDNS(href: string) {
5258 if (typeof href === 'string' && href) {
5259 const key = getResourceKey(href);
5260 if (!resumableState.dnsResources.hasOwnProperty(key)) {
5121 - const resource: Resource = [];
5261 resumableState.dnsResources[key] = EXISTS;
5123 - pushLinkImpl(resource, ({href, rel: 'dns-prefetch'}: PreconnectProps));
5124 - renderState.preconnects.add(resource);
5262 +
5263 + const headers = renderState.headers;
5264 + let header;
5265 + if (
5266 + headers &&
5267 + headers.remainingCapacity > 0 &&
5268 + // Compute the header since we might be able to fit it in the max length
5269 + ((header = getPrefetchDNSAsHeader(href)),
5270 + // We always consume the header length since once we find one header that doesn't fit
5271 + // we assume all the rest won't as well. This is to avoid getting into a situation
5272 + // where we have a very small remaining capacity but no headers will ever fit and we end
5273 + // up constantly trying to see if the next resource might make it. In the future we can
5274 + // make this behavior different between render and prerender since in the latter case
5275 + // we are less sensitive to the current requests runtime per and more sensitive to maximizing
5276 + // headers.
5277 + (headers.remainingCapacity -= header.length) >= 2)
5278 + ) {
5279 + // Store this as resettable in case we are prerendering and postpone in the Shell
5280 + renderState.resets.dns[key] = EXISTS;
5281 + if (headers.preconnects) {
5282 + headers.preconnects += ', ';
5283 + }
5284 + // $FlowFixMe[unsafe-addition]: we assign header during the if condition
5285 + headers.preconnects += header;
5286 + } else {
5287 + // Encode as element
5288 + const resource: Resource = [];
5289 + pushLinkImpl(resource, ({href, rel: 'dns-prefetch'}: PreconnectProps));
5290 + renderState.preconnects.add(resource);
5291 + }
5292 }
5293 flushResources(request);
5294 }
@@ -5144,21 +5311,47 @@ function preconnect(href: string, crossOrigin: ?CrossOriginEnum) {
5311 const renderState = getRenderState(request);
5312
5313 if (typeof href === 'string' && href) {
5147 - const resources =
5314 + const bucket =
5315 crossOrigin === 'use-credentials'
5149 - ? resumableState.connectResources.credentials
5316 + ? 'credentials'
5317 : typeof crossOrigin === 'string'
5151 - ? resumableState.connectResources.anonymous
5152 - : resumableState.connectResources.default;
5318 + ? 'anonymous'
5319 + : 'default';
5320 const key = getResourceKey(href);
5154 - if (!resources.hasOwnProperty(key)) {
5155 - const resource: Resource = [];
5156 - resources[key] = EXISTS;
5157 - pushLinkImpl(
5158 - resource,
5159 - ({rel: 'preconnect', href, crossOrigin}: PreconnectProps),
5160 - );
5161 - renderState.preconnects.add(resource);
5321 + if (!resumableState.connectResources[bucket].hasOwnProperty(key)) {
5322 + resumableState.connectResources[bucket][key] = EXISTS;
5323 +
5324 + const headers = renderState.headers;
5325 + let header;
5326 + if (
5327 + headers &&
5328 + headers.remainingCapacity > 0 &&
5329 + // Compute the header since we might be able to fit it in the max length
5330 + ((header = getPreconnectAsHeader(href, crossOrigin)),
5331 + // We always consume the header length since once we find one header that doesn't fit
5332 + // we assume all the rest won't as well. This is to avoid getting into a situation
5333 + // where we have a very small remaining capacity but no headers will ever fit and we end
5334 + // up constantly trying to see if the next resource might make it. In the future we can
5335 + // make this behavior different between render and prerender since in the latter case
5336 + // we are less sensitive to the current requests runtime per and more sensitive to maximizing
5337 + // headers.
5338 + (headers.remainingCapacity -= header.length) >= 2)
5339 + ) {
5340 + // Store this in resettableState in case we are prerending and postpone in the Shell
5341 + renderState.resets.connect[bucket][key] = EXISTS;
5342 + if (headers.preconnects) {
5343 + headers.preconnects += ', ';
5344 + }
5345 + // $FlowFixMe[unsafe-addition]: we assign header during the if condition
5346 + headers.preconnects += header;
5347 + } else {
5348 + const resource: Resource = [];
5349 + pushLinkImpl(
5350 + resource,
5351 + ({rel: 'preconnect', href, crossOrigin}: PreconnectProps),
5352 + );
5353 + renderState.preconnects.add(resource);
5354 + }
5355 }
5356 flushResources(request);
5357 }
@@ -5194,29 +5387,61 @@ function preload(href: string, as: string, options?: ?PreloadImplOptions) {
5387 return;
5388 }
5389 resumableState.imageResources[key] = PRELOAD_NO_CREDS;
5197 - const resource = ([]: Resource);
5198 - pushLinkImpl(
5199 - resource,
5200 - Object.assign(
5201 - ({
5202 - rel: 'preload',
5203 - // There is a bug in Safari where imageSrcSet is not respected on preload links
5204 - // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image.
5205 - // This harms older browers that do not support imageSrcSet by making their preloads not work
5206 - // but this population is shrinking fast and is already small so we accept this tradeoff.
5207 - href: imageSrcSet ? undefined : href,
5208 - as,
5209 - }: PreloadAsProps),
5210 - options,
5211 - ),
5212 - );
5213 - if (fetchPriority === 'high') {
5214 - renderState.highImagePreloads.add(resource);
5390 +
5391 + const headers = renderState.headers;
5392 + let header: string;
5393 + if (
5394 + headers &&
5395 + headers.remainingCapacity > 0 &&
5396 + fetchPriority === 'high' &&
5397 + // Compute the header since we might be able to fit it in the max length
5398 + ((header = getPreloadAsHeader(href, as, options)),
5399 + // We always consume the header length since once we find one header that doesn't fit
5400 + // we assume all the rest won't as well. This is to avoid getting into a situation
5401 + // where we have a very small remaining capacity but no headers will ever fit and we end
5402 + // up constantly trying to see if the next resource might make it. In the future we can
5403 + // make this behavior different between render and prerender since in the latter case
5404 + // we are less sensitive to the current requests runtime per and more sensitive to maximizing
5405 + // headers.
5406 + (headers.remainingCapacity -= header.length) >= 2)
5407 + ) {
5408 + // If we postpone in the shell we will still emit a preload as a header so we
5409 + // track this to make sure we don't reset it.
5410 + renderState.resets.image[key] = PRELOAD_NO_CREDS;
5411 + if (headers.highImagePreloads) {
5412 + headers.highImagePreloads += ', ';
5413 + }
5414 + // $FlowFixMe[unsafe-addition]: we assign header during the if condition
5415 + headers.highImagePreloads += header;
5416 } else {
5216 - renderState.bulkPreloads.add(resource);
5217 - // Stash the resource in case we need to promote it to higher priority
5218 - // when an img tag is rendered
5219 - renderState.preloads.images.set(key, resource);
5417 + // If we don't have headers to write to we have to encode as elements to flush in the head
5418 + // When we have imageSrcSet the browser probably cannot load the right version from headers
5419 + // (this should be verified by testing). For now we assume these need to go in the head
5420 + // as elements even if headers are available.
5421 + const resource = ([]: Resource);
5422 + pushLinkImpl(
5423 + resource,
5424 + Object.assign(
5425 + ({
5426 + rel: 'preload',
5427 + // There is a bug in Safari where imageSrcSet is not respected on preload links
5428 + // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image.
5429 + // This harms older browers that do not support imageSrcSet by making their preloads not work
5430 + // but this population is shrinking fast and is already small so we accept this tradeoff.
5431 + href: imageSrcSet ? undefined : href,
5432 + as,
5433 + }: PreloadAsProps),
5434 + options,
5435 + ),
5436 + );
5437 + if (fetchPriority === 'high') {
5438 + renderState.highImagePreloads.add(resource);
5439 + } else {
5440 + renderState.bulkPreloads.add(resource);
5441 + // Stash the resource in case we need to promote it to higher priority
5442 + // when an img tag is rendered
5443 + renderState.preloads.images.set(key, resource);
5444 + }
5445 }
5446 break;
5447 }
@@ -5276,25 +5501,55 @@ function preload(href: string, as: string, options?: ?PreloadImplOptions) {
5501 resources = ({}: ResumableState['unknownResources']['asType']);
5502 resumableState.unknownResources[as] = resources;
5503 }
5279 - const resource = ([]: Resource);
5280 - const props = Object.assign(
5281 - ({
5282 - rel: 'preload',
5283 - href,
5284 - as,
5285 - }: PreloadAsProps),
5286 - options,
5287 - );
5288 - switch (as) {
5289 - case 'font':
5290 - renderState.fontPreloads.add(resource);
5291 - break;
5292 - // intentional fall through
5293 - default:
5294 - renderState.bulkPreloads.add(resource);
5295 - }
5296 - pushLinkImpl(resource, props);
5504 resources[key] = PRELOAD_NO_CREDS;
5505 +
5506 + const headers = renderState.headers;
5507 + let header;
5508 + if (
5509 + headers &&
5510 + headers.remainingCapacity > 0 &&
5511 + as === 'font' &&
5512 + // We compute the header here because we might be able to fit it in the max length
5513 + ((header = getPreloadAsHeader(href, as, options)),
5514 + // We always consume the header length since once we find one header that doesn't fit
5515 + // we assume all the rest won't as well. This is to avoid getting into a situation
5516 + // where we have a very small remaining capacity but no headers will ever fit and we end
5517 + // up constantly trying to see if the next resource might make it. In the future we can
5518 + // make this behavior different between render and prerender since in the latter case
5519 + // we are less sensitive to the current requests runtime per and more sensitive to maximizing
5520 + // headers.
5521 + (headers.remainingCapacity -= header.length) >= 2)
5522 + ) {
5523 + // If we postpone in the shell we will still emit this preload so we
5524 + // track it here to prevent it from being reset.
5525 + renderState.resets.font[key] = PRELOAD_NO_CREDS;
5526 + if (headers.fontPreloads) {
5527 + headers.fontPreloads += ', ';
5528 + }
5529 + // $FlowFixMe[unsafe-addition]: we assign header during the if condition
5530 + headers.fontPreloads += header;
5531 + } else {
5532 + // We either don't have headers or we are preloading something that does
5533 + // not warrant elevated priority so we encode as an element.
5534 + const resource = ([]: Resource);
5535 + const props = Object.assign(
5536 + ({
5537 + rel: 'preload',
5538 + href,
5539 + as,
5540 + }: PreloadAsProps),
5541 + options,
5542 + );
5543 + pushLinkImpl(resource, props);
5544 + switch (as) {
5545 + case 'font':
5546 + renderState.fontPreloads.add(resource);
5547 + break;
5548 + // intentional fall through
5549 + default:
5550 + renderState.bulkPreloads.add(resource);
5551 + }
5552 + }
5553 }
5554 }
5555 // If we got this far we created a new resource
@@ -5683,6 +5938,138 @@ function adoptPreloadCredentials(
5938 if (target.integrity == null) target.integrity = preloadState[1];
5939 }
5940
5941 +function getPrefetchDNSAsHeader(href: string): string {
5942 + const escapedHref = escapeHrefForLinkHeaderURLContext(href);
5943 + return `<${escapedHref}>; rel=dns-prefetch`;
5944 +}
5945 +
5946 +function getPreconnectAsHeader(
5947 + href: string,
5948 + crossOrigin?: ?CrossOriginEnum,
5949 +): string {
5950 + const escapedHref = escapeHrefForLinkHeaderURLContext(href);
5951 + let value = `<${escapedHref}>; rel=preconnect`;
5952 + if (typeof crossOrigin === 'string') {
5953 + const escapedCrossOrigin = escapeStringForLinkHeaderQuotedParamValueContext(
5954 + crossOrigin,
5955 + 'crossOrigin',
5956 + );
5957 + value += `; crossorigin="${escapedCrossOrigin}"`;
5958 + }
5959 + return value;
5960 +}
5961 +
5962 +function getPreloadAsHeader(
5963 + href: string,
5964 + as: string,
5965 + params: ?PreloadImplOptions,
5966 +): string {
5967 + const escapedHref = escapeHrefForLinkHeaderURLContext(href);
5968 + const escapedAs = escapeStringForLinkHeaderQuotedParamValueContext(as, 'as');
5969 + let value = `<${escapedHref}>; rel=preload; as="${escapedAs}"`;
5970 + for (const paramName in params) {
5971 + if (hasOwnProperty.call(params, paramName)) {
5972 + const paramValue = params[paramName];
5973 + if (typeof paramValue === 'string') {
5974 + value += `; ${paramName.toLowerCase()}="${escapeStringForLinkHeaderQuotedParamValueContext(
5975 + paramValue,
5976 + paramName,
5977 + )}"`;
5978 + }
5979 + }
5980 + }
5981 + return value;
5982 +}
5983 +
5984 +function getStylesheetPreloadAsHeader(stylesheet: StylesheetResource): string {
5985 + const props = stylesheet.props;
5986 + const preloadOptions: PreloadImplOptions = {
5987 + crossOrigin: props.crossOrigin,
5988 + integrity: props.integrity,
5989 + nonce: props.nonce,
5990 + type: props.type,
5991 + fetchPriority: props.fetchPriority,
5992 + referrerPolicy: props.referrerPolicy,
5993 + media: props.media,
5994 + };
5995 + return getPreloadAsHeader(props.href, 'style', preloadOptions);
5996 +}
5997 +
5998 +// This escaping function is only safe to use for href values being written into
5999 +// a "Link" header in between `<` and `>` characters. The primary concern with the href is
6000 +// to escape the bounding characters as well as new lines. This is unsafe to use in any other
6001 +// context
6002 +const regexForHrefInLinkHeaderURLContext = /[<>\r\n]/g;
6003 +function escapeHrefForLinkHeaderURLContext(hrefInput: string): string {
6004 + if (__DEV__) {
6005 + checkAttributeStringCoercion(hrefInput, 'href');
6006 + }
6007 + const coercedHref = '' + hrefInput;
6008 + return coercedHref.replace(
6009 + regexForHrefInLinkHeaderURLContext,
6010 + escapeHrefForLinkHeaderURLContextReplacer,
6011 + );
6012 +}
6013 +function escapeHrefForLinkHeaderURLContextReplacer(match: string): string {
6014 + switch (match) {
6015 + case '<':
6016 + return '%3C';
6017 + case '>':
6018 + return '%3E';
6019 + case '\n':
6020 + return '%0A';
6021 + case '\r':
6022 + return '%0D';
6023 + default: {
6024 + // eslint-disable-next-line react-internal/prod-error-codes
6025 + throw new Error(
6026 + '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',
6027 + );
6028 + }
6029 + }
6030 +}
6031 +
6032 +// This escaping function is only safe to use for quoted param values in an HTTP header.
6033 +// It is unsafe to use for any value not inside quote marks in parater value position.
6034 +const regexForLinkHeaderQuotedParamValueContext = /["';,\r\n]/g;
6035 +function escapeStringForLinkHeaderQuotedParamValueContext(
6036 + value: string,
6037 + name: string,
6038 +): string {
6039 + if (__DEV__) {
6040 + checkOptionStringCoercion(value, name);
6041 + }
6042 + const coerced = '' + value;
6043 + return coerced.replace(
6044 + regexForLinkHeaderQuotedParamValueContext,
6045 + escapeStringForLinkHeaderQuotedParamValueContextReplacer,
6046 + );
6047 +}
6048 +function escapeStringForLinkHeaderQuotedParamValueContextReplacer(
6049 + match: string,
6050 +): string {
6051 + switch (match) {
6052 + case '"':
6053 + return '%22';
6054 + case "'":
6055 + return '%27';
6056 + case ';':
6057 + return '%3B';
6058 + case ',':
6059 + return '%2C';
6060 + case '\n':
6061 + return '%0A';
6062 + case '\r':
6063 + return '%0D';
6064 + default: {
6065 + // eslint-disable-next-line react-internal/prod-error-codes
6066 + throw new Error(
6067 + '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',
6068 + );
6069 + }
6070 + }
6071 +}
6072 +
6073 function hoistStyleQueueDependency(
6074 this: BoundaryResources,
6075 styleQueue: StyleQueue,
@@ -5711,5 +6098,102 @@ export function hoistResources(
6098 }
6099 }
6100
6101 +// This function is called at various times depending on whether we are rendering
6102 +// or prerendering. In this implementation we only actually emit headers once and
6103 +// subsequent calls are ignored. We track whether the request has a completed shell
6104 +// to determine whether we will follow headers with a flush including stylesheets.
6105 +// In the context of prerrender we don't have a completed shell when the request finishes
6106 +// with a postpone in the shell. In the context of a render we don't have a completed shell
6107 +// if this is called before the shell finishes rendering which usually will happen anytime
6108 +// anything suspends in the shell.
6109 +export function emitEarlyPreloads(
6110 + renderState: RenderState,
6111 + resumableState: ResumableState,
6112 + shellComplete: boolean,
6113 +): void {
6114 + const onHeaders = renderState.onHeaders;
6115 + if (onHeaders) {
6116 + const headers = renderState.headers;
6117 + if (headers) {
6118 + let linkHeader = headers.preconnects;
6119 + if (headers.fontPreloads) {
6120 + if (linkHeader) {
6121 + linkHeader += ', ';
6122 + }
6123 + linkHeader += headers.fontPreloads;
6124 + }
6125 + if (headers.highImagePreloads) {
6126 + if (linkHeader) {
6127 + linkHeader += ', ';
6128 + }
6129 + linkHeader += headers.highImagePreloads;
6130 + }
6131 +
6132 + if (!shellComplete) {
6133 + // We use raw iterators because we want to be able to halt iteration
6134 + // We could refactor renderState to store these dually in arrays to
6135 + // make this more efficient at the cost of additional memory and
6136 + // write overhead. However this code only runs once per request so
6137 + // for now I consider this sufficient.
6138 + const queueIter = renderState.styles.values();
6139 + outer: for (
6140 + let queueStep = queueIter.next();
6141 + headers.remainingCapacity > 0 && !queueStep.done;
6142 + queueStep = queueIter.next()
6143 + ) {
6144 + const sheets = queueStep.value.sheets;
6145 + const sheetIter = sheets.values();
6146 + for (
6147 + let sheetStep = sheetIter.next();
6148 + headers.remainingCapacity > 0 && !sheetStep.done;
6149 + sheetStep = sheetIter.next()
6150 + ) {
6151 + const sheet = sheetStep.value;
6152 + const props = sheet.props;
6153 + const key = getResourceKey(props.href);
6154 +
6155 + const header = getStylesheetPreloadAsHeader(sheet);
6156 + // We mutate the capacity b/c we don't want to keep checking if later headers will fit.
6157 + // This means that a particularly long header might close out the header queue where later
6158 + // headers could still fit. We could in the future alter the behavior here based on prerender vs render
6159 + // since during prerender we aren't as concerned with pure runtime performance.
6160 + if ((headers.remainingCapacity -= header.length) >= 2) {
6161 + renderState.resets.style[key] = PRELOAD_NO_CREDS;
6162 + if (linkHeader) {
6163 + linkHeader += ', ';
6164 + }
6165 + linkHeader += header;
6166 +
6167 + // We already track that the resource exists in resumableState however
6168 + // if the resumableState resets because we postponed in the shell
6169 + // which is what is happening in this branch if we are prerendering
6170 + // then we will end up resetting the resumableState. When it resets we
6171 + // want to record the fact that this stylesheet was already preloaded
6172 + renderState.resets.style[key] =
6173 + typeof props.crossOrigin === 'string' ||
6174 + typeof props.integrity === 'string'
6175 + ? [props.crossOrigin, props.integrity]
6176 + : PRELOAD_NO_CREDS;
6177 + } else {
6178 + break outer;
6179 + }
6180 + }
6181 + }
6182 + }
6183 + if (linkHeader) {
6184 + onHeaders({
6185 + Link: linkHeader,
6186 + });
6187 + } else {
6188 + // We still call this with no headers because a user may be using it as a signal that
6189 + // it React will not provide any headers
6190 + onHeaders({});
6191 + }
6192 + renderState.headers = null;
6193 + return;
6194 + }
6195 + }
6196 +}
6197 +
6198 export type TransitionStatus = FormStatus;
6199 export const NotPendingTransition: TransitionStatus = NotPending;
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+15
@@ -8,10 +8,12 @@
8 */
9
10 import type {
11 + RenderState as BaseRenderState,
12 ResumableState,
13 BoundaryResources,
14 StyleQueue,
15 Resource,
16 + HeadersDescriptor,
17 } from './ReactFizzConfigDOM';
18
19 import {
@@ -46,6 +48,14 @@ export type RenderState = {
48 headChunks: null | Array<Chunk | PrecomputedChunk>,
49 externalRuntimeScript: null | any,
50 bootstrapChunks: Array<Chunk | PrecomputedChunk>,
51 + onHeaders: void | ((headers: HeadersDescriptor) => void),
52 + headers: null | {
53 + preconnects: string,
54 + fontPreloads: string,
55 + highImagePreloads: string,
56 + remainingCapacity: number,
57 + },
58 + resets: BaseRenderState['resets'],
59 charsetChunks: Array<Chunk | PrecomputedChunk>,
60 preconnectChunks: Array<Chunk | PrecomputedChunk>,
61 importMapChunks: Array<Chunk | PrecomputedChunk>,
@@ -83,6 +93,7 @@ export function createRenderState(
93 undefined,
94 undefined,
95 undefined,
96 + undefined,
97 );
98 return {
99 // Keep this in sync with ReactFizzConfigDOM
@@ -94,6 +105,9 @@ export function createRenderState(
105 headChunks: renderState.headChunks,
106 externalRuntimeScript: renderState.externalRuntimeScript,
107 bootstrapChunks: renderState.bootstrapChunks,
108 + onHeaders: renderState.onHeaders,
109 + headers: renderState.headers,
110 + resets: renderState.resets,
111 charsetChunks: renderState.charsetChunks,
112 preconnectChunks: renderState.preconnectChunks,
113 importMapChunks: renderState.importMapChunks,
@@ -159,6 +173,7 @@ export {
173 setCurrentlyRenderingBoundaryResourcesTarget,
174 prepareHostDispatcher,
175 resetResumableState,
176 + emitEarlyPreloads,
177 } from './ReactFizzConfigDOM';
178
179 import escapeTextForBrowser from './escapeTextForBrowser';
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+151
@@ -3690,6 +3690,157 @@ describe('ReactDOMFizzServer', () => {
3690 );
3691 });
3692
3693 + it('provides headers after initial work if onHeaders option used', async () => {
3694 + let headers = null;
3695 + function onHeaders(x) {
3696 + headers = x;
3697 + }
3698 +
3699 + function Preloads() {
3700 + ReactDOM.preload('font2', {as: 'font'});
3701 + ReactDOM.preload('imagepre2', {as: 'image', fetchPriority: 'high'});
3702 + ReactDOM.preconnect('pre2', {crossOrigin: 'use-credentials'});
3703 + ReactDOM.prefetchDNS('dns2');
3704 + }
3705 +
3706 + function Blocked() {
3707 + readText('blocked');
3708 + return (
3709 + <>
3710 + <Preloads />
3711 + <img src="image2" />
3712 + </>
3713 + );
3714 + }
3715 +
3716 + function App() {
3717 + ReactDOM.preload('font', {as: 'font'});
3718 + ReactDOM.preload('imagepre', {as: 'image', fetchPriority: 'high'});
3719 + ReactDOM.preconnect('pre', {crossOrigin: 'use-credentials'});
3720 + ReactDOM.prefetchDNS('dns');
3721 + return (
3722 + <html>
3723 + <body>
3724 + <img src="image" />
3725 + <Blocked />
3726 + </body>
3727 + </html>
3728 + );
3729 + }
3730 +
3731 + await act(() => {
3732 + renderToPipeableStream(<App />, {onHeaders});
3733 + });
3734 +
3735 + expect(headers).toEqual({
3736 + Link: `
3737 +<pre>; rel=preconnect; crossorigin="use-credentials",
3738 + <dns>; rel=dns-prefetch,
3739 + <font>; rel=preload; as="font"; crossorigin="",
3740 + <imagepre>; rel=preload; as="image"; fetchpriority="high",
3741 + <image>; rel=preload; as="image"
3742 +`
3743 + .replaceAll('\n', '')
3744 + .trim(),
3745 + });
3746 + });
3747 +
3748 + it('encodes img srcset and sizes into preload header params', async () => {
3749 + let headers = null;
3750 + function onHeaders(x) {
3751 + headers = x;
3752 + }
3753 +
3754 + function App() {
3755 + ReactDOM.preload('presrc', {
3756 + as: 'image',
3757 + fetchPriority: 'high',
3758 + imageSrcSet: 'presrcset',
3759 + imageSizes: 'presizes',
3760 + });
3761 + return (
3762 + <html>
3763 + <body>
3764 + <img src="src" srcSet="srcset" sizes="sizes" />
3765 + </body>
3766 + </html>
3767 + );
3768 + }
3769 +
3770 + await act(() => {
3771 + renderToPipeableStream(<App />, {onHeaders});
3772 + });
3773 +
3774 + expect(headers).toEqual({
3775 + Link: `
3776 +<presrc>; rel=preload; as="image"; fetchpriority="high"; imagesrcset="presrcset"; imagesizes="presizes",
3777 + <src>; rel=preload; as="image"; imagesrcset="srcset"; imagesizes="sizes"
3778 +`
3779 + .replaceAll('\n', '')
3780 + .trim(),
3781 + });
3782 + });
3783 +
3784 + it('emits nothing for headers if you pipe before work begins', async () => {
3785 + let headers = null;
3786 + function onHeaders(x) {
3787 + headers = x;
3788 + }
3789 +
3790 + function App() {
3791 + ReactDOM.preload('presrc', {
3792 + as: 'image',
3793 + fetchPriority: 'high',
3794 + imageSrcSet: 'presrcset',
3795 + imageSizes: 'presizes',
3796 + });
3797 + return (
3798 + <html>
3799 + <body>
3800 + <img src="src" srcSet="srcset" sizes="sizes" />
3801 + </body>
3802 + </html>
3803 + );
3804 + }
3805 +
3806 + await act(() => {
3807 + renderToPipeableStream(<App />, {onHeaders}).pipe(writable);
3808 + });
3809 +
3810 + expect(headers).toEqual({});
3811 + });
3812 +
3813 + it('stops accumulating new headers once the maxHeadersLength limit is satisifed', async () => {
3814 + let headers = null;
3815 + function onHeaders(x) {
3816 + headers = x;
3817 + }
3818 +
3819 + function App() {
3820 + ReactDOM.preconnect('foo');
3821 + ReactDOM.preconnect('bar');
3822 + ReactDOM.preconnect('baz');
3823 + return (
3824 + <html>
3825 + <body>hello</body>
3826 + </html>
3827 + );
3828 + }
3829 +
3830 + await act(() => {
3831 + renderToPipeableStream(<App />, {onHeaders, maxHeadersLength: 44});
3832 + });
3833 +
3834 + expect(headers).toEqual({
3835 + Link: `
3836 +<foo>; rel=preconnect,
3837 + <bar>; rel=preconnect
3838 +`
3839 + .replaceAll('\n', '')
3840 + .trim(),
3841 + });
3842 + });
3843 +
3844 describe('error escaping', () => {
3845 it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
3846 window.__outlet = {};
packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
+73
@@ -13,6 +13,7 @@
13 let JSDOM;
14 let Stream;
15 let React;
16 +let ReactDOM;
17 let ReactDOMClient;
18 let ReactDOMFizzStatic;
19 let Suspense;
@@ -29,6 +30,7 @@ describe('ReactDOMFizzStatic', () => {
30 jest.resetModules();
31 JSDOM = require('jsdom').JSDOM;
32 React = require('react');
33 + ReactDOM = require('react-dom');
34 ReactDOMClient = require('react-dom/client');
35 if (__EXPERIMENTAL__) {
36 ReactDOMFizzStatic = require('react-dom/static');
@@ -262,4 +264,75 @@ describe('ReactDOMFizzStatic', () => {
264 'hello world',
265 ]);
266 });
267 +
268 + // @gate experimental
269 + it('supports onHeaders', async () => {
270 + let headers;
271 + function onHeaders(x) {
272 + headers = x;
273 + }
274 +
275 + function App() {
276 + ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
277 + ReactDOM.preload('font', {as: 'font'});
278 + return (
279 + <html>
280 + <body>hello</body>
281 + </html>
282 + );
283 + }
284 +
285 + const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
286 + onHeaders,
287 + });
288 + expect(headers).toEqual({
289 + Link: `
290 +<font>; rel=preload; as="font"; crossorigin="",
291 + <image>; rel=preload; as="image"; fetchpriority="high"
292 +`
293 + .replaceAll('\n', '')
294 + .trim(),
295 + });
296 +
297 + await act(async () => {
298 + result.prelude.pipe(writable);
299 + });
300 + expect(getVisibleChildren(container)).toEqual('hello');
301 + });
302 +
303 + // @gate experimental && enablePostpone
304 + it('includes stylesheet preloads in onHeaders when postponing in the Shell', async () => {
305 + let headers;
306 + function onHeaders(x) {
307 + headers = x;
308 + }
309 +
310 + function App() {
311 + ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
312 + ReactDOM.preinit('style', {as: 'style'});
313 + React.unstable_postpone();
314 + return (
315 + <html>
316 + <body>hello</body>
317 + </html>
318 + );
319 + }
320 +
321 + const result = await ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
322 + onHeaders,
323 + });
324 + expect(headers).toEqual({
325 + Link: `
326 +<image>; rel=preload; as="image"; fetchpriority="high",
327 + <style>; rel=preload; as="style"
328 +`
329 + .replaceAll('\n', '')
330 + .trim(),
331 + });
332 +
333 + await act(async () => {
334 + result.prelude.pipe(writable);
335 + });
336 + expect(getVisibleChildren(container)).toEqual(undefined);
337 + });
338 });
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+103
@@ -18,6 +18,7 @@ import {
18 global.ReadableStream =
19 require('web-streams-polyfill/ponyfill/es6').ReadableStream;
20 global.TextEncoder = require('util').TextEncoder;
21 +global.TextDecoder = require('util').TextDecoder;
22
23 let React;
24 let ReactDOM;
@@ -1316,4 +1317,106 @@ describe('ReactDOMFizzStaticBrowser', () => {
1317 '</head><body><div>Hello</div></body></html>',
1318 );
1319 });
1320 +
1321 + // @gate enablePostpone
1322 + it('does not emit preloads during resume for Resources preloaded through onHeaders', async () => {
1323 + let prerendering = true;
1324 +
1325 + let hasLoaded = false;
1326 + let resolve;
1327 + const promise = new Promise(r => (resolve = r));
1328 + function WaitIfResuming({children}) {
1329 + if (!prerendering && !hasLoaded) {
1330 + throw promise;
1331 + }
1332 + return children;
1333 + }
1334 +
1335 + function Postpone() {
1336 + if (prerendering) {
1337 + React.unstable_postpone();
1338 + }
1339 + return null;
1340 + }
1341 +
1342 + let headers;
1343 + function onHeaders(x) {
1344 + headers = x;
1345 + }
1346 +
1347 + function App() {
1348 + ReactDOM.preload('image', {as: 'image', fetchPriority: 'high'});
1349 + return (
1350 + <html>
1351 + <body>
1352 + hello
1353 + <Suspense fallback={null}>
1354 + <WaitIfResuming>
1355 + world
1356 + <link rel="stylesheet" href="style" precedence="default" />
1357 + </WaitIfResuming>
1358 + </Suspense>
1359 + <Postpone />
1360 + </body>
1361 + </html>
1362 + );
1363 + }
1364 +
1365 + const prerendered = await ReactDOMFizzStatic.prerender(<App />, {
1366 + onHeaders,
1367 + });
1368 + expect(prerendered.postponed).not.toBe(null);
1369 +
1370 + prerendering = false;
1371 +
1372 + expect(await readContent(prerendered.prelude)).toBe('');
1373 + expect(headers).toEqual(
1374 + new Headers({
1375 + Link: `
1376 +<image>; rel=preload; as="image"; fetchpriority="high",
1377 + <style>; rel=preload; as="style"
1378 +`
1379 + .replaceAll('\n', '')
1380 + .trim(),
1381 + }),
1382 + );
1383 +
1384 + const content = await ReactDOMFizzServer.resume(
1385 + <App />,
1386 + JSON.parse(JSON.stringify(prerendered.postponed)),
1387 + );
1388 +
1389 + const decoder = new TextDecoder();
1390 + const reader = content.getReader();
1391 + let {value, done} = await reader.read();
1392 + let result = decoder.decode(value, {stream: true});
1393 +
1394 + expect(result).toBe(
1395 + '<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$-->',
1396 + );
1397 +
1398 + await 1;
1399 + hasLoaded = true;
1400 + resolve();
1401 +
1402 + while (true) {
1403 + ({value, done} = await reader.read());
1404 + if (done) {
1405 + result += decoder.decode(value);
1406 + break;
1407 + }
1408 + result += decoder.decode(value, {stream: true});
1409 + }
1410 +
1411 + // We are mostly just trying to assert that no preload for our stylesheet was emitted
1412 + // prior to sending the segment the stylesheet was for. This test is asserting this
1413 + // because the boundary complete instruction is sent when we are writing the
1414 + const instructionIndex = result.indexOf('$RC');
1415 + expect(instructionIndex > -1).toBe(true);
1416 + const slice = result.slice(0, instructionIndex + '$RC'.length);
1417 +
1418 + expect(slice).toBe(
1419 + '<!DOCTYPE html><html><head></head><body>hello<!--$?--><template id="B:1"></template><!--/$--><div hidden id="S:1">world<!-- --></div><script>$RC',
1420 + );
1421 + });
1422 });
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+17 -1
@@ -9,7 +9,10 @@
9
10 import type {PostponedState} from 'react-server/src/ReactFizzServer';
11 import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
12 -import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12 +import type {
13 + BootstrapScriptDescriptor,
14 + HeadersDescriptor,
15 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
16 import type {ImportMap} from '../shared/ReactDOMTypes';
17
18 import ReactVersion from 'shared/ReactVersion';
@@ -44,6 +47,8 @@ type Options = {
47 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
48 importMap?: ImportMap,
49 formState?: ReactFormState<any, any> | null,
50 + onHeaders?: (headers: Headers) => void,
51 + maxHeadersLength?: number,
52 };
53
54 type ResumeOptions = {
@@ -97,6 +102,15 @@ function renderToReadableStream(
102 allReady.catch(() => {});
103 reject(error);
104 }
105 +
106 + const onHeaders = options ? options.onHeaders : undefined;
107 + let onHeadersImpl;
108 + if (onHeaders) {
109 + onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
110 + onHeaders(new Headers(headersDescriptor));
111 + };
112 + }
113 +
114 const resumableState = createResumableState(
115 options ? options.identifierPrefix : undefined,
116 options ? options.unstable_externalRuntimeSrc : undefined,
@@ -112,6 +126,8 @@ function renderToReadableStream(
126 options ? options.bootstrapModules : undefined,
127 options ? options.unstable_externalRuntimeSrc : undefined,
128 options ? options.importMap : undefined,
129 + onHeadersImpl,
130 + options ? options.maxHeadersLength : undefined,
131 ),
132 createRootFormatContext(options ? options.namespaceURI : undefined),
133 options ? options.progressiveChunkSize : undefined,
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+17 -1
@@ -8,7 +8,10 @@
8 */
9
10 import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
11 -import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
11 +import type {
12 + BootstrapScriptDescriptor,
13 + HeadersDescriptor,
14 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
15 import type {ImportMap} from '../shared/ReactDOMTypes';
16
17 import ReactVersion from 'shared/ReactVersion';
@@ -41,6 +44,8 @@ type Options = {
44 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
45 importMap?: ImportMap,
46 formState?: ReactFormState<any, any> | null,
47 + onHeaders?: (headers: Headers) => void,
48 + maxHeadersLength?: number,
49 };
50
51 // TODO: Move to sub-classing ReadableStream.
@@ -87,6 +92,15 @@ function renderToReadableStream(
92 allReady.catch(() => {});
93 reject(error);
94 }
95 +
96 + const onHeaders = options ? options.onHeaders : undefined;
97 + let onHeadersImpl;
98 + if (onHeaders) {
99 + onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
100 + onHeaders(new Headers(headersDescriptor));
101 + };
102 + }
103 +
104 const resumableState = createResumableState(
105 options ? options.identifierPrefix : undefined,
106 options ? options.unstable_externalRuntimeSrc : undefined,
@@ -102,6 +116,8 @@ function renderToReadableStream(
116 options ? options.bootstrapModules : undefined,
117 options ? options.unstable_externalRuntimeSrc : undefined,
118 options ? options.importMap : undefined,
119 + onHeadersImpl,
120 + options ? options.maxHeadersLength : undefined,
121 ),
122 createRootFormatContext(options ? options.namespaceURI : undefined),
123 options ? options.progressiveChunkSize : undefined,
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+17 -1
@@ -9,7 +9,10 @@
9
10 import type {PostponedState} from 'react-server/src/ReactFizzServer';
11 import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
12 -import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12 +import type {
13 + BootstrapScriptDescriptor,
14 + HeadersDescriptor,
15 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
16 import type {ImportMap} from '../shared/ReactDOMTypes';
17
18 import ReactVersion from 'shared/ReactVersion';
@@ -44,6 +47,8 @@ type Options = {
47 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
48 importMap?: ImportMap,
49 formState?: ReactFormState<any, any> | null,
50 + onHeaders?: (headers: Headers) => void,
51 + maxHeadersLength?: number,
52 };
53
54 type ResumeOptions = {
@@ -97,6 +102,15 @@ function renderToReadableStream(
102 allReady.catch(() => {});
103 reject(error);
104 }
105 +
106 + const onHeaders = options ? options.onHeaders : undefined;
107 + let onHeadersImpl;
108 + if (onHeaders) {
109 + onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
110 + onHeaders(new Headers(headersDescriptor));
111 + };
112 + }
113 +
114 const resumableState = createResumableState(
115 options ? options.identifierPrefix : undefined,
116 options ? options.unstable_externalRuntimeSrc : undefined,
@@ -112,6 +126,8 @@ function renderToReadableStream(
126 options ? options.bootstrapModules : undefined,
127 options ? options.unstable_externalRuntimeSrc : undefined,
128 options ? options.importMap : undefined,
129 + onHeadersImpl,
130 + options ? options.maxHeadersLength : undefined,
131 ),
132 createRootFormatContext(options ? options.namespaceURI : undefined),
133 options ? options.progressiveChunkSize : undefined,
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+10 -1
@@ -10,7 +10,10 @@
10 import type {Request, PostponedState} from 'react-server/src/ReactFizzServer';
11 import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
12 import type {Writable} from 'stream';
13 -import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
13 +import type {
14 + BootstrapScriptDescriptor,
15 + HeadersDescriptor,
16 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
17 import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
18 import type {ImportMap} from '../shared/ReactDOMTypes';
19
@@ -23,6 +26,7 @@ import {
26 startFlowing,
27 stopFlowing,
28 abort,
29 + prepareForStartFlowingIfBeforeAllReady,
30 } from 'react-server/src/ReactFizzServer';
31
32 import {
@@ -60,6 +64,8 @@ type Options = {
64 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
65 importMap?: ImportMap,
66 formState?: ReactFormState<any, any> | null,
67 + onHeaders?: (headers: HeadersDescriptor) => void,
68 + maxHeadersLength?: number,
69 };
70
71 type ResumeOptions = {
@@ -94,6 +100,8 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
100 options ? options.bootstrapModules : undefined,
101 options ? options.unstable_externalRuntimeSrc : undefined,
102 options ? options.importMap : undefined,
103 + options ? options.onHeaders : undefined,
104 + options ? options.maxHeadersLength : undefined,
105 ),
106 createRootFormatContext(options ? options.namespaceURI : undefined),
107 options ? options.progressiveChunkSize : undefined,
@@ -122,6 +130,7 @@ function renderToPipeableStream(
130 );
131 }
132 hasStartedFlowing = true;
133 + prepareForStartFlowingIfBeforeAllReady(request);
134 startFlowing(request, destination);
135 destination.on('drain', createDrainHandler(destination, request));
136 destination.on(
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
+17 -1
@@ -8,7 +8,10 @@
8 */
9
10 import type {ReactNodeList} from 'shared/ReactTypes';
11 -import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
11 +import type {
12 + BootstrapScriptDescriptor,
13 + HeadersDescriptor,
14 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
15 import type {PostponedState} from 'react-server/src/ReactFizzServer';
16 import type {ImportMap} from '../shared/ReactDOMTypes';
17
@@ -41,6 +44,8 @@ type Options = {
44 onPostpone?: (reason: string) => void,
45 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
46 importMap?: ImportMap,
47 + onHeaders?: (headers: Headers) => void,
48 + maxHeadersLength?: number,
49 };
50
51 type StaticResult = {
@@ -77,6 +82,15 @@ function prerender(
82 };
83 resolve(result);
84 }
85 +
86 + const onHeaders = options ? options.onHeaders : undefined;
87 + let onHeadersImpl;
88 + if (onHeaders) {
89 + onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
90 + onHeaders(new Headers(headersDescriptor));
91 + };
92 + }
93 +
94 const resources = createResumableState(
95 options ? options.identifierPrefix : undefined,
96 options ? options.unstable_externalRuntimeSrc : undefined,
@@ -92,6 +106,8 @@ function prerender(
106 options ? options.bootstrapModules : undefined,
107 options ? options.unstable_externalRuntimeSrc : undefined,
108 options ? options.importMap : undefined,
109 + onHeadersImpl,
110 + options ? options.maxHeadersLength : undefined,
111 ),
112 createRootFormatContext(options ? options.namespaceURI : undefined),
113 options ? options.progressiveChunkSize : undefined,
packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
+16 -1
@@ -8,7 +8,10 @@
8 */
9
10 import type {ReactNodeList} from 'shared/ReactTypes';
11 -import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
11 +import type {
12 + BootstrapScriptDescriptor,
13 + HeadersDescriptor,
14 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
15 import type {PostponedState} from 'react-server/src/ReactFizzServer';
16 import type {ImportMap} from '../shared/ReactDOMTypes';
17
@@ -41,6 +44,8 @@ type Options = {
44 onPostpone?: (reason: string) => void,
45 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
46 importMap?: ImportMap,
47 + onHeaders?: (headers: Headers) => void,
48 + maxHeadersLength?: number,
49 };
50
51 type StaticResult = {
@@ -77,6 +82,14 @@ function prerender(
82 };
83 resolve(result);
84 }
85 +
86 + const onHeaders = options ? options.onHeaders : undefined;
87 + let onHeadersImpl;
88 + if (onHeaders) {
89 + onHeadersImpl = (headersDescriptor: HeadersDescriptor) => {
90 + onHeaders(new Headers(headersDescriptor));
91 + };
92 + }
93 const resources = createResumableState(
94 options ? options.identifierPrefix : undefined,
95 options ? options.unstable_externalRuntimeSrc : undefined,
@@ -92,6 +105,8 @@ function prerender(
105 options ? options.bootstrapModules : undefined,
106 options ? options.unstable_externalRuntimeSrc : undefined,
107 options ? options.importMap : undefined,
108 + onHeadersImpl,
109 + options ? options.maxHeadersLength : undefined,
110 ),
111 createRootFormatContext(options ? options.namespaceURI : undefined),
112 options ? options.progressiveChunkSize : undefined,
packages/react-dom/src/server/ReactDOMFizzStaticNode.js
+8 -1
@@ -8,7 +8,10 @@
8 */
9
10 import type {ReactNodeList} from 'shared/ReactTypes';
11 -import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
11 +import type {
12 + BootstrapScriptDescriptor,
13 + HeadersDescriptor,
14 +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
15 import type {PostponedState} from 'react-server/src/ReactFizzServer';
16 import type {ImportMap} from '../shared/ReactDOMTypes';
17
@@ -42,6 +45,8 @@ type Options = {
45 onPostpone?: (reason: string) => void,
46 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
47 importMap?: ImportMap,
48 + onHeaders?: (headers: HeadersDescriptor) => void,
49 + maxHeadersLength?: number,
50 };
51
52 type StaticResult = {
@@ -101,6 +106,8 @@ function prerenderToNodeStream(
106 options ? options.bootstrapModules : undefined,
107 options ? options.unstable_externalRuntimeSrc : undefined,
108 options ? options.importMap : undefined,
109 + options ? options.onHeaders : undefined,
110 + options ? options.maxHeadersLength : undefined,
111 ),
112 createRootFormatContext(options ? options.namespaceURI : undefined),
113 options ? options.progressiveChunkSize : undefined,
packages/react-dom/src/shared/ReactDOMTypes.js
+6 -5
@@ -17,7 +17,7 @@ export type PreloadOptions = {
17 integrity?: string,
18 type?: string,
19 nonce?: string,
20 - fetchPriority?: 'high' | 'low' | 'auto',
20 + fetchPriority?: FetchPriorityEnum,
21 imageSrcSet?: string,
22 imageSizes?: string,
23 referrerPolicy?: string,
@@ -34,7 +34,7 @@ export type PreinitOptions = {
34 crossOrigin?: string,
35 integrity?: string,
36 nonce?: string,
37 - fetchPriority?: 'high' | 'low' | 'auto',
37 + fetchPriority?: FetchPriorityEnum,
38 };
39 export type PreinitModuleOptions = {
40 as?: string,
@@ -51,10 +51,11 @@ export type PreloadImplOptions = {
51 integrity?: ?string,
52 nonce?: ?string,
53 type?: ?string,
54 - fetchPriority?: ?FetchPriorityEnum,
54 + fetchPriority?: ?string,
55 referrerPolicy?: ?string,
56 imageSrcSet?: ?string,
57 imageSizes?: ?string,
58 + media?: ?string,
59 };
60 export type PreloadModuleImplOptions = {
61 as?: ?string,
@@ -65,12 +66,12 @@ export type PreloadModuleImplOptions = {
66 export type PreinitStyleOptions = {
67 crossOrigin?: ?CrossOriginEnum,
68 integrity?: ?string,
68 - fetchPriority?: ?FetchPriorityEnum,
69 + fetchPriority?: ?string,
70 };
71 export type PreinitScriptOptions = {
72 crossOrigin?: ?CrossOriginEnum,
73 integrity?: ?string,
73 - fetchPriority?: ?FetchPriorityEnum,
74 + fetchPriority?: ?string,
75 nonce?: ?string,
76 };
77 export type PreinitModuleScriptOptions = {
packages/react-noop-renderer/src/ReactNoopServer.js
+1
@@ -271,6 +271,7 @@ const ReactNoopServer = ReactFizzServer({
271 setCurrentlyRenderingBoundaryResourcesTarget(resources: BoundaryResources) {},
272
273 prepareHostDispatcher() {},
274 + emitEarlyPreloads() {},
275 });
276
277 type Options = {
packages/react-server/src/ReactFizzServer.js
+102 -20
@@ -77,6 +77,7 @@ import {
77 pushFormStateMarkerIsMatching,
78 pushFormStateMarkerIsNotMatching,
79 resetResumableState,
80 + emitEarlyPreloads,
81 } from './ReactFizzConfig';
82 import {
83 constructClassInstance,
@@ -455,6 +456,7 @@ export function createPrerenderRequest(
456 onShellError,
457 onFatalError,
458 onPostpone,
459 + undefined,
460 );
461 // Start tracking postponed holes during this render.
462 request.trackedPostpones = {
@@ -3016,8 +3018,7 @@ function erroredTask(
3018
3019 request.allPendingTasks--;
3020 if (request.allPendingTasks === 0) {
3019 - const onAllReady = request.onAllReady;
3020 - onAllReady();
3021 + completeAll(request);
3022 }
3023 }
3024
@@ -3165,9 +3166,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3166 }
3167 request.pendingRootTasks--;
3168 if (request.pendingRootTasks === 0) {
3168 - request.onShellError = noop;
3169 - const onShellReady = request.onShellReady;
3170 - onShellReady();
3169 + completeShell(request);
3170 }
3171 }
3172 }
@@ -3209,11 +3208,54 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3208
3209 request.allPendingTasks--;
3210 if (request.allPendingTasks === 0) {
3212 - const onAllReady = request.onAllReady;
3213 - onAllReady();
3211 + completeAll(request);
3212 }
3213 }
3214
3215 +// I extracted this function out because we want to ensure we consistently emit preloads before
3216 +// transitioning to the next request stage and this transition can happen in multiple places in this
3217 +// implementation.
3218 +function completeShell(request: Request) {
3219 + if (request.trackedPostpones === null) {
3220 + // We only emit early preloads on shell completion for renders. For prerenders
3221 + // we wait for the entire Request to finish because we are not responding to a
3222 + // live request and can wait for as much data as possible.
3223 +
3224 + // we should only be calling completeShell when the shell is complete so we
3225 + // just use a literal here
3226 + const shellComplete = true;
3227 + emitEarlyPreloads(
3228 + request.renderState,
3229 + request.resumableState,
3230 + shellComplete,
3231 + );
3232 + }
3233 + // We have completed the shell so the shell can't error anymore.
3234 + request.onShellError = noop;
3235 + const onShellReady = request.onShellReady;
3236 + onShellReady();
3237 +}
3238 +
3239 +// I extracted this function out because we want to ensure we consistently emit preloads before
3240 +// transitioning to the next request stage and this transition can happen in multiple places in this
3241 +// implementation.
3242 +function completeAll(request: Request) {
3243 + // During a render the shell must be complete if the entire request is finished
3244 + // however during a Prerender it is possible that the shell is incomplete because
3245 + // it postponed. We cannot use rootPendingTasks in the prerender case because
3246 + // those hit zero even when the shell postpones. Instead we look at the completedRootSegment
3247 + const shellComplete =
3248 + request.trackedPostpones === null
3249 + ? // Render, we assume it is completed
3250 + true
3251 + : // Prerender Request, we use the state of the root segment
3252 + request.completedRootSegment === null ||
3253 + request.completedRootSegment.status !== POSTPONED;
3254 + emitEarlyPreloads(request.renderState, request.resumableState, shellComplete);
3255 + const onAllReady = request.onAllReady;
3256 + onAllReady();
3257 +}
3258 +
3259 function queueCompletedSegment(
3260 boundary: SuspenseBoundary,
3261 segment: Segment,
@@ -3254,10 +3296,7 @@ function finishedTask(
3296 }
3297 request.pendingRootTasks--;
3298 if (request.pendingRootTasks === 0) {
3257 - // We have completed the shell so the shell can't error anymore.
3258 - request.onShellError = noop;
3259 - const onShellReady = request.onShellReady;
3260 - onShellReady();
3299 + completeShell(request);
3300 }
3301 } else {
3302 boundary.pendingTasks--;
@@ -3313,10 +3352,7 @@ function finishedTask(
3352
3353 request.allPendingTasks--;
3354 if (request.allPendingTasks === 0) {
3316 - // This needs to be called at the very end so that we can synchronously write the result
3317 - // in the callback if needed.
3318 - const onAllReady = request.onAllReady;
3319 - onAllReady();
3355 + completeAll(request);
3356 }
3357 }
3358
@@ -3528,14 +3564,11 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
3564 );
3565 request.pendingRootTasks--;
3566 if (request.pendingRootTasks === 0) {
3531 - request.onShellError = noop;
3532 - const onShellReady = request.onShellReady;
3533 - onShellReady();
3567 + completeShell(request);
3568 }
3569 request.allPendingTasks--;
3570 if (request.allPendingTasks === 0) {
3537 - const onAllReady = request.onAllReady;
3538 - onAllReady();
3571 + completeAll(request);
3572 }
3573 return;
3574 } finally {
@@ -4056,6 +4089,33 @@ export function startWork(request: Request): void {
4089 } else {
4090 scheduleWork(() => performWork(request));
4091 }
4092 + if (request.trackedPostpones === null) {
4093 + // this is either a regular render or a resume. For regular render we want
4094 + // to call emitEarlyPreloads after the first performWork because we want
4095 + // are responding to a live request and need to balance sending something early
4096 + // (i.e. don't want for the shell to finish) but we need something to send.
4097 + // The only implementation of this is for DOM at the moment and during resumes nothing
4098 + // actually emits but the code paths here are the same.
4099 + // During a prerender we don't want to be too aggressive in emitting early preloads
4100 + // because we aren't responding to a live request and we can wait for the prerender to
4101 + // postpone before we emit anything.
4102 + if (supportsRequestStorage) {
4103 + scheduleWork(() =>
4104 + requestStorage.run(
4105 + request,
4106 + enqueueEarlyPreloadsAfterInitialWork,
4107 + request,
4108 + ),
4109 + );
4110 + } else {
4111 + scheduleWork(() => enqueueEarlyPreloadsAfterInitialWork(request));
4112 + }
4113 + }
4114 +}
4115 +
4116 +function enqueueEarlyPreloadsAfterInitialWork(request: Request) {
4117 + const shellComplete = request.pendingRootTasks === 0;
4118 + emitEarlyPreloads(request.renderState, request.resumableState, shellComplete);
4119 }
4120
4121 function enqueueFlush(request: Request): void {
@@ -4081,6 +4141,27 @@ function enqueueFlush(request: Request): void {
4141 }
4142 }
4143
4144 +// This function is intented to only be called during the pipe function for the Node builds.
4145 +// The reason we need this is because `renderToPipeableStream` is the only API which allows
4146 +// you to start flowing before the shell is complete and we've had a chance to emit early
4147 +// preloads already. This is really just defensive programming to ensure that we give hosts an
4148 +// opportunity to flush early preloads before streaming begins in case they are in an environment
4149 +// that only supports a single call to emitEarlyPreloads like the DOM renderers. It's unfortunate
4150 +// to put this Node only function directly in ReactFizzServer but it'd be more ackward to factor it
4151 +// by moving the implementation into ReactServerStreamConfigNode and even then we may not be able to
4152 +// eliminate all the wasted branching.
4153 +export function prepareForStartFlowingIfBeforeAllReady(request: Request) {
4154 + const shellComplete =
4155 + request.trackedPostpones === null
4156 + ? // Render Request, we define shell complete by the pending root tasks
4157 + request.pendingRootTasks === 0
4158 + : // Prerender Request, we define shell complete by completedRootSegemtn
4159 + request.completedRootSegment === null
4160 + ? request.pendingRootTasks === 0
4161 + : request.completedRootSegment.status !== POSTPONED;
4162 + emitEarlyPreloads(request.renderState, request.resumableState, shellComplete);
4163 +}
4164 +
4165 export function startFlowing(request: Request, destination: Destination): void {
4166 if (request.status === CLOSING) {
4167 request.status = CLOSED;
@@ -4095,6 +4176,7 @@ export function startFlowing(request: Request, destination: Destination): void {
4176 return;
4177 }
4178 request.destination = destination;
4179 +
4180 try {
4181 flushCompletedQueues(request, destination);
4182 } catch (error) {
packages/react-server/src/forks/ReactFizzConfig.custom.js
+2
@@ -32,6 +32,7 @@ export opaque type RenderState = mixed;
32 export opaque type ResumableState = mixed;
33 export opaque type BoundaryResources = mixed;
34 export opaque type FormatContext = mixed;
35 +export opaque type HeadersDescriptor = mixed;
36 export type {TransitionStatus};
37
38 export const isPrimaryRenderer = false;
@@ -91,3 +92,4 @@ export const createBoundaryResources = $$$config.createBoundaryResources;
92 export const setCurrentlyRenderingBoundaryResourcesTarget =
93 $$$config.setCurrentlyRenderingBoundaryResourcesTarget;
94 export const writeResourcesForBoundary = $$$config.writeResourcesForBoundary;
95 +export const emitEarlyPreloads = $$$config.emitEarlyPreloads;
packages/shared/CheckStringCoercion.js
+17
@@ -117,6 +117,23 @@ export function checkPropStringCoercion(
117 }
118 }
119
120 +export function checkOptionStringCoercion(
121 + value: mixed,
122 + propName: string,
123 +): void | string {
124 + if (__DEV__) {
125 + if (willCoercionThrow(value)) {
126 + console.error(
127 + 'The provided `%s` option is an unsupported type %s.' +
128 + ' This value must be coerced to a string before using it here.',
129 + propName,
130 + typeName(value),
131 + );
132 + return testStringCoercion(value); // throw (to help callers find troubleshooting comments)
133 + }
134 + }
135 +}
136 +
137 export function checkCSSPropertyStringCoercion(
138 value: mixed,
139 propName: string,
scripts/error-codes/codes.json
+3 -2
@@ -484,5 +484,6 @@
484 "496": "Only objects or functions can be passed to taintObjectReference. Try taintUniqueValue instead.",
485 "497": "Only objects or functions can be passed to taintObjectReference.",
486 "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.",
487 - "499": "Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported."
488 -}
\ No newline at end of file
487 + "499": "Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.",
488 + "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."
489 +}