Skip to content

Commit 53dcbb2

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 b277259 commit 53dcbb2

File tree

5 files changed

+151
-19
lines changed

5 files changed

+151
-19
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export type ResponseState = {
142142
// Hoistable chunks
143143
charsetChunks: Array<Chunk | PrecomputedChunk>,
144144
preconnectChunks: Array<Chunk | PrecomputedChunk>,
145+
importMapChunks: Array<Chunk | PrecomputedChunk>,
145146
preloadChunks: Array<Chunk | PrecomputedChunk>,
146147
hoistableChunks: Array<Chunk | PrecomputedChunk>,
147148

@@ -361,6 +362,7 @@ export function createResponseState(
361362
hasBody: false,
362363
charsetChunks: [],
363364
preconnectChunks: [],
365+
importMapChunks: [],
364366
preloadChunks: [],
365367
hoistableChunks: [],
366368
stylesToHoist: false,
@@ -2701,12 +2703,17 @@ function pushStartHtml(
27012703
function pushScript(
27022704
target: Array<Chunk | PrecomputedChunk>,
27032705
props: Object,
2706+
responseState: ResponseState,
27042707
resources: Resources,
27052708
textEmbedded: boolean,
27062709
insertionMode: InsertionMode,
27072710
noscriptTagInScope: boolean,
27082711
): null {
27092712
if (enableFloat) {
2713+
if (props.type === 'importmap') {
2714+
return pushScriptImpl(responseState.importMapChunks, props);
2715+
}
2716+
27102717
const asyncProp = props.async;
27112718
if (
27122719
typeof props.src !== 'string' ||
@@ -3122,6 +3129,7 @@ export function pushStartInstance(
31223129
? pushScript(
31233130
target,
31243131
props,
3132+
responseState,
31253133
resources,
31263134
textEmbedded,
31273135
formatContext.insertionMode,
@@ -4231,6 +4239,12 @@ export function writePreamble(
42314239
// Flush unblocked stylesheets by precedence
42324240
resources.precedences.forEach(flushAllStylesInPreamble, destination);
42334241

4242+
const importMapChunks = responseState.importMapChunks;
4243+
for (i = 0; i < importMapChunks.length; i++) {
4244+
writeChunk(destination, importMapChunks[i]);
4245+
}
4246+
importMapChunks.length = 0;
4247+
42344248
resources.bootstrapScripts.forEach(flushResourceInPreamble, destination);
42354249

42364250
resources.scripts.forEach(flushResourceInPreamble, destination);
@@ -4301,6 +4315,13 @@ export function writeHoistables(
43014315
// but we want to kick off preloading as soon as possible
43024316
resources.precedences.forEach(preloadLateStyles, destination);
43034317

4318+
// Import maps should have already flushed. If they haven't there is a chance we still haven't emitted any preloads for scripts
4319+
// or modules so we will emit them now
4320+
const importMapChunks = responseState.importMapChunks;
4321+
for (i = 0; i < importMapChunks.length; i++) {
4322+
writeChunk(destination, importMapChunks[i]);
4323+
}
4324+
43044325
// bootstrap scripts should flush above script priority but these can only flush in the preamble
43054326
// so we elide the code here for performance
43064327

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type ResponseState = {
5656
hasBody: boolean,
5757
charsetChunks: Array<Chunk | PrecomputedChunk>,
5858
preconnectChunks: Array<Chunk | PrecomputedChunk>,
59+
importMapChunks: Array<Chunk | PrecomputedChunk>,
5960
preloadChunks: Array<Chunk | PrecomputedChunk>,
6061
hoistableChunks: Array<Chunk | PrecomputedChunk>,
6162
stylesToHoist: boolean,
@@ -95,6 +96,7 @@ export function createResponseState(
9596
hasBody: responseState.hasBody,
9697
charsetChunks: responseState.charsetChunks,
9798
preconnectChunks: responseState.preconnectChunks,
99+
importMapChunks: responseState.importMapChunks,
98100
preloadChunks: responseState.preloadChunks,
99101
hoistableChunks: responseState.hoistableChunks,
100102
stylesToHoist: responseState.stylesToHoist,

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3999,6 +3999,96 @@ body {
39993999
);
40004000
});
40014001

4002+
it('hoists <script type="importmap"> tags above scripts and preloads', async () => {
4003+
function App() {
4004+
return (
4005+
<html>
4006+
<body>
4007+
hello
4008+
<script async={true} src="foo" />
4009+
<script type="importmap" data-meaningful="">
4010+
first map
4011+
</script>
4012+
<script type="importmap" data-meaningful="">
4013+
second map (two maps do not make sense but we are asserting they
4014+
flush anyway)
4015+
</script>
4016+
<script src="sync script" data-meaningful="" />
4017+
</body>
4018+
</html>
4019+
);
4020+
}
4021+
4022+
await act(() => {
4023+
renderToPipeableStream(<App />).pipe(writable);
4024+
});
4025+
4026+
expect(getMeaningfulChildren(document)).toEqual(
4027+
<html>
4028+
<head>
4029+
<script type="importmap" data-meaningful="">
4030+
first map
4031+
</script>
4032+
<script type="importmap" data-meaningful="">
4033+
second map (two maps do not make sense but we are asserting they
4034+
flush anyway)
4035+
</script>
4036+
<script async="" src="foo" />
4037+
</head>
4038+
<body>
4039+
hello
4040+
<script src="sync script" data-meaningful="" />
4041+
</body>
4042+
</html>,
4043+
);
4044+
4045+
const root = ReactDOMClient.hydrateRoot(document, <App />);
4046+
await waitForAll([]);
4047+
4048+
expect(getMeaningfulChildren(document)).toEqual(
4049+
<html>
4050+
<head>
4051+
<script type="importmap" data-meaningful="">
4052+
first map
4053+
</script>
4054+
<script type="importmap" data-meaningful="">
4055+
second map (two maps do not make sense but we are asserting they
4056+
flush anyway)
4057+
</script>
4058+
<script async="" src="foo" />
4059+
{/* The reason we see a second copy of this map is we assume there
4060+
will only be one and thus only hydrate the first one we encounter.
4061+
All subsequent importmap hoistables will be considered new on the client
4062+
This is similar to how <title> hydrates as well */}
4063+
<script type="importmap" data-meaningful="">
4064+
second map (two maps do not make sense but we are asserting they
4065+
flush anyway)
4066+
</script>
4067+
</head>
4068+
<body>
4069+
hello
4070+
<script src="sync script" data-meaningful="" />
4071+
</body>
4072+
</html>,
4073+
);
4074+
4075+
root.unmount();
4076+
4077+
expect(getMeaningfulChildren(document)).toEqual(
4078+
<html>
4079+
<head>
4080+
{/* This one never hydrated so React leaves it behind */}
4081+
<script type="importmap" data-meaningful="">
4082+
second map (two maps do not make sense but we are asserting they
4083+
flush anyway)
4084+
</script>
4085+
<script async="" src="foo" />
4086+
</head>
4087+
<body />
4088+
</html>,
4089+
);
4090+
});
4091+
40024092
describe('ReactDOM.prefetchDNS(href)', () => {
40034093
it('creates a dns-prefetch resource when called', async () => {
40044094
function App({url}) {

packages/react-dom/src/test-utils/FizzTestUtils.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ async function executeScript(script: Element) {
104104
const newScript = ownerDocument.createElement('script');
105105
newScript.textContent = script.textContent;
106106
// 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);
110110
}
111111

112112
parent.insertBefore(newScript, script);

0 commit comments

Comments
 (0)