@samitouri / QOS-React-2 / commits / 9d4582dffd

[Float][Fizz][Static] add `importMap` option to Fizz and Static server renderers (#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.

Josh Story committed Aug 24, 2023 at 13:48 UTC 9d4582dffdea5b4dcb6a6093ea848d15423c7701
13 files changed +177 -15
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+37 -3
@@ -15,6 +15,7 @@ import type {
15 PreloadModuleOptions,
16 PreinitOptions,
17 PreinitModuleOptions,
18 + ImportMap,
19 } from 'react-dom/src/shared/ReactDOMTypes';
20
21 import {
@@ -139,6 +140,7 @@ export type RenderState = {
140 // Hoistable chunks
141 charsetChunks: Array<Chunk | PrecomputedChunk>,
142 preconnectChunks: Array<Chunk | PrecomputedChunk>,
143 + importMapChunks: Array<Chunk | PrecomputedChunk>,
144 preloadChunks: Array<Chunk | PrecomputedChunk>,
145 hoistableChunks: Array<Chunk | PrecomputedChunk>,
146
@@ -205,7 +207,7 @@ const scriptCrossOrigin = stringToPrecomputedChunk('" crossorigin="');
207 const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
208
209 /**
208 - * This escaping function is designed to work with bootstrapScriptContent only.
210 + * This escaping function is designed to work with bootstrapScriptContent and importMap only.
211 * because we know we are escaping the entire script. We can avoid for instance
212 * escaping html comment string sequences that are valid javascript as well because
213 * if there are no sebsequent <script sequences the html parser will never enter
@@ -214,7 +216,7 @@ const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
216 * While untrusted script content should be made safe before using this api it will
217 * ensure that the script cannot be early terminated or never terminated state
218 */
217 -function escapeBootstrapScriptContent(scriptText: string) {
219 +function escapeBootstrapAndImportMapScriptContent(scriptText: string) {
220 if (__DEV__) {
221 checkHtmlStringCoercion(scriptText);
222 }
@@ -237,12 +239,19 @@ export type ExternalRuntimeScript = {
239 src: string,
240 chunks: Array<Chunk | PrecomputedChunk>,
241 };
242 +
243 +const importMapScriptStart = stringToPrecomputedChunk(
244 + '<script type="importmap">',
245 +);
246 +const importMapScriptEnd = stringToPrecomputedChunk('</script>');
247 +
248 // Allows us to keep track of what we've already written so we can refer back to it.
249 // if passed externalRuntimeConfig and the enableFizzExternalRuntime feature flag
250 // is set, the server will send instructions via data attributes (instead of inline scripts)
251 export function createRenderState(
252 resumableState: ResumableState,
253 nonce: string | void,
254 + importMap: ImportMap | void,
255 ): RenderState {
256 const inlineScriptWithNonce =
257 nonce === undefined
@@ -251,6 +260,17 @@ export function createRenderState(
260 '<script nonce="' + escapeTextForBrowser(nonce) + '">',
261 );
262 const idPrefix = resumableState.idPrefix;
263 + const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
264 + if (importMap !== undefined) {
265 + const map = importMap;
266 + importMapChunks.push(importMapScriptStart);
267 + importMapChunks.push(
268 + stringToChunk(
269 + escapeBootstrapAndImportMapScriptContent(JSON.stringify(map)),
270 + ),
271 + );
272 + importMapChunks.push(importMapScriptEnd);
273 + }
274 return {
275 placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
276 segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
@@ -260,6 +280,7 @@ export function createRenderState(
280 headChunks: null,
281 charsetChunks: [],
282 preconnectChunks: [],
283 + importMapChunks,
284 preloadChunks: [],
285 hoistableChunks: [],
286 nonce,
@@ -290,7 +311,9 @@ export function createResumableState(
311 );
312 bootstrapChunks.push(
313 inlineScriptWithNonce,
293 - stringToChunk(escapeBootstrapScriptContent(bootstrapScriptContent)),
314 + stringToChunk(
315 + escapeBootstrapAndImportMapScriptContent(bootstrapScriptContent),
316 + ),
317 endInlineScript,
318 );
319 }
@@ -4342,6 +4365,12 @@ export function writePreamble(
4365 // Flush unblocked stylesheets by precedence
4366 resumableState.precedences.forEach(flushAllStylesInPreamble, destination);
4367
4368 + const importMapChunks = renderState.importMapChunks;
4369 + for (i = 0; i < importMapChunks.length; i++) {
4370 + writeChunk(destination, importMapChunks[i]);
4371 + }
4372 + importMapChunks.length = 0;
4373 +
4374 resumableState.bootstrapScripts.forEach(flushResourceInPreamble, destination);
4375
4376 resumableState.scripts.forEach(flushResourceInPreamble, destination);
@@ -4415,6 +4444,11 @@ export function writeHoistables(
4444 // but we want to kick off preloading as soon as possible
4445 resumableState.precedences.forEach(preloadLateStyles, destination);
4446
4447 + // We only hoist importmaps that are configured through createResponse and that will
4448 + // always flush in the preamble. Generally we don't expect people to render them as
4449 + // tags when using React but if you do they are going to be treated like regular inline
4450 + // scripts and flush after other hoistables which is problematic
4451 +
4452 // bootstrap scripts should flush above script priority but these can only flush in the preamble
4453 // so we elide the code here for performance
4454
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+2
@@ -41,6 +41,7 @@ export type RenderState = {
41 headChunks: null | Array<Chunk | PrecomputedChunk>,
42 charsetChunks: Array<Chunk | PrecomputedChunk>,
43 preconnectChunks: Array<Chunk | PrecomputedChunk>,
44 + importMapChunks: Array<Chunk | PrecomputedChunk>,
45 preloadChunks: Array<Chunk | PrecomputedChunk>,
46 hoistableChunks: Array<Chunk | PrecomputedChunk>,
47 boundaryResources: ?BoundaryResources,
@@ -65,6 +66,7 @@ export function createRenderState(
66 headChunks: renderState.headChunks,
67 charsetChunks: renderState.charsetChunks,
68 preconnectChunks: renderState.preconnectChunks,
69 + importMapChunks: renderState.importMapChunks,
70 preloadChunks: renderState.preloadChunks,
71 hoistableChunks: renderState.hoistableChunks,
72 boundaryResources: renderState.boundaryResources,
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+46 -1
@@ -3623,6 +3623,33 @@ describe('ReactDOMFizzServer', () => {
3623 await waitForAll([]);
3624 });
3625
3626 + it('takes an importMap option which emits an "importmap" script in the head', async () => {
3627 + const importMap = {
3628 + foo: './path/to/foo.js',
3629 + };
3630 + await act(() => {
3631 + renderToPipeableStream(
3632 + <html>
3633 + <head>
3634 + <script async={true} src="foo" />
3635 + </head>
3636 + <body>
3637 + <div>hello world</div>
3638 + </body>
3639 + </html>,
3640 + {
3641 + importMap,
3642 + },
3643 + ).pipe(writable);
3644 + });
3645 +
3646 + expect(document.head.innerHTML).toBe(
3647 + '<script type="importmap">' +
3648 + JSON.stringify(importMap) +
3649 + '</script><script async="" src="foo"></script>',
3650 + );
3651 + });
3652 +
3653 describe('error escaping', () => {
3654 it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
3655 window.__outlet = {};
@@ -3949,7 +3976,7 @@ describe('ReactDOMFizzServer', () => {
3976 ]);
3977 });
3978
3952 - describe('bootstrapScriptContent escaping', () => {
3979 + describe('bootstrapScriptContent and importMap escaping', () => {
3980 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 () => {
3981 window.__test_outlet = '';
3982 const stringWithScriptsInIt =
@@ -4005,6 +4032,24 @@ describe('ReactDOMFizzServer', () => {
4032 });
4033 expect(window.__test_outlet).toBe(1);
4034 });
4035 +
4036 + it('escapes </[sS]cirpt> in importMaps', async () => {
4037 + window.__test_outlet_key = '';
4038 + window.__test_outlet_value = '';
4039 + const jsonWithScriptsInIt = {
4040 + "keypos</script><script>window.__test_outlet_key = 'pwned'</script><script>":
4041 + 'value',
4042 + key: "valuepos</script><script>window.__test_outlet_value = 'pwned'</script><script>",
4043 + };
4044 + await act(() => {
4045 + const {pipe} = renderToPipeableStream(<div />, {
4046 + importMap: jsonWithScriptsInIt,
4047 + });
4048 + pipe(writable);
4049 + });
4050 + expect(window.__test_outlet_key).toBe('');
4051 + expect(window.__test_outlet_value).toBe('');
4052 + });
4053 });
4054
4055 // @gate enableFizzExternalRuntime
packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
+26 -1
@@ -84,6 +84,10 @@ describe('ReactDOMFizzStatic', () => {
84 if (node.nodeName === 'SCRIPT') {
85 const script = document.createElement('script');
86 script.textContent = node.textContent;
87 + for (let i = 0; i < node.attributes.length; i++) {
88 + const attribute = node.attributes[i];
89 + script.setAttribute(attribute.name, attribute.value);
90 + }
91 fakeBody.removeChild(node);
92 container.appendChild(script);
93 } else {
@@ -98,7 +102,7 @@ describe('ReactDOMFizzStatic', () => {
102 while (node) {
103 if (node.nodeType === 1) {
104 if (
101 - node.tagName !== 'SCRIPT' &&
105 + (node.tagName !== 'SCRIPT' || node.hasAttribute('type')) &&
106 node.tagName !== 'TEMPLATE' &&
107 node.tagName !== 'template' &&
108 !node.hasAttribute('hidden') &&
@@ -237,4 +241,25 @@ describe('ReactDOMFizzStatic', () => {
241
242 expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
243 });
244 +
245 + // @gate experimental
246 + it('should support importMap option', async () => {
247 + const importMap = {
248 + foo: 'path/to/foo.js',
249 + };
250 + const result = await ReactDOMFizzStatic.prerenderToNodeStream(
251 + <html>
252 + <body>hello world</body>
253 + </html>,
254 + {importMap},
255 + );
256 +
257 + await act(async () => {
258 + result.prelude.pipe(writable);
259 + });
260 + expect(getVisibleChildren(container)).toEqual([
261 + <script type="importmap">{JSON.stringify(importMap)}</script>,
262 + 'hello world',
263 + ]);
264 + });
265 });
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+8 -1
@@ -10,6 +10,7 @@
10 import type {PostponedState} from 'react-server/src/ReactFizzServer';
11 import type {ReactNodeList} from 'shared/ReactTypes';
12 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
13 +import type {ImportMap} from '../shared/ReactDOMTypes';
14
15 import ReactVersion from 'shared/ReactVersion';
16
@@ -38,6 +39,7 @@ type Options = {
39 onError?: (error: mixed) => ?string,
40 onPostpone?: (reason: string) => void,
41 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
42 + importMap?: ImportMap,
43 };
44
45 type ResumeOptions = {
@@ -101,7 +103,11 @@ function renderToReadableStream(
103 const request = createRequest(
104 children,
105 resumableState,
104 - createRenderState(resumableState, options ? options.nonce : undefined),
106 + createRenderState(
107 + resumableState,
108 + options ? options.nonce : undefined,
109 + options ? options.importMap : undefined,
110 + ),
111 createRootFormatContext(options ? options.namespaceURI : undefined),
112 options ? options.progressiveChunkSize : undefined,
113 options ? options.onError : undefined,
@@ -171,6 +177,7 @@ function resume(
177 createRenderState(
178 postponedState.resumableState,
179 options ? options.nonce : undefined,
180 + undefined, // importMap
181 ),
182 postponedState.rootFormatContext,
183 postponedState.progressiveChunkSize,
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+7 -1
@@ -9,6 +9,7 @@
9
10 import type {ReactNodeList} from 'shared/ReactTypes';
11 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12 +import type {ImportMap} from '../shared/ReactDOMTypes';
13
14 import ReactVersion from 'shared/ReactVersion';
15
@@ -37,6 +38,7 @@ type Options = {
38 onError?: (error: mixed) => ?string,
39 onPostpone?: (reason: string) => void,
40 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41 + importMap?: ImportMap,
42 };
43
44 // TODO: Move to sub-classing ReadableStream.
@@ -93,7 +95,11 @@ function renderToReadableStream(
95 const request = createRequest(
96 children,
97 resumableState,
96 - createRenderState(resumableState, options ? options.nonce : undefined),
98 + createRenderState(
99 + resumableState,
100 + options ? options.nonce : undefined,
101 + options ? options.importMap : undefined,
102 + ),
103 createRootFormatContext(options ? options.namespaceURI : undefined),
104 options ? options.progressiveChunkSize : undefined,
105 options ? options.onError : undefined,
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+8 -1
@@ -10,6 +10,7 @@
10 import type {PostponedState} from 'react-server/src/ReactFizzServer';
11 import type {ReactNodeList} from 'shared/ReactTypes';
12 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
13 +import type {ImportMap} from '../shared/ReactDOMTypes';
14
15 import ReactVersion from 'shared/ReactVersion';
16
@@ -38,6 +39,7 @@ type Options = {
39 onError?: (error: mixed) => ?string,
40 onPostpone?: (reason: string) => void,
41 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
42 + importMap?: ImportMap,
43 };
44
45 type ResumeOptions = {
@@ -101,7 +103,11 @@ function renderToReadableStream(
103 const request = createRequest(
104 children,
105 resumableState,
104 - createRenderState(resumableState, options ? options.nonce : undefined),
106 + createRenderState(
107 + resumableState,
108 + options ? options.nonce : undefined,
109 + options ? options.importMap : undefined,
110 + ),
111 createRootFormatContext(options ? options.namespaceURI : undefined),
112 options ? options.progressiveChunkSize : undefined,
113 options ? options.onError : undefined,
@@ -171,6 +177,7 @@ function resume(
177 createRenderState(
178 postponedState.resumableState,
179 options ? options.nonce : undefined,
180 + undefined, // importMap
181 ),
182 postponedState.rootFormatContext,
183 postponedState.progressiveChunkSize,
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+8 -1
@@ -12,6 +12,7 @@ import type {ReactNodeList} from 'shared/ReactTypes';
12 import type {Writable} from 'stream';
13 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
14 import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
15 +import type {ImportMap} from '../shared/ReactDOMTypes';
16
17 import ReactVersion from 'shared/ReactVersion';
18
@@ -51,6 +52,7 @@ type Options = {
52 onError?: (error: mixed) => ?string,
53 onPostpone?: (reason: string) => void,
54 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
55 + importMap?: ImportMap,
56 };
57
58 type ResumeOptions = {
@@ -81,7 +83,11 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
83 return createRequest(
84 children,
85 resumableState,
84 - createRenderState(resumableState, options ? options.nonce : undefined),
86 + createRenderState(
87 + resumableState,
88 + options ? options.nonce : undefined,
89 + options ? options.importMap : undefined,
90 + ),
91 createRootFormatContext(options ? options.namespaceURI : undefined),
92 options ? options.progressiveChunkSize : undefined,
93 options ? options.onError : undefined,
@@ -140,6 +146,7 @@ function resumeRequestImpl(
146 createRenderState(
147 postponedState.resumableState,
148 options ? options.nonce : undefined,
149 + undefined, // importMap
150 ),
151 postponedState.rootFormatContext,
152 postponedState.progressiveChunkSize,
packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js
+7 -1
@@ -10,6 +10,7 @@
10 import type {ReactNodeList} from 'shared/ReactTypes';
11 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12 import type {PostponedState} from 'react-server/src/ReactFizzServer';
13 +import type {ImportMap} from '../shared/ReactDOMTypes';
14
15 import ReactVersion from 'shared/ReactVersion';
16
@@ -38,6 +39,7 @@ type Options = {
39 onError?: (error: mixed) => ?string,
40 onPostpone?: (reason: string) => void,
41 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
42 + importMap?: ImportMap,
43 };
44
45 type StaticResult = {
@@ -81,7 +83,11 @@ function prerender(
83 const request = createRequest(
84 children,
85 resources,
84 - createRenderState(resources, undefined),
86 + createRenderState(
87 + resources,
88 + undefined, // nonce
89 + options ? options.importMap : undefined,
90 + ),
91 createRootFormatContext(options ? options.namespaceURI : undefined),
92 options ? options.progressiveChunkSize : undefined,
93 options ? options.onError : undefined,
packages/react-dom/src/server/ReactDOMFizzStaticEdge.js
+7 -1
@@ -10,6 +10,7 @@
10 import type {ReactNodeList} from 'shared/ReactTypes';
11 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12 import type {PostponedState} from 'react-server/src/ReactFizzServer';
13 +import type {ImportMap} from '../shared/ReactDOMTypes';
14
15 import ReactVersion from 'shared/ReactVersion';
16
@@ -38,6 +39,7 @@ type Options = {
39 onError?: (error: mixed) => ?string,
40 onPostpone?: (reason: string) => void,
41 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
42 + importMap?: ImportMap,
43 };
44
45 type StaticResult = {
@@ -81,7 +83,11 @@ function prerender(
83 const request = createRequest(
84 children,
85 resources,
84 - createRenderState(resources, undefined),
86 + createRenderState(
87 + resources,
88 + undefined, // nonce
89 + options ? options.importMap : undefined,
90 + ),
91 createRootFormatContext(options ? options.namespaceURI : undefined),
92 options ? options.progressiveChunkSize : undefined,
93 options ? options.onError : undefined,
packages/react-dom/src/server/ReactDOMFizzStaticNode.js
+7 -1
@@ -10,6 +10,7 @@
10 import type {ReactNodeList} from 'shared/ReactTypes';
11 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12 import type {PostponedState} from 'react-server/src/ReactFizzServer';
13 +import type {ImportMap} from '../shared/ReactDOMTypes';
14
15 import {Writable, Readable} from 'stream';
16
@@ -40,6 +41,7 @@ type Options = {
41 onError?: (error: mixed) => ?string,
42 onPostpone?: (reason: string) => void,
43 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
44 + importMap?: ImportMap,
45 };
46
47 type StaticResult = {
@@ -95,7 +97,11 @@ function prerenderToNodeStream(
97 const request = createRequest(
98 children,
99 resumableState,
98 - createRenderState(resumableState, undefined),
100 + createRenderState(
101 + resumableState,
102 + undefined, // nonce
103 + options ? options.importMap : undefined,
104 + ),
105 createRootFormatContext(options ? options.namespaceURI : undefined),
106 options ? options.progressiveChunkSize : undefined,
107 options ? options.onError : undefined,
packages/react-dom/src/shared/ReactDOMTypes.js
+11
@@ -47,3 +47,14 @@ export type HostDispatcher = {
47 preinit: (href: string, options: PreinitOptions) => void,
48 preinitModule: (href: string, options?: ?PreinitModuleOptions) => void,
49 };
50 +
51 +export type ImportMap = {
52 + imports?: {
53 + [specifier: string]: string,
54 + },
55 + scopes?: {
56 + [scope: string]: {
57 + [specifier: string]: string,
58 + },
59 + },
60 +};
packages/react-dom/src/test-utils/FizzTestUtils.js
+3 -3
@@ -104,9 +104,9 @@ async function executeScript(script: Element) {
104 const newScript = ownerDocument.createElement('script');
105 newScript.textContent = script.textContent;
106 // make sure to add nonce back to script if it exists
107 - const scriptNonce = script.getAttribute('nonce');
108 - if (scriptNonce) {
109 - newScript.setAttribute('nonce', scriptNonce);
107 + for (let i = 0; i < script.attributes.length; i++) {
108 + const attribute = script.attributes[i];
109 + newScript.setAttribute(attribute.name, attribute.value);
110 }
111
112 parent.insertBefore(newScript, script);