diff --git a/packages/react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js b/packages/react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js index c2a9227737edf..49c555f43f813 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js @@ -79,7 +79,11 @@ function preconnect(href: string, crossOrigin?: ?CrossOriginEnum) { } } -function preload(href: string, as: string, options?: ?PreloadImplOptions) { +export function preload( + href: string, + as: string, + options?: ?PreloadImplOptions, +) { if (typeof href === 'string') { const request = resolveRequest(); if (request) { @@ -112,7 +116,10 @@ function preload(href: string, as: string, options?: ?PreloadImplOptions) { } } -function preloadModule(href: string, options?: ?PreloadModuleImplOptions) { +export function preloadModule( + href: string, + options?: ?PreloadModuleImplOptions, +): void { if (typeof href === 'string') { const request = resolveRequest(); if (request) { diff --git a/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js index 75ed81bf6fe42..1c9b4763f5201 100644 --- a/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js @@ -17,8 +17,10 @@ import type { } from 'react-dom/src/shared/ReactDOMTypes'; // This module registers the host dispatcher so it needs to be imported -// but it does not have any exports -import './ReactDOMFlightServerHostDispatcher'; +// even if no exports are used. +import {preload, preloadModule} from './ReactDOMFlightServerHostDispatcher'; + +import {getCrossOriginString} from '../shared/crossOriginStrings'; // We use zero to represent the absence of an explicit precedence because it is // small, smaller than how we encode undefined, and is unambiguous. We could use @@ -62,10 +64,123 @@ export function createHints(): Hints { return new Set(); } -export opaque type FormatContext = null; +const NO_SCOPE = /* */ 0b000000; +const NOSCRIPT_SCOPE = /* */ 0b000001; +const PICTURE_SCOPE = /* */ 0b000010; + +export opaque type FormatContext = number; export function createRootFormatContext(): FormatContext { - return null; + return NO_SCOPE; +} + +function processImg(props: Object, formatContext: FormatContext): void { + // This should mirror the logic of pushImg in ReactFizzConfigDOM. + const pictureOrNoScriptTagInScope = + formatContext & (PICTURE_SCOPE | NOSCRIPT_SCOPE); + const {src, srcSet} = props; + if ( + props.loading !== 'lazy' && + (src || srcSet) && + (typeof src === 'string' || src == null) && + (typeof srcSet === 'string' || srcSet == null) && + props.fetchPriority !== 'low' && + !pictureOrNoScriptTagInScope && + // We exclude data URIs in src and srcSet since these should not be preloaded + !( + typeof src === 'string' && + src[4] === ':' && + (src[0] === 'd' || src[0] === 'D') && + (src[1] === 'a' || src[1] === 'A') && + (src[2] === 't' || src[2] === 'T') && + (src[3] === 'a' || src[3] === 'A') + ) && + !( + typeof srcSet === 'string' && + srcSet[4] === ':' && + (srcSet[0] === 'd' || srcSet[0] === 'D') && + (srcSet[1] === 'a' || srcSet[1] === 'A') && + (srcSet[2] === 't' || srcSet[2] === 'T') && + (srcSet[3] === 'a' || srcSet[3] === 'A') + ) + ) { + // We have a suspensey image and ought to preload it to optimize the loading of display blocking + // resumableState. + const sizes = typeof props.sizes === 'string' ? props.sizes : undefined; + + const crossOrigin = getCrossOriginString(props.crossOrigin); + + preload( + // The preload() API requires a href but if we have an imageSrcSet then that will take precedence. + // We already remove the href anyway in both Fizz and Fiber due to a Safari bug so the empty string + // will never actually appear in the DOM. + src || '', + 'image', + { + imageSrcSet: srcSet, + imageSizes: sizes, + crossOrigin: crossOrigin, + integrity: props.integrity, + type: props.type, + fetchPriority: props.fetchPriority, + referrerPolicy: props.referrerPolicy, + }, + ); + } +} + +function processLink(props: Object, formatContext: FormatContext): void { + const noscriptTagInScope = formatContext & NOSCRIPT_SCOPE; + const rel = props.rel; + const href = props.href; + if ( + noscriptTagInScope || + props.itemProp != null || + typeof rel !== 'string' || + typeof href !== 'string' || + href === '' + ) { + // We shouldn't preload resources that are in noscript or have no configuration. + return; + } + + switch (rel) { + case 'preload': { + preload(href, props.as, { + crossOrigin: props.crossOrigin, + integrity: props.integrity, + nonce: props.nonce, + type: props.type, + fetchPriority: props.fetchPriority, + referrerPolicy: props.referrerPolicy, + imageSrcSet: props.imageSrcSet, + imageSizes: props.imageSizes, + media: props.media, + }); + return; + } + case 'modulepreload': { + preloadModule(href, { + as: props.as, + crossOrigin: props.crossOrigin, + integrity: props.integrity, + nonce: props.nonce, + }); + return; + } + case 'stylesheet': { + preload(href, 'stylesheet', { + crossOrigin: props.crossOrigin, + integrity: props.integrity, + nonce: props.nonce, + type: props.type, + fetchPriority: props.fetchPriority, + referrerPolicy: props.referrerPolicy, + media: props.media, + }); + return; + } + } } export function getChildFormatContext( @@ -73,5 +188,18 @@ export function getChildFormatContext( type: string, props: Object, ): FormatContext { - return parentContext; + switch (type) { + case 'img': + processImg(props, parentContext); + return parentContext; + case 'link': + processLink(props, parentContext); + return parentContext; + case 'picture': + return parentContext | PICTURE_SCOPE; + case 'noscript': + return parentContext | NOSCRIPT_SCOPE; + default: + return parentContext; + } } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 9754a7f51c3b5..97869cf0bea28 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -47,6 +47,9 @@ describe('ReactFlightDOM', () => { // condition jest.resetModules(); + // Some of the tests pollute the head. + document.head.innerHTML = ''; + JSDOM = require('jsdom').JSDOM; patchSetImmediate(); @@ -1998,6 +2001,105 @@ describe('ReactFlightDOM', () => { expect(hintRows.length).toEqual(6); }); + it('preloads resources without needing to render them', async () => { + function NoScriptComponent() { + return ( +

+ + +

+ ); + } + + function Component() { + return ( +
+ + + + + + + + + + + +
+ ); + } + + const {writable, readable} = getTestStream(); + const {pipe} = await serverAct(() => + ReactServerDOMServer.renderToPipeableStream(, webpackMap), + ); + pipe(writable); + + let response = null; + function getResponse() { + if (response === null) { + response = ReactServerDOMClient.createFromReadableStream(readable); + } + return response; + } + + function App() { + // Not rendered but use for its side-effects. + getResponse(); + return ( + + +

hello world

+ + + ); + } + + const root = ReactDOMClient.createRoot(document); + await act(() => { + root.render(); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +

hello world

+ + , + ); + }); + it('should be able to include a client reference in printed errors', async () => { const reportedErrors = [];