Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
138 changes: 133 additions & 5 deletions packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,16 +64,142 @@ 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', {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stylesheet value used here is incorrect. It should be style.
https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preload#what_types_of_content_can_be_preloaded

Suggested change
preload(href, 'stylesheet', {
preload(href, 'style', {

vercel/next.js#84569

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the report. Fixed in #34760.

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(
parentContext: FormatContext,
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;
}
}
102 changes: 102 additions & 0 deletions packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ describe('ReactFlightDOM', () => {
// condition
jest.resetModules();

// Some of the tests pollute the head.
document.head.innerHTML = '';

JSDOM = require('jsdom').JSDOM;

patchSetImmediate();
Expand Down Expand Up @@ -1998,6 +2001,105 @@ describe('ReactFlightDOM', () => {
expect(hintRows.length).toEqual(6);
});

it('preloads resources without needing to render them', async () => {
function NoScriptComponent() {
return (
<p>
<img src="image-do-not-load" />
<link rel="stylesheet" href="css-do-not-load" />
</p>
);
}

function Component() {
return (
<div>
<img src="image-resource" />
<img
src="image-do-not-load"
srcSet="image-preload-src-set"
sizes="image-sizes"
/>
<img src="image-do-not-load" loading="lazy" />
<link
rel="preload"
href="video-resource"
as="video"
media="(orientation: landscape)"
/>
<link rel="modulepreload" href="module-resource" />
<picture>
<source
srcSet="image-not-yet-preloaded"
media="(orientation: portrait)"
/>
<img src="image-do-not-load" />
</picture>
<noscript>
<NoScriptComponent />
</noscript>
<link rel="stylesheet" href="css-resource" />
</div>
);
}

const {writable, readable} = getTestStream();
const {pipe} = await serverAct(() =>
ReactServerDOMServer.renderToPipeableStream(<Component />, 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 (
<html>
<body>
<p>hello world</p>
</body>
</html>
);
}

const root = ReactDOMClient.createRoot(document);
await act(() => {
root.render(<App />);
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="preload" as="image" href="image-resource" />
<link
rel="preload"
as="image"
imagesrcset="image-preload-src-set"
imagesizes="image-sizes"
/>
<link
rel="preload"
as="video"
href="video-resource"
media="(orientation: landscape)"
/>
<link rel="modulepreload" href="module-resource" />
<link rel="preload" as="stylesheet" href="css-resource" />
</head>
<body>
<p>hello world</p>
</body>
</html>,
);
});

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

Expand Down
Loading