Skip to content

Commit 58b402f

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 58b402f

13 files changed

+177
-15
lines changed

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
PreloadModuleOptions,
1616
PreinitOptions,
1717
PreinitModuleOptions,
18+
ImportMap,
1819
} from 'react-dom/src/shared/ReactDOMTypes';
1920

2021
import {
@@ -139,6 +140,7 @@ export type RenderState = {
139140
// Hoistable chunks
140141
charsetChunks: Array<Chunk | PrecomputedChunk>,
141142
preconnectChunks: Array<Chunk | PrecomputedChunk>,
143+
importMapChunks: Array<Chunk | PrecomputedChunk>,
142144
preloadChunks: Array<Chunk | PrecomputedChunk>,
143145
hoistableChunks: Array<Chunk | PrecomputedChunk>,
144146

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

207209
/**
208-
* This escaping function is designed to work with bootstrapScriptContent only.
210+
* This escaping function is designed to work with bootstrapScriptContent and importMap only.
209211
* because we know we are escaping the entire script. We can avoid for instance
210212
* escaping html comment string sequences that are valid javascript as well because
211213
* if there are no sebsequent <script sequences the html parser will never enter
@@ -214,7 +216,7 @@ const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
214216
* While untrusted script content should be made safe before using this api it will
215217
* ensure that the script cannot be early terminated or never terminated state
216218
*/
217-
function escapeBootstrapScriptContent(scriptText: string) {
219+
function escapeBootstrapAndImportMapScriptContent(scriptText: string) {
218220
if (__DEV__) {
219221
checkHtmlStringCoercion(scriptText);
220222
}
@@ -237,12 +239,19 @@ export type ExternalRuntimeScript = {
237239
src: string,
238240
chunks: Array<Chunk | PrecomputedChunk>,
239241
};
242+
243+
const importMapScriptStart = stringToPrecomputedChunk(
244+
'<script type="importmap">',
245+
);
246+
const importMapScriptEnd = stringToPrecomputedChunk('</script>');
247+
240248
// Allows us to keep track of what we've already written so we can refer back to it.
241249
// if passed externalRuntimeConfig and the enableFizzExternalRuntime feature flag
242250
// is set, the server will send instructions via data attributes (instead of inline scripts)
243251
export function createRenderState(
244252
resumableState: ResumableState,
245253
nonce: string | void,
254+
importMap: ImportMap | void,
246255
): RenderState {
247256
const inlineScriptWithNonce =
248257
nonce === undefined
@@ -251,6 +260,17 @@ export function createRenderState(
251260
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
252261
);
253262
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+
}
254274
return {
255275
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
256276
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
@@ -260,6 +280,7 @@ export function createRenderState(
260280
headChunks: null,
261281
charsetChunks: [],
262282
preconnectChunks: [],
283+
importMapChunks,
263284
preloadChunks: [],
264285
hoistableChunks: [],
265286
nonce,
@@ -290,7 +311,9 @@ export function createResumableState(
290311
);
291312
bootstrapChunks.push(
292313
inlineScriptWithNonce,
293-
stringToChunk(escapeBootstrapScriptContent(bootstrapScriptContent)),
314+
stringToChunk(
315+
escapeBootstrapAndImportMapScriptContent(bootstrapScriptContent),
316+
),
294317
endInlineScript,
295318
);
296319
}
@@ -4342,6 +4365,12 @@ export function writePreamble(
43424365
// Flush unblocked stylesheets by precedence
43434366
resumableState.precedences.forEach(flushAllStylesInPreamble, destination);
43444367

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

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

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+
44184452
// bootstrap scripts should flush above script priority but these can only flush in the preamble
44194453
// so we elide the code here for performance
44204454

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: 26 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,25 @@ describe('ReactDOMFizzStatic', () => {
237241

238242
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
239243
});
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+
});
240265
});

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {PostponedState} from 'react-server/src/ReactFizzServer';
1111
import type {ReactNodeList} from 'shared/ReactTypes';
1212
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
13+
import type {ImportMap} from '../shared/ReactDOMTypes';
1314

1415
import ReactVersion from 'shared/ReactVersion';
1516

@@ -38,6 +39,7 @@ type Options = {
3839
onError?: (error: mixed) => ?string,
3940
onPostpone?: (reason: string) => void,
4041
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
42+
importMap?: ImportMap,
4143
};
4244

4345
type ResumeOptions = {
@@ -101,7 +103,11 @@ function renderToReadableStream(
101103
const request = createRequest(
102104
children,
103105
resumableState,
104-
createRenderState(resumableState, options ? options.nonce : undefined),
106+
createRenderState(
107+
resumableState,
108+
options ? options.nonce : undefined,
109+
options ? options.importMap : undefined,
110+
),
105111
createRootFormatContext(options ? options.namespaceURI : undefined),
106112
options ? options.progressiveChunkSize : undefined,
107113
options ? options.onError : undefined,
@@ -171,6 +177,7 @@ function resume(
171177
createRenderState(
172178
postponedState.resumableState,
173179
options ? options.nonce : undefined,
180+
undefined, // importMap
174181
),
175182
postponedState.rootFormatContext,
176183
postponedState.progressiveChunkSize,

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

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

1314
import ReactVersion from 'shared/ReactVersion';
1415

@@ -37,6 +38,7 @@ type Options = {
3738
onError?: (error: mixed) => ?string,
3839
onPostpone?: (reason: string) => void,
3940
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41+
importMap?: ImportMap,
4042
};
4143

4244
// TODO: Move to sub-classing ReadableStream.
@@ -93,7 +95,11 @@ function renderToReadableStream(
9395
const request = createRequest(
9496
children,
9597
resumableState,
96-
createRenderState(resumableState, options ? options.nonce : undefined),
98+
createRenderState(
99+
resumableState,
100+
options ? options.nonce : undefined,
101+
options ? options.importMap : undefined,
102+
),
97103
createRootFormatContext(options ? options.namespaceURI : undefined),
98104
options ? options.progressiveChunkSize : undefined,
99105
options ? options.onError : undefined,

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {PostponedState} from 'react-server/src/ReactFizzServer';
1111
import type {ReactNodeList} from 'shared/ReactTypes';
1212
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
13+
import type {ImportMap} from '../shared/ReactDOMTypes';
1314

1415
import ReactVersion from 'shared/ReactVersion';
1516

@@ -38,6 +39,7 @@ type Options = {
3839
onError?: (error: mixed) => ?string,
3940
onPostpone?: (reason: string) => void,
4041
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
42+
importMap?: ImportMap,
4143
};
4244

4345
type ResumeOptions = {
@@ -101,7 +103,11 @@ function renderToReadableStream(
101103
const request = createRequest(
102104
children,
103105
resumableState,
104-
createRenderState(resumableState, options ? options.nonce : undefined),
106+
createRenderState(
107+
resumableState,
108+
options ? options.nonce : undefined,
109+
options ? options.importMap : undefined,
110+
),
105111
createRootFormatContext(options ? options.namespaceURI : undefined),
106112
options ? options.progressiveChunkSize : undefined,
107113
options ? options.onError : undefined,
@@ -171,6 +177,7 @@ function resume(
171177
createRenderState(
172178
postponedState.resumableState,
173179
options ? options.nonce : undefined,
180+
undefined, // importMap
174181
),
175182
postponedState.rootFormatContext,
176183
postponedState.progressiveChunkSize,

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {ReactNodeList} from 'shared/ReactTypes';
1212
import type {Writable} from 'stream';
1313
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
1414
import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
15+
import type {ImportMap} from '../shared/ReactDOMTypes';
1516

1617
import ReactVersion from 'shared/ReactVersion';
1718

@@ -51,6 +52,7 @@ type Options = {
5152
onError?: (error: mixed) => ?string,
5253
onPostpone?: (reason: string) => void,
5354
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
55+
importMap?: ImportMap,
5456
};
5557

5658
type ResumeOptions = {
@@ -81,7 +83,11 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
8183
return createRequest(
8284
children,
8385
resumableState,
84-
createRenderState(resumableState, options ? options.nonce : undefined),
86+
createRenderState(
87+
resumableState,
88+
options ? options.nonce : undefined,
89+
options ? options.importMap : undefined,
90+
),
8591
createRootFormatContext(options ? options.namespaceURI : undefined),
8692
options ? options.progressiveChunkSize : undefined,
8793
options ? options.onError : undefined,
@@ -140,6 +146,7 @@ function resumeRequestImpl(
140146
createRenderState(
141147
postponedState.resumableState,
142148
options ? options.nonce : undefined,
149+
undefined, // importMap
143150
),
144151
postponedState.rootFormatContext,
145152
postponedState.progressiveChunkSize,

0 commit comments

Comments
 (0)