Skip to content

Commit

Permalink
[Float][Fizz][Static] add importMap option to Fizz and Static serve…
Browse files Browse the repository at this point in the history
…r renderers (facebook#27260)

Import maps need to be emitted before any scripts or preloads so the
browser can properly locate these resources.

Unlike most scripts, importmaps are singletons meaning you can only have
one per document and they must appear before any modules are loaded or
preloaded. In the future there may be a way to dynamically add more
mappings however the proposed API for this seems likely to be a
javascript API and not an html tag.

Given the unique constraints here this PR implements React's support of
importMaps as the following

1. an `importMap` option accepting a plain object mapping module
specifier to path is accepted in any API that renders a preamble (head
content). Notably this precludes resume rendering because in resume
cases the preamble should have already been produced as part of the
prerender step.
2. the importMap is stringified and emitted as a `<script
type="importmap">...</script>` in the preamble.
3. the importMap is escaped identically to how bootstrapScriptContent is
escaped, notably, isntances of `</script>` are escaped to avoid breaking
out of the script context

Users can still render importmap tags however with Float enabled this is
rather pointless as most modules will be hoisted above the importmap
that is rendered. In practice this means the only functional way to use
import maps with React is to use this config API.
  • Loading branch information
gnoff authored and AndyPengc12 committed Apr 15, 2024
1 parent f1aa04b commit 41db22f
Show file tree
Hide file tree
Showing 13 changed files with 177 additions and 15 deletions.
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

0 comments on commit 41db22f

Please sign in to comment.