Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Float][Fizz][Static] add importMap option to Fizz and Static server renderers #27260

Merged
merged 1 commit into from
Aug 24, 2023
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
40 changes: 37 additions & 3 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
PreloadModuleOptions,
PreinitOptions,
PreinitModuleOptions,
ImportMap,
} from 'react-dom/src/shared/ReactDOMTypes';

import {
Expand Down Expand Up @@ -139,6 +140,7 @@ export type RenderState = {
// Hoistable chunks
charsetChunks: Array<Chunk | PrecomputedChunk>,
preconnectChunks: Array<Chunk | PrecomputedChunk>,
importMapChunks: Array<Chunk | PrecomputedChunk>,
preloadChunks: Array<Chunk | PrecomputedChunk>,
hoistableChunks: Array<Chunk | PrecomputedChunk>,

Expand Down Expand Up @@ -205,7 +207,7 @@ const scriptCrossOrigin = stringToPrecomputedChunk('" crossorigin="');
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');

/**
* This escaping function is designed to work with bootstrapScriptContent only.
* This escaping function is designed to work with bootstrapScriptContent and importMap only.
* because we know we are escaping the entire script. We can avoid for instance
* escaping html comment string sequences that are valid javascript as well because
* if there are no sebsequent <script sequences the html parser will never enter
Expand All @@ -214,7 +216,7 @@ const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
* While untrusted script content should be made safe before using this api it will
* ensure that the script cannot be early terminated or never terminated state
*/
function escapeBootstrapScriptContent(scriptText: string) {
function escapeBootstrapAndImportMapScriptContent(scriptText: string) {
if (__DEV__) {
checkHtmlStringCoercion(scriptText);
}
Expand All @@ -237,12 +239,19 @@ export type ExternalRuntimeScript = {
src: string,
chunks: Array<Chunk | PrecomputedChunk>,
};

const importMapScriptStart = stringToPrecomputedChunk(
'<script type="importmap">',
);
const importMapScriptEnd = stringToPrecomputedChunk('</script>');

// Allows us to keep track of what we've already written so we can refer back to it.
// if passed externalRuntimeConfig and the enableFizzExternalRuntime feature flag
// is set, the server will send instructions via data attributes (instead of inline scripts)
export function createRenderState(
resumableState: ResumableState,
nonce: string | void,
importMap: ImportMap | void,
): RenderState {
const inlineScriptWithNonce =
nonce === undefined
Expand All @@ -251,6 +260,17 @@ export function createRenderState(
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
);
const idPrefix = resumableState.idPrefix;
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
stringToChunk(
escapeBootstrapAndImportMapScriptContent(JSON.stringify(map)),
),
);
importMapChunks.push(importMapScriptEnd);
}
return {
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
Expand All @@ -260,6 +280,7 @@ export function createRenderState(
headChunks: null,
charsetChunks: [],
preconnectChunks: [],
importMapChunks,
preloadChunks: [],
hoistableChunks: [],
nonce,
Expand Down Expand Up @@ -290,7 +311,9 @@ export function createResumableState(
);
bootstrapChunks.push(
inlineScriptWithNonce,
stringToChunk(escapeBootstrapScriptContent(bootstrapScriptContent)),
stringToChunk(
escapeBootstrapAndImportMapScriptContent(bootstrapScriptContent),
),
endInlineScript,
);
}
Expand Down Expand Up @@ -4342,6 +4365,12 @@ export function writePreamble(
// Flush unblocked stylesheets by precedence
resumableState.precedences.forEach(flushAllStylesInPreamble, destination);

const importMapChunks = renderState.importMapChunks;
for (i = 0; i < importMapChunks.length; i++) {
writeChunk(destination, importMapChunks[i]);
}
importMapChunks.length = 0;

resumableState.bootstrapScripts.forEach(flushResourceInPreamble, destination);

resumableState.scripts.forEach(flushResourceInPreamble, destination);
Expand Down Expand Up @@ -4415,6 +4444,11 @@ export function writeHoistables(
// but we want to kick off preloading as soon as possible
resumableState.precedences.forEach(preloadLateStyles, destination);

// We only hoist importmaps that are configured through createResponse and that will
// always flush in the preamble. Generally we don't expect people to render them as
// tags when using React but if you do they are going to be treated like regular inline
// scripts and flush after other hoistables which is problematic

// bootstrap scripts should flush above script priority but these can only flush in the preamble
// so we elide the code here for performance

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type RenderState = {
headChunks: null | Array<Chunk | PrecomputedChunk>,
charsetChunks: Array<Chunk | PrecomputedChunk>,
preconnectChunks: Array<Chunk | PrecomputedChunk>,
importMapChunks: Array<Chunk | PrecomputedChunk>,
preloadChunks: Array<Chunk | PrecomputedChunk>,
hoistableChunks: Array<Chunk | PrecomputedChunk>,
boundaryResources: ?BoundaryResources,
Expand All @@ -65,6 +66,7 @@ export function createRenderState(
headChunks: renderState.headChunks,
charsetChunks: renderState.charsetChunks,
preconnectChunks: renderState.preconnectChunks,
importMapChunks: renderState.importMapChunks,
preloadChunks: renderState.preloadChunks,
hoistableChunks: renderState.hoistableChunks,
boundaryResources: renderState.boundaryResources,
Expand Down
47 changes: 46 additions & 1 deletion packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3623,6 +3623,33 @@ describe('ReactDOMFizzServer', () => {
await waitForAll([]);
});

it('takes an importMap option which emits an "importmap" script in the head', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>',
);
});

describe('error escaping', () => {
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
window.__outlet = {};
Expand Down Expand Up @@ -3949,7 +3976,7 @@ describe('ReactDOMFizzServer', () => {
]);
});

describe('bootstrapScriptContent escaping', () => {
describe('bootstrapScriptContent and importMap escaping', () => {
it('the "S" in "</?[Ss]cript" strings are replaced with unicode escaped lowercase s or S depending on case, preserving case sensitivity of nearby characters', async () => {
window.__test_outlet = '';
const stringWithScriptsInIt =
Expand Down Expand Up @@ -4005,6 +4032,24 @@ describe('ReactDOMFizzServer', () => {
});
expect(window.__test_outlet).toBe(1);
});

it('escapes </[sS]cirpt> in importMaps', async () => {
window.__test_outlet_key = '';
window.__test_outlet_value = '';
const jsonWithScriptsInIt = {
"keypos</script><script>window.__test_outlet_key = 'pwned'</script><script>":
'value',
key: "valuepos</script><script>window.__test_outlet_value = 'pwned'</script><script>",
};
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
importMap: jsonWithScriptsInIt,
});
pipe(writable);
});
expect(window.__test_outlet_key).toBe('');
expect(window.__test_outlet_value).toBe('');
});
});

// @gate enableFizzExternalRuntime
Expand Down
27 changes: 26 additions & 1 deletion packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ describe('ReactDOMFizzStatic', () => {
if (node.nodeName === 'SCRIPT') {
const script = document.createElement('script');
script.textContent = node.textContent;
for (let i = 0; i < node.attributes.length; i++) {
const attribute = node.attributes[i];
script.setAttribute(attribute.name, attribute.value);
}
fakeBody.removeChild(node);
container.appendChild(script);
} else {
Expand All @@ -98,7 +102,7 @@ describe('ReactDOMFizzStatic', () => {
while (node) {
if (node.nodeType === 1) {
if (
node.tagName !== 'SCRIPT' &&
(node.tagName !== 'SCRIPT' || node.hasAttribute('type')) &&
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
Expand Down Expand Up @@ -237,4 +241,25 @@ describe('ReactDOMFizzStatic', () => {

expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
});

// @gate experimental
it('should support importMap option', async () => {
const importMap = {
foo: 'path/to/foo.js',
};
const result = await ReactDOMFizzStatic.prerenderToNodeStream(
<html>
<body>hello world</body>
</html>,
{importMap},
);

await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual([
<script type="importmap">{JSON.stringify(importMap)}</script>,
'hello world',
]);
});
});
9 changes: 8 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type {PostponedState} from 'react-server/src/ReactFizzServer';
import type {ReactNodeList} from 'shared/ReactTypes';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {ImportMap} from '../shared/ReactDOMTypes';

import ReactVersion from 'shared/ReactVersion';

Expand Down Expand Up @@ -38,6 +39,7 @@ type Options = {
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
};

type ResumeOptions = {
Expand Down Expand Up @@ -101,7 +103,11 @@ function renderToReadableStream(
const request = createRequest(
children,
resumableState,
createRenderState(resumableState, options ? options.nonce : undefined),
createRenderState(
resumableState,
options ? options.nonce : undefined,
options ? options.importMap : undefined,
),
createRootFormatContext(options ? options.namespaceURI : undefined),
options ? options.progressiveChunkSize : undefined,
options ? options.onError : undefined,
Expand Down Expand Up @@ -171,6 +177,7 @@ function resume(
createRenderState(
postponedState.resumableState,
options ? options.nonce : undefined,
undefined, // importMap
),
postponedState.rootFormatContext,
postponedState.progressiveChunkSize,
Expand Down
8 changes: 7 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBun.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import type {ReactNodeList} from 'shared/ReactTypes';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {ImportMap} from '../shared/ReactDOMTypes';

import ReactVersion from 'shared/ReactVersion';

Expand Down Expand Up @@ -37,6 +38,7 @@ type Options = {
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
};

// TODO: Move to sub-classing ReadableStream.
Expand Down Expand Up @@ -93,7 +95,11 @@ function renderToReadableStream(
const request = createRequest(
children,
resumableState,
createRenderState(resumableState, options ? options.nonce : undefined),
createRenderState(
resumableState,
options ? options.nonce : undefined,
options ? options.importMap : undefined,
),
createRootFormatContext(options ? options.namespaceURI : undefined),
options ? options.progressiveChunkSize : undefined,
options ? options.onError : undefined,
Expand Down
9 changes: 8 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerEdge.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type {PostponedState} from 'react-server/src/ReactFizzServer';
import type {ReactNodeList} from 'shared/ReactTypes';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {ImportMap} from '../shared/ReactDOMTypes';

import ReactVersion from 'shared/ReactVersion';

Expand Down Expand Up @@ -38,6 +39,7 @@ type Options = {
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
};

type ResumeOptions = {
Expand Down Expand Up @@ -101,7 +103,11 @@ function renderToReadableStream(
const request = createRequest(
children,
resumableState,
createRenderState(resumableState, options ? options.nonce : undefined),
createRenderState(
resumableState,
options ? options.nonce : undefined,
options ? options.importMap : undefined,
),
createRootFormatContext(options ? options.namespaceURI : undefined),
options ? options.progressiveChunkSize : undefined,
options ? options.onError : undefined,
Expand Down Expand Up @@ -171,6 +177,7 @@ function resume(
createRenderState(
postponedState.resumableState,
options ? options.nonce : undefined,
undefined, // importMap
),
postponedState.rootFormatContext,
postponedState.progressiveChunkSize,
Expand Down
9 changes: 8 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {ReactNodeList} from 'shared/ReactTypes';
import type {Writable} from 'stream';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
import type {ImportMap} from '../shared/ReactDOMTypes';

import ReactVersion from 'shared/ReactVersion';

Expand Down Expand Up @@ -51,6 +52,7 @@ type Options = {
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
};

type ResumeOptions = {
Expand Down Expand Up @@ -81,7 +83,11 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
return createRequest(
children,
resumableState,
createRenderState(resumableState, options ? options.nonce : undefined),
createRenderState(
resumableState,
options ? options.nonce : undefined,
options ? options.importMap : undefined,
),
createRootFormatContext(options ? options.namespaceURI : undefined),
options ? options.progressiveChunkSize : undefined,
options ? options.onError : undefined,
Expand Down Expand Up @@ -140,6 +146,7 @@ function resumeRequestImpl(
createRenderState(
postponedState.resumableState,
options ? options.nonce : undefined,
undefined, // importMap
),
postponedState.rootFormatContext,
postponedState.progressiveChunkSize,
Expand Down
Loading