Skip to content

Commit 71bd635

Browse files
committed
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.
1 parent 31034b6 commit 71bd635

12 files changed

+157
-15
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export type RenderState = {
139139
// Hoistable chunks
140140
charsetChunks: Array<Chunk | PrecomputedChunk>,
141141
preconnectChunks: Array<Chunk | PrecomputedChunk>,
142+
importMapChunks: Array<Chunk | PrecomputedChunk>,
142143
preloadChunks: Array<Chunk | PrecomputedChunk>,
143144
hoistableChunks: Array<Chunk | PrecomputedChunk>,
144145

@@ -205,7 +206,7 @@ const scriptCrossOrigin = stringToPrecomputedChunk('" crossorigin="');
205206
const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
206207

207208
/**
208-
* This escaping function is designed to work with bootstrapScriptContent only.
209+
* This escaping function is designed to work with bootstrapScriptContent and importMap only.
209210
* because we know we are escaping the entire script. We can avoid for instance
210211
* escaping html comment string sequences that are valid javascript as well because
211212
* if there are no sebsequent <script sequences the html parser will never enter
@@ -214,7 +215,7 @@ const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
214215
* While untrusted script content should be made safe before using this api it will
215216
* ensure that the script cannot be early terminated or never terminated state
216217
*/
217-
function escapeBootstrapScriptContent(scriptText: string) {
218+
function escapeBootstrapAndImportMapScriptContent(scriptText: string) {
218219
if (__DEV__) {
219220
checkHtmlStringCoercion(scriptText);
220221
}
@@ -237,12 +238,19 @@ export type ExternalRuntimeScript = {
237238
src: string,
238239
chunks: Array<Chunk | PrecomputedChunk>,
239240
};
241+
242+
const importMapScriptStart = stringToPrecomputedChunk(
243+
'<script type="importmap">',
244+
);
245+
const importMapScriptEnd = stringToPrecomputedChunk('</script>');
246+
240247
// Allows us to keep track of what we've already written so we can refer back to it.
241248
// if passed externalRuntimeConfig and the enableFizzExternalRuntime feature flag
242249
// is set, the server will send instructions via data attributes (instead of inline scripts)
243250
export function createRenderState(
244251
resumableState: ResumableState,
245252
nonce: string | void,
253+
importMap: {[string]: string} | void,
246254
): RenderState {
247255
const inlineScriptWithNonce =
248256
nonce === undefined
@@ -251,6 +259,17 @@ export function createRenderState(
251259
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
252260
);
253261
const idPrefix = resumableState.idPrefix;
262+
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
263+
if (importMap !== undefined) {
264+
const map = importMap;
265+
importMapChunks.push(importMapScriptStart);
266+
importMapChunks.push(
267+
stringToChunk(
268+
escapeBootstrapAndImportMapScriptContent(JSON.stringify(map)),
269+
),
270+
);
271+
importMapChunks.push(importMapScriptEnd);
272+
}
254273
return {
255274
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
256275
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
@@ -260,6 +279,7 @@ export function createRenderState(
260279
headChunks: null,
261280
charsetChunks: [],
262281
preconnectChunks: [],
282+
importMapChunks,
263283
preloadChunks: [],
264284
hoistableChunks: [],
265285
nonce,
@@ -290,7 +310,9 @@ export function createResumableState(
290310
);
291311
bootstrapChunks.push(
292312
inlineScriptWithNonce,
293-
stringToChunk(escapeBootstrapScriptContent(bootstrapScriptContent)),
313+
stringToChunk(
314+
escapeBootstrapAndImportMapScriptContent(bootstrapScriptContent),
315+
),
294316
endInlineScript,
295317
);
296318
}
@@ -4342,6 +4364,12 @@ export function writePreamble(
43424364
// Flush unblocked stylesheets by precedence
43434365
resumableState.precedences.forEach(flushAllStylesInPreamble, destination);
43444366

4367+
const importMapChunks = renderState.importMapChunks;
4368+
for (i = 0; i < importMapChunks.length; i++) {
4369+
writeChunk(destination, importMapChunks[i]);
4370+
}
4371+
importMapChunks.length = 0;
4372+
43454373
resumableState.bootstrapScripts.forEach(flushResourceInPreamble, destination);
43464374

43474375
resumableState.scripts.forEach(flushResourceInPreamble, destination);
@@ -4415,6 +4443,11 @@ export function writeHoistables(
44154443
// but we want to kick off preloading as soon as possible
44164444
resumableState.precedences.forEach(preloadLateStyles, destination);
44174445

4446+
// We only hoist importmaps that are configured through createResponse and that will
4447+
// always flush in the preamble. Generally we don't expect people to render them as
4448+
// tags when using React but if you do they are going to be treated like regular inline
4449+
// scripts and flush after other hoistables which is problematic
4450+
44184451
// bootstrap scripts should flush above script priority but these can only flush in the preamble
44194452
// so we elide the code here for performance
44204453

packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type RenderState = {
4141
headChunks: null | Array<Chunk | PrecomputedChunk>,
4242
charsetChunks: Array<Chunk | PrecomputedChunk>,
4343
preconnectChunks: Array<Chunk | PrecomputedChunk>,
44+
importMapChunks: Array<Chunk | PrecomputedChunk>,
4445
preloadChunks: Array<Chunk | PrecomputedChunk>,
4546
hoistableChunks: Array<Chunk | PrecomputedChunk>,
4647
boundaryResources: ?BoundaryResources,
@@ -65,6 +66,7 @@ export function createRenderState(
6566
headChunks: renderState.headChunks,
6667
charsetChunks: renderState.charsetChunks,
6768
preconnectChunks: renderState.preconnectChunks,
69+
importMapChunks: renderState.importMapChunks,
6870
preloadChunks: renderState.preloadChunks,
6971
hoistableChunks: renderState.hoistableChunks,
7072
boundaryResources: renderState.boundaryResources,

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3623,6 +3623,33 @@ describe('ReactDOMFizzServer', () => {
36233623
await waitForAll([]);
36243624
});
36253625

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+
36263653
describe('error escaping', () => {
36273654
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
36283655
window.__outlet = {};
@@ -3949,7 +3976,7 @@ describe('ReactDOMFizzServer', () => {
39493976
]);
39503977
});
39513978

3952-
describe('bootstrapScriptContent escaping', () => {
3979+
describe('bootstrapScriptContent and importMap escaping', () => {
39533980
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 () => {
39543981
window.__test_outlet = '';
39553982
const stringWithScriptsInIt =
@@ -4005,6 +4032,24 @@ describe('ReactDOMFizzServer', () => {
40054032
});
40064033
expect(window.__test_outlet).toBe(1);
40074034
});
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+
});
40084053
});
40094054

40104055
// @gate enableFizzExternalRuntime

packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ describe('ReactDOMFizzStatic', () => {
8484
if (node.nodeName === 'SCRIPT') {
8585
const script = document.createElement('script');
8686
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+
}
8791
fakeBody.removeChild(node);
8892
container.appendChild(script);
8993
} else {
@@ -98,7 +102,7 @@ describe('ReactDOMFizzStatic', () => {
98102
while (node) {
99103
if (node.nodeType === 1) {
100104
if (
101-
node.tagName !== 'SCRIPT' &&
105+
(node.tagName !== 'SCRIPT' || node.hasAttribute('type')) &&
102106
node.tagName !== 'TEMPLATE' &&
103107
node.tagName !== 'template' &&
104108
!node.hasAttribute('hidden') &&
@@ -237,4 +241,24 @@ describe('ReactDOMFizzStatic', () => {
237241

238242
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
239243
});
244+
245+
it('should support importMap option', async () => {
246+
const importMap = {
247+
foo: 'path/to/foo.js',
248+
};
249+
const result = await ReactDOMFizzStatic.prerenderToNodeStream(
250+
<html>
251+
<body>hello world</body>
252+
</html>,
253+
{importMap},
254+
);
255+
256+
await act(async () => {
257+
result.prelude.pipe(writable);
258+
});
259+
expect(getVisibleChildren(container)).toEqual([
260+
<script type="importmap">{JSON.stringify(importMap)}</script>,
261+
'hello world',
262+
]);
263+
});
240264
});

packages/react-dom/src/server/ReactDOMFizzServerBrowser.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type Options = {
3838
onError?: (error: mixed) => ?string,
3939
onPostpone?: (reason: string) => void,
4040
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41+
importMap?: {[string]: string},
4142
};
4243

4344
type ResumeOptions = {
@@ -101,7 +102,11 @@ function renderToReadableStream(
101102
const request = createRequest(
102103
children,
103104
resumableState,
104-
createRenderState(resumableState, options ? options.nonce : undefined),
105+
createRenderState(
106+
resumableState,
107+
options ? options.nonce : undefined,
108+
options ? options.importMap : undefined,
109+
),
105110
createRootFormatContext(options ? options.namespaceURI : undefined),
106111
options ? options.progressiveChunkSize : undefined,
107112
options ? options.onError : undefined,
@@ -171,6 +176,7 @@ function resume(
171176
createRenderState(
172177
postponedState.resumableState,
173178
options ? options.nonce : undefined,
179+
undefined, // importMap
174180
),
175181
postponedState.rootFormatContext,
176182
postponedState.progressiveChunkSize,

packages/react-dom/src/server/ReactDOMFizzServerBun.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type Options = {
3737
onError?: (error: mixed) => ?string,
3838
onPostpone?: (reason: string) => void,
3939
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
40+
importMap?: {[string]: string},
4041
};
4142

4243
// TODO: Move to sub-classing ReadableStream.
@@ -93,7 +94,11 @@ function renderToReadableStream(
9394
const request = createRequest(
9495
children,
9596
resumableState,
96-
createRenderState(resumableState, options ? options.nonce : undefined),
97+
createRenderState(
98+
resumableState,
99+
options ? options.nonce : undefined,
100+
options ? options.importMap : undefined,
101+
),
97102
createRootFormatContext(options ? options.namespaceURI : undefined),
98103
options ? options.progressiveChunkSize : undefined,
99104
options ? options.onError : undefined,

packages/react-dom/src/server/ReactDOMFizzServerEdge.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type Options = {
3838
onError?: (error: mixed) => ?string,
3939
onPostpone?: (reason: string) => void,
4040
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41+
importMap?: {[string]: string},
4142
};
4243

4344
type ResumeOptions = {
@@ -101,7 +102,11 @@ function renderToReadableStream(
101102
const request = createRequest(
102103
children,
103104
resumableState,
104-
createRenderState(resumableState, options ? options.nonce : undefined),
105+
createRenderState(
106+
resumableState,
107+
options ? options.nonce : undefined,
108+
options ? options.importMap : undefined,
109+
),
105110
createRootFormatContext(options ? options.namespaceURI : undefined),
106111
options ? options.progressiveChunkSize : undefined,
107112
options ? options.onError : undefined,
@@ -171,6 +176,7 @@ function resume(
171176
createRenderState(
172177
postponedState.resumableState,
173178
options ? options.nonce : undefined,
179+
undefined, // importMap
174180
),
175181
postponedState.rootFormatContext,
176182
postponedState.progressiveChunkSize,

packages/react-dom/src/server/ReactDOMFizzServerNode.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ type Options = {
5151
onError?: (error: mixed) => ?string,
5252
onPostpone?: (reason: string) => void,
5353
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
54+
importMap?: {[string]: string},
5455
};
5556

5657
type ResumeOptions = {
@@ -81,7 +82,11 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
8182
return createRequest(
8283
children,
8384
resumableState,
84-
createRenderState(resumableState, options ? options.nonce : undefined),
85+
createRenderState(
86+
resumableState,
87+
options ? options.nonce : undefined,
88+
options ? options.importMap : undefined,
89+
),
8590
createRootFormatContext(options ? options.namespaceURI : undefined),
8691
options ? options.progressiveChunkSize : undefined,
8792
options ? options.onError : undefined,
@@ -140,6 +145,7 @@ function resumeRequestImpl(
140145
createRenderState(
141146
postponedState.resumableState,
142147
options ? options.nonce : undefined,
148+
undefined, // importMap
143149
),
144150
postponedState.rootFormatContext,
145151
postponedState.progressiveChunkSize,

packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type Options = {
3838
onError?: (error: mixed) => ?string,
3939
onPostpone?: (reason: string) => void,
4040
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41+
importMap?: {[string]: string},
4142
};
4243

4344
type StaticResult = {
@@ -81,7 +82,11 @@ function prerender(
8182
const request = createRequest(
8283
children,
8384
resources,
84-
createRenderState(resources, undefined),
85+
createRenderState(
86+
resources,
87+
undefined, // nonce
88+
options ? options.importMap : undefined,
89+
),
8590
createRootFormatContext(options ? options.namespaceURI : undefined),
8691
options ? options.progressiveChunkSize : undefined,
8792
options ? options.onError : undefined,

packages/react-dom/src/server/ReactDOMFizzStaticEdge.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type Options = {
3838
onError?: (error: mixed) => ?string,
3939
onPostpone?: (reason: string) => void,
4040
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41+
importMap?: {[string]: string},
4142
};
4243

4344
type StaticResult = {
@@ -81,7 +82,11 @@ function prerender(
8182
const request = createRequest(
8283
children,
8384
resources,
84-
createRenderState(resources, undefined),
85+
createRenderState(
86+
resources,
87+
undefined, // nonce
88+
options ? options.importMap : undefined,
89+
),
8590
createRootFormatContext(options ? options.namespaceURI : undefined),
8691
options ? options.progressiveChunkSize : undefined,
8792
options ? options.onError : undefined,

0 commit comments

Comments
 (0)