[Float] Support script preloads (#25432)

* support script preloads

* gates
This commit is contained in:
Josh Story 2022-10-05 09:47:35 -07:00 committed by GitHub
parent 65b3449c89
commit 618388bc32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 147 additions and 51 deletions

View File

@ -29,7 +29,7 @@ import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostCo
// The resource types we support. currently they match the form for the as argument. // The resource types we support. currently they match the form for the as argument.
// In the future this may need to change, especially when modules / scripts are supported // In the future this may need to change, especially when modules / scripts are supported
type ResourceType = 'style' | 'font'; type ResourceType = 'style' | 'font' | 'script';
type PreloadProps = { type PreloadProps = {
rel: 'preload', rel: 'preload',
@ -150,7 +150,7 @@ function getDocumentFromRoot(root: FloatRoot): Document {
// ReactDOM.Preload // ReactDOM.Preload
// -------------------------------------- // --------------------------------------
type PreloadAs = ResourceType; type PreloadAs = ResourceType;
type PreloadOptions = {as: PreloadAs, crossOrigin?: string}; type PreloadOptions = {as: PreloadAs, crossOrigin?: string, integrity?: string};
function preload(href: string, options: PreloadOptions) { function preload(href: string, options: PreloadOptions) {
if (__DEV__) { if (__DEV__) {
validatePreloadArguments(href, options); validatePreloadArguments(href, options);
@ -194,6 +194,7 @@ function preloadPropsFromPreloadOptions(
rel: 'preload', rel: 'preload',
as, as,
crossOrigin: as === 'font' ? '' : options.crossOrigin, crossOrigin: as === 'font' ? '' : options.crossOrigin,
integrity: options.integrity,
}; };
} }
@ -832,7 +833,7 @@ export function isHostResourceType(type: string, props: Props): boolean {
} }
function isResourceAsType(as: mixed): boolean { function isResourceAsType(as: mixed): boolean {
return as === 'style' || as === 'font'; return as === 'style' || as === 'font' || as === 'script';
} }
// When passing user input into querySelector(All) the embedded string must not alter // When passing user input into querySelector(All) the embedded string must not alter

View File

@ -19,7 +19,7 @@ import {
type Props = {[string]: mixed}; type Props = {[string]: mixed};
type ResourceType = 'style' | 'font'; type ResourceType = 'style' | 'font' | 'script';
type PreloadProps = { type PreloadProps = {
rel: 'preload', rel: 'preload',
@ -123,7 +123,7 @@ export const ReactDOMServerDispatcher = {
}; };
type PreloadAs = ResourceType; type PreloadAs = ResourceType;
type PreloadOptions = {as: PreloadAs, crossOrigin?: string}; type PreloadOptions = {as: PreloadAs, crossOrigin?: string, integrity?: string};
function preload(href: string, options: PreloadOptions) { function preload(href: string, options: PreloadOptions) {
if (!currentResources) { if (!currentResources) {
// While we expect that preload calls are primarily going to be observed // While we expect that preload calls are primarily going to be observed
@ -248,6 +248,7 @@ function preloadPropsFromPreloadOptions(
rel: 'preload', rel: 'preload',
as, as,
crossOrigin: as === 'font' ? '' : options.crossOrigin, crossOrigin: as === 'font' ? '' : options.crossOrigin,
integrity: options.integrity,
}; };
} }
@ -526,6 +527,7 @@ export function resourcesFromLink(props: Props): boolean {
return false; return false;
} }
switch (as) { switch (as) {
case 'script':
case 'style': case 'style':
case 'font': { case 'font': {
if (__DEV__) { if (__DEV__) {

View File

@ -16,7 +16,6 @@ export function validateUnmatchedLinkResourceProps(
currentProps: ?Props, currentProps: ?Props,
) { ) {
if (__DEV__) { if (__DEV__) {
if (pendingProps.rel !== 'font' && pendingProps.rel !== 'style') {
if (currentProps != null) { if (currentProps != null) {
const originalResourceName = const originalResourceName =
typeof currentProps.href === 'string' typeof currentProps.href === 'string'
@ -37,7 +36,7 @@ export function validateUnmatchedLinkResourceProps(
' valid for a Resource type. Generally Resources are not expected to ever have updated' + ' valid for a Resource type. Generally Resources are not expected to ever have updated' +
' props however in some limited circumstances it can be valid when changing the href.' + ' props however in some limited circumstances it can be valid when changing the href.' +
' When React encounters props that invalidate the Resource it is the same as not rendering' + ' When React encounters props that invalidate the Resource it is the same as not rendering' +
' a Resource at all. valid rel types for Resources are "font" and "style". The previous' + ' a Resource at all. valid rel types for Resources are "stylesheet" and "preload". The previous' +
' rel for this instance was %s. The updated rel is %s%s.', ' rel for this instance was %s. The updated rel is %s%s.',
originalResourceName, originalResourceName,
originalRelStatement, originalRelStatement,
@ -56,7 +55,6 @@ export function validateUnmatchedLinkResourceProps(
} }
} }
} }
}
export function validatePreloadResourceDifference( export function validatePreloadResourceDifference(
originalProps: any, originalProps: any,
@ -517,6 +515,7 @@ export function validatePreloadArguments(href: mixed, options: mixed) {
} }
break; break;
} }
case 'script':
case 'style': { case 'style': {
break; break;
} }
@ -529,7 +528,7 @@ export function validatePreloadArguments(href: mixed, options: mixed) {
' Please use one of the following valid values instead: %s. The href for the preload call where this' + ' Please use one of the following valid values instead: %s. The href for the preload call where this' +
' warning originated is "%s".', ' warning originated is "%s".',
typeOfAs, typeOfAs,
'"style" and "font"', '"style", "font", or "script"',
href, href,
); );
} }
@ -557,7 +556,6 @@ export function validatePreinitArguments(href: mixed, options: mixed) {
} else { } else {
const as = options.as; const as = options.as;
switch (as) { switch (as) {
case 'font':
case 'style': { case 'style': {
break; break;
} }

View File

@ -270,7 +270,7 @@ describe('ReactDOMFloat', () => {
' valid for a Resource type. Generally Resources are not expected to ever have updated' + ' valid for a Resource type. Generally Resources are not expected to ever have updated' +
' props however in some limited circumstances it can be valid when changing the href.' + ' props however in some limited circumstances it can be valid when changing the href.' +
' When React encounters props that invalidate the Resource it is the same as not rendering' + ' When React encounters props that invalidate the Resource it is the same as not rendering' +
' a Resource at all. valid rel types for Resources are "font" and "style". The previous' + ' a Resource at all. valid rel types for Resources are "stylesheet" and "preload". The previous' +
' rel for this instance was "stylesheet". The updated rel is "author" and the updated href is "bar".', ' rel for this instance was "stylesheet". The updated rel is "author" and the updated href is "bar".',
); );
expect(getVisibleChildren(document)).toEqual( expect(getVisibleChildren(document)).toEqual(
@ -407,6 +407,97 @@ describe('ReactDOMFloat', () => {
</html>, </html>,
); );
}); });
// @gate enableFloat
it('supports script preloads', async () => {
function ServerApp() {
ReactDOM.preload('foo', {as: 'script', integrity: 'foo hash'});
ReactDOM.preload('bar', {
as: 'script',
crossOrigin: 'use-credentials',
integrity: 'bar hash',
});
return (
<html>
<link rel="preload" href="baz" as="script" />
<head>
<title>hi</title>
</head>
<body>foo</body>
</html>
);
}
function ClientApp() {
ReactDOM.preload('foo', {as: 'script', integrity: 'foo hash'});
ReactDOM.preload('qux', {as: 'script'});
return (
<html>
<head>
<title>hi</title>
</head>
<body>foo</body>
<link
rel="preload"
href="quux"
as="script"
crossOrigin=""
integrity="quux hash"
/>
</html>
);
}
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<ServerApp />);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="preload" as="script" href="foo" integrity="foo hash" />
<link
rel="preload"
as="script"
href="bar"
crossorigin="use-credentials"
integrity="bar hash"
/>
<link rel="preload" as="script" href="baz" />
<title>hi</title>
</head>
<body>foo</body>
</html>,
);
ReactDOMClient.hydrateRoot(document, <ClientApp />);
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="preload" as="script" href="foo" integrity="foo hash" />
<link
rel="preload"
as="script"
href="bar"
crossorigin="use-credentials"
integrity="bar hash"
/>
<link rel="preload" as="script" href="baz" />
<title>hi</title>
<link rel="preload" as="script" href="qux" />
<link
rel="preload"
as="script"
href="quux"
crossorigin=""
integrity="quux hash"
/>
</head>
<body>foo</body>
</html>,
);
});
}); });
describe('ReactDOM.preinit as style', () => { describe('ReactDOM.preinit as style', () => {
@ -2885,7 +2976,11 @@ describe('ReactDOMFloat', () => {
(mockError, scenarioNumber) => { (mockError, scenarioNumber) => {
if (__DEV__) { if (__DEV__) {
expect(mockError.mock.calls[scenarioNumber]).toEqual( expect(mockError.mock.calls[scenarioNumber]).toEqual(
makeArgs('undefined', '"style" and "font"', 'foo'), makeArgs(
'undefined',
'"style", "font", or "script"',
'foo',
),
); );
} else { } else {
expect(mockError).not.toHaveBeenCalled(); expect(mockError).not.toHaveBeenCalled();
@ -2898,7 +2993,7 @@ describe('ReactDOMFloat', () => {
(mockError, scenarioNumber) => { (mockError, scenarioNumber) => {
if (__DEV__) { if (__DEV__) {
expect(mockError.mock.calls[scenarioNumber]).toEqual( expect(mockError.mock.calls[scenarioNumber]).toEqual(
makeArgs('null', '"style" and "font"', 'bar'), makeArgs('null', '"style", "font", or "script"', 'bar'),
); );
} else { } else {
expect(mockError).not.toHaveBeenCalled(); expect(mockError).not.toHaveBeenCalled();
@ -2913,7 +3008,7 @@ describe('ReactDOMFloat', () => {
expect(mockError.mock.calls[scenarioNumber]).toEqual( expect(mockError.mock.calls[scenarioNumber]).toEqual(
makeArgs( makeArgs(
'something with type "number"', 'something with type "number"',
'"style" and "font"', '"style", "font", or "script"',
'baz', 'baz',
), ),
); );
@ -2930,7 +3025,7 @@ describe('ReactDOMFloat', () => {
expect(mockError.mock.calls[scenarioNumber]).toEqual( expect(mockError.mock.calls[scenarioNumber]).toEqual(
makeArgs( makeArgs(
'something with type "object"', 'something with type "object"',
'"style" and "font"', '"style", "font", or "script"',
'qux', 'qux',
), ),
); );
@ -2945,7 +3040,7 @@ describe('ReactDOMFloat', () => {
(mockError, scenarioNumber) => { (mockError, scenarioNumber) => {
if (__DEV__) { if (__DEV__) {
expect(mockError.mock.calls[scenarioNumber]).toEqual( expect(mockError.mock.calls[scenarioNumber]).toEqual(
makeArgs('"bar"', '"style" and "font"', 'quux'), makeArgs('"bar"', '"style", "font", or "script"', 'quux'),
); );
} else { } else {
expect(mockError).not.toHaveBeenCalled(); expect(mockError).not.toHaveBeenCalled();