From 58b402fecca9fa90ee9b2d1cb6044d697b61a9e9 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 21 Aug 2023 12:17:11 -0700 Subject: [PATCH] Import maps need to be emitted before any scripts or preloads so the browser can properly locate these resources. This change makes React aware of the concept of import maps and emits them before scripts and modules and their preloads. --- .../src/server/ReactFizzConfigDOM.js | 40 ++++++++++++++-- .../src/server/ReactFizzConfigDOMLegacy.js | 2 + .../src/__tests__/ReactDOMFizzServer-test.js | 47 ++++++++++++++++++- .../src/__tests__/ReactDOMFizzStatic-test.js | 27 ++++++++++- .../src/server/ReactDOMFizzServerBrowser.js | 9 +++- .../src/server/ReactDOMFizzServerBun.js | 8 +++- .../src/server/ReactDOMFizzServerEdge.js | 9 +++- .../src/server/ReactDOMFizzServerNode.js | 9 +++- .../src/server/ReactDOMFizzStaticBrowser.js | 8 +++- .../src/server/ReactDOMFizzStaticEdge.js | 8 +++- .../src/server/ReactDOMFizzStaticNode.js | 8 +++- .../react-dom/src/shared/ReactDOMTypes.js | 11 +++++ .../react-dom/src/test-utils/FizzTestUtils.js | 6 +-- 13 files changed, 177 insertions(+), 15 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 4a3c2d157dc..0eb1c51f726 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -15,6 +15,7 @@ import type { PreloadModuleOptions, PreinitOptions, PreinitModuleOptions, + ImportMap, } from 'react-dom/src/shared/ReactDOMTypes'; import { @@ -139,6 +140,7 @@ export type RenderState = { // Hoistable chunks charsetChunks: Array, preconnectChunks: Array, + importMapChunks: Array, preloadChunks: Array, hoistableChunks: Array, @@ -205,7 +207,7 @@ const scriptCrossOrigin = stringToPrecomputedChunk('" crossorigin="'); const endAsyncScript = stringToPrecomputedChunk('" async="">'); /** - * 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 '); * 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); } @@ -237,12 +239,19 @@ export type ExternalRuntimeScript = { src: string, chunks: Array, }; + +const importMapScriptStart = stringToPrecomputedChunk( + ''); + // 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 @@ -251,6 +260,17 @@ export function createRenderState( '', + ); + }); + describe('error escaping', () => { it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => { window.__outlet = {}; @@ -3949,7 +3976,7 @@ describe('ReactDOMFizzServer', () => { ]); }); - describe('bootstrapScriptContent escaping', () => { + describe('bootstrapScriptContent and importMap escaping', () => { it('the "S" in " { window.__test_outlet = ''; const stringWithScriptsInIt = @@ -4005,6 +4032,24 @@ describe('ReactDOMFizzServer', () => { }); expect(window.__test_outlet).toBe(1); }); + + it('escapes in importMaps', async () => { + window.__test_outlet_key = ''; + window.__test_outlet_value = ''; + const jsonWithScriptsInIt = { + "keypos, + 'hello world', + ]); + }); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 1ce1150423e..0de26739b56 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -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'; @@ -38,6 +39,7 @@ type Options = { onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + importMap?: ImportMap, }; type ResumeOptions = { @@ -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, @@ -171,6 +177,7 @@ function resume( createRenderState( postponedState.resumableState, options ? options.nonce : undefined, + undefined, // importMap ), postponedState.rootFormatContext, postponedState.progressiveChunkSize, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js index e3800b7debc..997934e1a3d 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js @@ -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'; @@ -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. @@ -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, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js index 1ce1150423e..0de26739b56 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js @@ -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'; @@ -38,6 +39,7 @@ type Options = { onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + importMap?: ImportMap, }; type ResumeOptions = { @@ -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, @@ -171,6 +177,7 @@ function resume( createRenderState( postponedState.resumableState, options ? options.nonce : undefined, + undefined, // importMap ), postponedState.rootFormatContext, postponedState.progressiveChunkSize, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index ca4853a1533..89332ef5dc7 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -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'; @@ -51,6 +52,7 @@ type Options = { onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + importMap?: ImportMap, }; type ResumeOptions = { @@ -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, @@ -140,6 +146,7 @@ function resumeRequestImpl( createRenderState( postponedState.resumableState, options ? options.nonce : undefined, + undefined, // importMap ), postponedState.rootFormatContext, postponedState.progressiveChunkSize, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index de603b20ac8..0e03530b2d8 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -10,6 +10,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {PostponedState} from 'react-server/src/ReactFizzServer'; +import type {ImportMap} from '../shared/ReactDOMTypes'; import ReactVersion from 'shared/ReactVersion'; @@ -38,6 +39,7 @@ type Options = { onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + importMap?: ImportMap, }; type StaticResult = { @@ -81,7 +83,11 @@ function prerender( const request = createRequest( children, resources, - createRenderState(resources, undefined), + createRenderState( + resources, + undefined, // nonce + options ? options.importMap : undefined, + ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js index de603b20ac8..0e03530b2d8 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticEdge.js @@ -10,6 +10,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {PostponedState} from 'react-server/src/ReactFizzServer'; +import type {ImportMap} from '../shared/ReactDOMTypes'; import ReactVersion from 'shared/ReactVersion'; @@ -38,6 +39,7 @@ type Options = { onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + importMap?: ImportMap, }; type StaticResult = { @@ -81,7 +83,11 @@ function prerender( const request = createRequest( children, resources, - createRenderState(resources, undefined), + createRenderState( + resources, + undefined, // nonce + options ? options.importMap : undefined, + ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index d22e1ea2d06..d538eb9f81c 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -10,6 +10,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type {PostponedState} from 'react-server/src/ReactFizzServer'; +import type {ImportMap} from '../shared/ReactDOMTypes'; import {Writable, Readable} from 'stream'; @@ -40,6 +41,7 @@ type Options = { onError?: (error: mixed) => ?string, onPostpone?: (reason: string) => void, unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, + importMap?: ImportMap, }; type StaticResult = { @@ -95,7 +97,11 @@ function prerenderToNodeStream( const request = createRequest( children, resumableState, - createRenderState(resumableState, undefined), + createRenderState( + resumableState, + undefined, // nonce + options ? options.importMap : undefined, + ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, options ? options.onError : undefined, diff --git a/packages/react-dom/src/shared/ReactDOMTypes.js b/packages/react-dom/src/shared/ReactDOMTypes.js index 9b8bf37c04d..3e8c7d1a79d 100644 --- a/packages/react-dom/src/shared/ReactDOMTypes.js +++ b/packages/react-dom/src/shared/ReactDOMTypes.js @@ -47,3 +47,14 @@ export type HostDispatcher = { preinit: (href: string, options: PreinitOptions) => void, preinitModule: (href: string, options?: ?PreinitModuleOptions) => void, }; + +export type ImportMap = { + imports?: { + [specifier: string]: string, + }, + scopes?: { + [scope: string]: { + [specifier: string]: string, + }, + }, +}; diff --git a/packages/react-dom/src/test-utils/FizzTestUtils.js b/packages/react-dom/src/test-utils/FizzTestUtils.js index a10bec7fa0d..545743a6445 100644 --- a/packages/react-dom/src/test-utils/FizzTestUtils.js +++ b/packages/react-dom/src/test-utils/FizzTestUtils.js @@ -104,9 +104,9 @@ async function executeScript(script: Element) { const newScript = ownerDocument.createElement('script'); newScript.textContent = script.textContent; // make sure to add nonce back to script if it exists - const scriptNonce = script.getAttribute('nonce'); - if (scriptNonce) { - newScript.setAttribute('nonce', scriptNonce); + for (let i = 0; i < script.attributes.length; i++) { + const attribute = script.attributes[i]; + newScript.setAttribute(attribute.name, attribute.value); } parent.insertBefore(newScript, script);