Skip to content

Commit

Permalink
Import maps need to be emitted before any scripts or preloads so the …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
gnoff committed Aug 22, 2023
1 parent 31034b6 commit aabeeab
Show file tree
Hide file tree
Showing 13 changed files with 192 additions and 31 deletions.
51 changes: 35 additions & 16 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -1968,7 +1968,7 @@ export function clearSingleton(instance: Instance): void {

export const supportsResources = true;

type HoistableTagType = 'link' | 'meta' | 'title';
type HoistableTagType = 'link' | 'meta' | 'title' | 'script';
type TResource<
T: 'stylesheet' | 'style' | 'script' | 'void',
S: null | {...},
Expand Down Expand Up @@ -2759,7 +2759,12 @@ export function getResource(
return null;
}
case 'script': {
if (typeof pendingProps.src === 'string' && pendingProps.async === true) {
if (pendingProps.type === 'importmap') {
return null;
} else if (
typeof pendingProps.src === 'string' &&
pendingProps.async === true
) {
const scriptProps: ScriptProps = pendingProps;
const key = getScriptKey(scriptProps.src);
const scripts = getResourcesFromRoot(resourceRoot).hoistableScripts;
Expand Down Expand Up @@ -3110,21 +3115,21 @@ export function hydrateHoistable(
case 'title': {
instance = ownerDocument.getElementsByTagName('title')[0];
if (
!instance ||
isOwnedInstance(instance) ||
instance.namespaceURI === SVG_NAMESPACE ||
instance.hasAttribute('itemprop')
instance &&
!isOwnedInstance(instance) &&
instance.namespaceURI !== SVG_NAMESPACE &&
!instance.hasAttribute('itemprop')
) {
instance = ownerDocument.createElement(type);
(ownerDocument.head: any).insertBefore(
instance,
ownerDocument.querySelector('head > title'),
);
setInitialProperties(instance, type, props);
break;
}
instance = ownerDocument.createElement(type);
setInitialProperties(instance, type, props);
precacheFiberNode(internalInstanceHandle, instance);
markNodeAsHoistable(instance);
return instance;
(ownerDocument.head: any).insertBefore(
instance,
ownerDocument.querySelector('head > title'),
);
break;
}
case 'link': {
const cache = getHydratableHoistableCache('link', 'href', ownerDocument);
Expand Down Expand Up @@ -3201,6 +3206,17 @@ export function hydrateHoistable(
(ownerDocument.head: any).appendChild(instance);
break;
}
case 'script': {
// the only hoistable script is type="importmap" and there should only be 1
instance = ownerDocument.querySelector('script[type="importmap"]');
if (instance && !isOwnedInstance(instance)) {
break;
}
instance = ownerDocument.createElement(type);
setInitialProperties(instance, type, props);
(ownerDocument.head: any).appendChild(instance);
break;
}
default:
throw new Error(
`getNodesForType encountered a type it did not expect: "${type}". This is a bug in React.`,
Expand Down Expand Up @@ -3406,7 +3422,9 @@ export function isHostHoistableType(
}
}
case 'script': {
if (
if (props.type === 'importmap') {
return true;
} else if (
props.async !== true ||
props.onLoad ||
props.onError ||
Expand Down Expand Up @@ -3436,8 +3454,9 @@ export function isHostHoistableType(
}
}
return false;
} else {
return true;
}
return true;
}
case 'noscript':
case 'template': {
Expand Down
39 changes: 36 additions & 3 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export type RenderState = {
// Hoistable chunks
charsetChunks: Array<Chunk | PrecomputedChunk>,
preconnectChunks: Array<Chunk | PrecomputedChunk>,
importMapChunks: Array<Chunk | PrecomputedChunk>,
preloadChunks: Array<Chunk | PrecomputedChunk>,
hoistableChunks: Array<Chunk | PrecomputedChunk>,

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

/**
* 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 <script sequences the html parser will never enter
Expand All @@ -214,7 +215,7 @@ const endAsyncScript = stringToPrecomputedChunk('" async=""></script>');
* 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);
}
Expand All @@ -237,12 +238,19 @@ export type ExternalRuntimeScript = {
src: string,
chunks: Array<Chunk | PrecomputedChunk>,
};

const importMapScriptStart = stringToPrecomputedChunk(
'<script type="importmap">',
);
const importMapScriptEnd = stringToPrecomputedChunk('</script>');

// 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: {[string]: string} | void,
): RenderState {
const inlineScriptWithNonce =
nonce === undefined
Expand All @@ -251,6 +259,17 @@ export function createRenderState(
'<script nonce="' + escapeTextForBrowser(nonce) + '">',
);
const idPrefix = resumableState.idPrefix;
const importMapChunks: Array<Chunk | PrecomputedChunk> = [];
if (importMap !== undefined) {
const map = importMap;
importMapChunks.push(importMapScriptStart);
importMapChunks.push(
stringToChunk(
escapeBootstrapAndImportMapScriptContent(JSON.stringify(map)),
),
);
importMapChunks.push(importMapScriptEnd);
}
return {
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
Expand All @@ -260,6 +279,7 @@ export function createRenderState(
headChunks: null,
charsetChunks: [],
preconnectChunks: [],
importMapChunks,
preloadChunks: [],
hoistableChunks: [],
nonce,
Expand Down Expand Up @@ -290,7 +310,9 @@ export function createResumableState(
);
bootstrapChunks.push(
inlineScriptWithNonce,
stringToChunk(escapeBootstrapScriptContent(bootstrapScriptContent)),
stringToChunk(
escapeBootstrapAndImportMapScriptContent(bootstrapScriptContent),
),
endInlineScript,
);
}
Expand Down Expand Up @@ -4342,6 +4364,12 @@ export function writePreamble(
// Flush unblocked stylesheets by precedence
resumableState.precedences.forEach(flushAllStylesInPreamble, destination);

const importMapChunks = renderState.importMapChunks;
for (i = 0; i < importMapChunks.length; i++) {
writeChunk(destination, importMapChunks[i]);
}
importMapChunks.length = 0;

resumableState.bootstrapScripts.forEach(flushResourceInPreamble, destination);

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

// We only hoist importmaps that are configured through createResponse and that will
// always flush in the preamble. Generally we don't expect people to render them as
// tags when using React but if you do they are going to be treated like regular inline
// scripts and flush after other hoistables which is problematic

// bootstrap scripts should flush above script priority but these can only flush in the preamble
// so we elide the code here for performance

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type RenderState = {
headChunks: null | Array<Chunk | PrecomputedChunk>,
charsetChunks: Array<Chunk | PrecomputedChunk>,
preconnectChunks: Array<Chunk | PrecomputedChunk>,
importMapChunks: Array<Chunk | PrecomputedChunk>,
preloadChunks: Array<Chunk | PrecomputedChunk>,
hoistableChunks: Array<Chunk | PrecomputedChunk>,
boundaryResources: ?BoundaryResources,
Expand All @@ -65,6 +66,7 @@ export function createRenderState(
headChunks: renderState.headChunks,
charsetChunks: renderState.charsetChunks,
preconnectChunks: renderState.preconnectChunks,
importMapChunks: renderState.importMapChunks,
preloadChunks: renderState.preloadChunks,
hoistableChunks: renderState.hoistableChunks,
boundaryResources: renderState.boundaryResources,
Expand Down
47 changes: 46 additions & 1 deletion packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3623,6 +3623,33 @@ describe('ReactDOMFizzServer', () => {
await waitForAll([]);
});

it('takes an importMap option which emits an "importmap" script in the head', async () => {
const importMap = {
foo: './path/to/foo.js',
};
await act(() => {
renderToPipeableStream(
<html>
<head>
<script async={true} src="foo" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
{
importMap,
},
).pipe(writable);
});

expect(document.head.innerHTML).toBe(
'<script type="importmap">' +
JSON.stringify(importMap) +
'</script><script async="" src="foo"></script>',
);
});

describe('error escaping', () => {
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
window.__outlet = {};
Expand Down Expand Up @@ -3949,7 +3976,7 @@ describe('ReactDOMFizzServer', () => {
]);
});

describe('bootstrapScriptContent escaping', () => {
describe('bootstrapScriptContent and importMap escaping', () => {
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 () => {
window.__test_outlet = '';
const stringWithScriptsInIt =
Expand Down Expand Up @@ -4005,6 +4032,24 @@ describe('ReactDOMFizzServer', () => {
});
expect(window.__test_outlet).toBe(1);
});

it('escapes </[sS]cirpt> in importMaps', async () => {
window.__test_outlet_key = '';
window.__test_outlet_value = '';
const jsonWithScriptsInIt = {
"keypos</script><script>window.__test_outlet_key = 'pwned'</script><script>":
'value',
key: "valuepos</script><script>window.__test_outlet_value = 'pwned'</script><script>",
};
await act(() => {
const {pipe} = renderToPipeableStream(<div />, {
importMap: jsonWithScriptsInIt,
});
pipe(writable);
});
expect(window.__test_outlet_key).toBe('');
expect(window.__test_outlet_value).toBe('');
});
});

// @gate enableFizzExternalRuntime
Expand Down
26 changes: 25 additions & 1 deletion packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ describe('ReactDOMFizzStatic', () => {
if (node.nodeName === 'SCRIPT') {
const script = document.createElement('script');
script.textContent = node.textContent;
for (let i = 0; i < node.attributes.length; i++) {
const attribute = node.attributes[i];
script.setAttribute(attribute.name, attribute.value);
}
fakeBody.removeChild(node);
container.appendChild(script);
} else {
Expand All @@ -98,7 +102,7 @@ describe('ReactDOMFizzStatic', () => {
while (node) {
if (node.nodeType === 1) {
if (
node.tagName !== 'SCRIPT' &&
(node.tagName !== 'SCRIPT' || node.hasAttribute('type')) &&
node.tagName !== 'TEMPLATE' &&
node.tagName !== 'template' &&
!node.hasAttribute('hidden') &&
Expand Down Expand Up @@ -237,4 +241,24 @@ describe('ReactDOMFizzStatic', () => {

expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
});

it('should support importMap option', async () => {
const importMap = {
foo: 'path/to/foo.js',
};
const result = await ReactDOMFizzStatic.prerenderToNodeStream(
<html>
<body>hello world</body>
</html>,
{importMap},
);

await act(async () => {
result.prelude.pipe(writable);
});
expect(getVisibleChildren(container)).toEqual([
<script type="importmap">{JSON.stringify(importMap)}</script>,
'hello world',
]);
});
});
8 changes: 7 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Options = {
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: {[string]: string},
};

type ResumeOptions = {
Expand Down Expand Up @@ -101,7 +102,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,
Expand Down Expand Up @@ -171,6 +176,7 @@ function resume(
createRenderState(
postponedState.resumableState,
options ? options.nonce : undefined,
options ? options.importMap : undefined,
),
postponedState.rootFormatContext,
postponedState.progressiveChunkSize,
Expand Down
7 changes: 6 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBun.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Options = {
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: {[string]: string},
};

// TODO: Move to sub-classing ReadableStream.
Expand Down Expand Up @@ -93,7 +94,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,
Expand Down
Loading

0 comments on commit aabeeab

Please sign in to comment.