Skip to content

Commit aabeeab

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 aabeeab

13 files changed

+192
-31
lines changed

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,7 +1968,7 @@ export function clearSingleton(instance: Instance): void {
19681968

19691969
export const supportsResources = true;
19701970

1971-
type HoistableTagType = 'link' | 'meta' | 'title';
1971+
type HoistableTagType = 'link' | 'meta' | 'title' | 'script';
19721972
type TResource<
19731973
T: 'stylesheet' | 'style' | 'script' | 'void',
19741974
S: null | {...},
@@ -2759,7 +2759,12 @@ export function getResource(
27592759
return null;
27602760
}
27612761
case 'script': {
2762-
if (typeof pendingProps.src === 'string' && pendingProps.async === true) {
2762+
if (pendingProps.type === 'importmap') {
2763+
return null;
2764+
} else if (
2765+
typeof pendingProps.src === 'string' &&
2766+
pendingProps.async === true
2767+
) {
27632768
const scriptProps: ScriptProps = pendingProps;
27642769
const key = getScriptKey(scriptProps.src);
27652770
const scripts = getResourcesFromRoot(resourceRoot).hoistableScripts;
@@ -3110,21 +3115,21 @@ export function hydrateHoistable(
31103115
case 'title': {
31113116
instance = ownerDocument.getElementsByTagName('title')[0];
31123117
if (
3113-
!instance ||
3114-
isOwnedInstance(instance) ||
3115-
instance.namespaceURI === SVG_NAMESPACE ||
3116-
instance.hasAttribute('itemprop')
3118+
instance &&
3119+
!isOwnedInstance(instance) &&
3120+
instance.namespaceURI !== SVG_NAMESPACE &&
3121+
!instance.hasAttribute('itemprop')
31173122
) {
3118-
instance = ownerDocument.createElement(type);
3119-
(ownerDocument.head: any).insertBefore(
3120-
instance,
3121-
ownerDocument.querySelector('head > title'),
3122-
);
3123+
setInitialProperties(instance, type, props);
3124+
break;
31233125
}
3126+
instance = ownerDocument.createElement(type);
31243127
setInitialProperties(instance, type, props);
3125-
precacheFiberNode(internalInstanceHandle, instance);
3126-
markNodeAsHoistable(instance);
3127-
return instance;
3128+
(ownerDocument.head: any).insertBefore(
3129+
instance,
3130+
ownerDocument.querySelector('head > title'),
3131+
);
3132+
break;
31283133
}
31293134
case 'link': {
31303135
const cache = getHydratableHoistableCache('link', 'href', ownerDocument);
@@ -3201,6 +3206,17 @@ export function hydrateHoistable(
32013206
(ownerDocument.head: any).appendChild(instance);
32023207
break;
32033208
}
3209+
case 'script': {
3210+
// the only hoistable script is type="importmap" and there should only be 1
3211+
instance = ownerDocument.querySelector('script[type="importmap"]');
3212+
if (instance && !isOwnedInstance(instance)) {
3213+
break;
3214+
}
3215+
instance = ownerDocument.createElement(type);
3216+
setInitialProperties(instance, type, props);
3217+
(ownerDocument.head: any).appendChild(instance);
3218+
break;
3219+
}
32043220
default:
32053221
throw new Error(
32063222
`getNodesForType encountered a type it did not expect: "${type}". This is a bug in React.`,
@@ -3406,7 +3422,9 @@ export function isHostHoistableType(
34063422
}
34073423
}
34083424
case 'script': {
3409-
if (
3425+
if (props.type === 'importmap') {
3426+
return true;
3427+
} else if (
34103428
props.async !== true ||
34113429
props.onLoad ||
34123430
props.onError ||
@@ -3436,8 +3454,9 @@ export function isHostHoistableType(
34363454
}
34373455
}
34383456
return false;
3457+
} else {
3458+
return true;
34393459
}
3440-
return true;
34413460
}
34423461
case 'noscript':
34433462
case 'template': {

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+
options ? options.importMap : undefined,
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,

0 commit comments

Comments
 (0)