From fa11bd6ecca57d8ddbc728df881f0e165225c04f Mon Sep 17 00:00:00 2001
From: mofeiZ <34200447+mofeiZ@users.noreply.github.com>
Date: Wed, 30 Nov 2022 13:22:08 -0500
Subject: [PATCH] [ServerRenderer] Add option to send instructions as data
attributes (#25437)
### Changes made:
- Running with enableFizzExternalRuntime (feature flag) and
unstable_externalRuntimeSrc (param) will generate html nodes with data
attributes that encode Fizz instructions.
```
```
- Added an external runtime browser script
`ReactDOMServerExternalRuntime`, which processes and removes these nodes
- This runtime should be passed as to renderInto[...] via
`unstable_externalRuntimeSrc`
- Since this runtime is render blocking (for all streamed suspense
boundaries and segments), we want this to reach the client as early as
possible. By default, Fizz will send this script at the end of the shell
when it detects dynamic content (e.g. suspenseful pending tasks), but it
can be sent even earlier by calling `preinit(...)` inside a component.
- The current implementation relies on Float to dedupe sending
`unstable_externalRuntimeSrc`, so `enableFizzExternalRuntime` is only
valid when `enableFloat` is also set.
---
.../src/server/ReactDOMFloatServer.js | 14 +-
.../server/ReactDOMServerExternalRuntime.js | 102 +++-
.../src/server/ReactDOMServerFormatConfig.js | 524 +++++++++++++++---
.../ReactDOMServerLegacyFormatConfig.js | 24 +-
.../src/__tests__/ReactDOMFizzServer-test.js | 156 +++++-
.../src/__tests__/ReactDOMFloat-test.js | 78 ++-
.../src/server/ReactDOMLegacyServerImpl.js | 3 +
.../react-dom/src/test-utils/FizzTestUtils.js | 79 ++-
.../server/ReactNativeServerFormatConfig.js | 1 +
.../src/ReactDOMServerFB.js | 3 +
packages/react-server/src/ReactFizzServer.js | 9 +-
scripts/error-codes/codes.json | 3 +-
12 files changed, 845 insertions(+), 151 deletions(-)
diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
index 4af71458cb6a7..2805b629d4b48 100644
--- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
+++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
@@ -275,7 +275,7 @@ type PreinitOptions = {
crossOrigin?: string,
integrity?: string,
};
-function preinit(href: string, options: PreinitOptions) {
+function preinit(href: string, options: PreinitOptions): void {
if (!currentResources) {
// While we expect that preinit calls are primarily going to be observed
// during render because effects and events don't run on the server it is
@@ -285,7 +285,17 @@ function preinit(href: string, options: PreinitOptions) {
// simply return and do not warn.
return;
}
- const resources = currentResources;
+ preinitImpl(currentResources, href, options);
+}
+
+// On the server, preinit may be called outside of render when sending an
+// external SSR runtime as part of the initial resources payload. Since this
+// is an internal React call, we do not need to use the resources stack.
+export function preinitImpl(
+ resources: Resources,
+ href: string,
+ options: PreinitOptions,
+): void {
if (__DEV__) {
validatePreinitArguments(href, options);
}
diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js b/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js
index 01f53697e2e5f..63f5b64d93c8c 100644
--- a/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js
+++ b/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js
@@ -3,6 +3,7 @@
* clients. Therefore, it should be fast and not have many external dependencies.
* @flow
*/
+/* eslint-disable dot-notation */
// Imports are resolved statically by the closure compiler in release bundles
// and by rollup in jest unit tests
@@ -13,13 +14,94 @@ import {
completeSegment,
} from './fizz-instruction-set/ReactDOMFizzInstructionSet';
-// Intentionally does nothing. Implementation will be added in future PR.
-// eslint-disable-next-line no-unused-vars
-const observer = new MutationObserver(mutations => {
- // These are only called so I can check what the module output looks like. The
- // code is unreachable.
- clientRenderBoundary();
- completeBoundaryWithStyles();
- completeBoundary();
- completeSegment();
-});
+if (!window.$RC) {
+ // TODO: Eventually remove, we currently need to set these globals for
+ // compatibility with ReactDOMFizzInstructionSet
+ window.$RC = completeBoundary;
+ window.$RM = new Map();
+}
+
+if (document.readyState === 'loading') {
+ if (document.body != null) {
+ installFizzInstrObserver(document.body);
+ } else {
+ // body may not exist yet if the fizz runtime is sent in
+ // (e.g. as a preinit resource)
+ const domBodyObserver = new MutationObserver(() => {
+ // We expect the body node to be stable once parsed / created
+ if (document.body) {
+ if (document.readyState === 'loading') {
+ installFizzInstrObserver(document.body);
+ }
+ handleExistingNodes();
+ domBodyObserver.disconnect();
+ }
+ });
+ // documentElement must already exist at this point
+ // $FlowFixMe[incompatible-call]
+ domBodyObserver.observe(document.documentElement, {childList: true});
+ }
+}
+
+handleExistingNodes();
+
+function handleExistingNodes() {
+ const existingNodes = document.getElementsByTagName('template');
+ for (let i = 0; i < existingNodes.length; i++) {
+ handleNode(existingNodes[i]);
+ }
+}
+
+function installFizzInstrObserver(target /*: Node */) {
+ const fizzInstrObserver = new MutationObserver(mutations => {
+ for (let i = 0; i < mutations.length; i++) {
+ const addedNodes = mutations[i].addedNodes;
+ for (let j = 0; j < addedNodes.length; j++) {
+ if (addedNodes.item(j).parentNode) {
+ handleNode(addedNodes.item(j));
+ }
+ }
+ }
+ });
+ // We assume that instruction data nodes are eventually appended to the
+ // body, even if Fizz is streaming to a shell / subtree.
+ fizzInstrObserver.observe(target, {
+ childList: true,
+ });
+ window.addEventListener('DOMContentLoaded', () => {
+ fizzInstrObserver.disconnect();
+ });
+}
+
+function handleNode(node_ /*: Node */) {
+ // $FlowFixMe[incompatible-cast]
+ if (node_.nodeType !== 1 || !(node_ /*: HTMLElement*/).dataset) {
+ return;
+ }
+ // $FlowFixMe[incompatible-cast]
+ const node = (node_ /*: HTMLElement*/);
+ const dataset = node.dataset;
+ if (dataset['rxi'] != null) {
+ clientRenderBoundary(
+ dataset['bid'],
+ dataset['dgst'],
+ dataset['msg'],
+ dataset['stck'],
+ );
+ node.remove();
+ } else if (dataset['rri'] != null) {
+ // Convert styles here, since its type is Array>
+ completeBoundaryWithStyles(
+ dataset['bid'],
+ dataset['sid'],
+ JSON.parse(dataset['sty']),
+ );
+ node.remove();
+ } else if (dataset['rci'] != null) {
+ completeBoundary(dataset['bid'], dataset['sid']);
+ node.remove();
+ } else if (dataset['rsi'] != null) {
+ completeSegment(dataset['sid'], dataset['pid']);
+ node.remove();
+ }
+}
diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
index 91d241e4e7929..c66292d801835 100644
--- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
+++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
@@ -63,6 +63,7 @@ import sanitizeURL from '../shared/sanitizeURL';
import isArray from 'shared/isArray';
import {
+ preinitImpl,
prepareToRenderResources,
finishRenderingResources,
resourcesFromElement,
@@ -105,22 +106,33 @@ export function cleanupAfterRender(previousDispatcher: mixed) {
// E.g. this can be used to distinguish legacy renderers from this modern one.
export const isPrimaryRenderer = true;
+export type StreamingFormat = 0 | 1;
+const ScriptStreamingFormat: StreamingFormat = 0;
+const DataStreamingFormat: StreamingFormat = 1;
+
// Per response, global state that is not contextual to the rendering subtree.
export type ResponseState = {
bootstrapChunks: Array,
- startInlineScript: PrecomputedChunk,
placeholderPrefix: PrecomputedChunk,
segmentPrefix: PrecomputedChunk,
boundaryPrefix: string,
idPrefix: string,
nextSuspenseID: number,
+ streamingFormat: StreamingFormat,
+ // state for script streaming format, unused if using external runtime / data
+ startInlineScript: PrecomputedChunk,
sentCompleteSegmentFunction: boolean,
sentCompleteBoundaryFunction: boolean,
sentClientRenderFunction: boolean,
- sentStyleInsertionFunction: boolean, // We allow the legacy renderer to extend this object.
+ sentStyleInsertionFunction: boolean,
+ // state for data streaming format
+ externalRuntimeConfig: BootstrapScriptDescriptor | null,
+ // We allow the legacy renderer to extend this object.
...
};
+const dataElementQuotedEnd = stringToPrecomputedChunk('">');
+
const startInlineScript = stringToPrecomputedChunk('');
@@ -154,6 +166,8 @@ export type BootstrapScriptDescriptor = {
integrity?: string,
};
// 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 createResponseState(
identifierPrefix: string | void,
nonce: string | void,
@@ -170,6 +184,8 @@ export function createResponseState(
'');
+const completeSegmentScriptEnd = stringToPrecomputedChunk('")');
+
+const completeSegmentData1 = stringToPrecomputedChunk(
+ '');
+const completeBoundaryScript3a = stringToPrecomputedChunk('",');
+const completeBoundaryScript3b = stringToPrecomputedChunk('"');
+const completeBoundaryScriptEnd = stringToPrecomputedChunk(')');
+
+const completeBoundaryData1 = stringToPrecomputedChunk(
+ '');
const clientRenderErrorScriptArgInterstitial = stringToPrecomputedChunk(',');
+const clientRenderScriptEnd = stringToPrecomputedChunk(')');
+
+const clientRenderData1 = stringToPrecomputedChunk(
+ '
+ return writeChunkAndReturn(destination, clientRenderDataEnd);
}
- return writeChunkAndReturn(destination, clientRenderScript2);
}
const regexForJSStringsInInstructionScripts = /[<\u2028\u2029]/g;
@@ -2598,7 +2738,22 @@ export function writeInitialResources(
destination: Destination,
resources: Resources,
responseState: ResponseState,
+ willFlushAllSegments: boolean,
): boolean {
+ // Write initially discovered resources after the shell completes
+ if (
+ enableFizzExternalRuntime &&
+ !willFlushAllSegments &&
+ responseState.externalRuntimeConfig
+ ) {
+ // If the root segment is incomplete due to suspended tasks
+ // (e.g. willFlushAllSegments = false) and we are using data
+ // streaming format, ensure the external runtime is sent.
+ // (User code could choose to send this even earlier by calling
+ // preinit(...), if they know they will suspend).
+ const {src, integrity} = responseState.externalRuntimeConfig;
+ preinitImpl(resources, src, {as: 'script', integrity});
+ }
function flushLinkResource(resource) {
if (!resource.flushed) {
pushLinkImpl(target, resource.props, responseState);
@@ -2839,7 +2994,10 @@ const arraySubsequentOpenBracket = stringToPrecomputedChunk(',[');
const arrayInterstitial = stringToPrecomputedChunk(',');
const arrayCloseBracket = stringToPrecomputedChunk(']');
-function writeStyleResourceDependencies(
+// This function writes a 2D array of strings to be embedded in javascript.
+// E.g.
+// [["JS_escaped_string1", "JS_escaped_string2"]]
+function writeStyleResourceDependenciesInJS(
destination: Destination,
boundaryResources: BoundaryResources,
): void {
@@ -2852,12 +3010,12 @@ function writeStyleResourceDependencies(
// should be ready before content is shown on the client
} else if (resource.flushed) {
writeChunk(destination, nextArrayOpenBrackChunk);
- writeStyleResourceDependencyHrefOnly(destination, resource.href);
+ writeStyleResourceDependencyHrefOnlyInJS(destination, resource.href);
writeChunk(destination, arrayCloseBracket);
nextArrayOpenBrackChunk = arraySubsequentOpenBracket;
} else {
writeChunk(destination, nextArrayOpenBrackChunk);
- writeStyleResourceDependency(
+ writeStyleResourceDependencyInJS(
destination,
resource.href,
resource.precedence,
@@ -2873,7 +3031,8 @@ function writeStyleResourceDependencies(
writeChunk(destination, arrayCloseBracket);
}
-function writeStyleResourceDependencyHrefOnly(
+/* Helper functions */
+function writeStyleResourceDependencyHrefOnlyInJS(
destination: Destination,
href: string,
) {
@@ -2889,7 +3048,7 @@ function writeStyleResourceDependencyHrefOnly(
);
}
-function writeStyleResourceDependency(
+function writeStyleResourceDependencyInJS(
destination: Destination,
href: string,
precedence: string,
@@ -2936,7 +3095,7 @@ function writeStyleResourceDependency(
);
// eslint-disable-next-line-no-fallthrough
default:
- writeStyleResourceAttribute(destination, propKey, propValue);
+ writeStyleResourceAttributeInJS(destination, propKey, propValue);
break;
}
}
@@ -2944,7 +3103,7 @@ function writeStyleResourceDependency(
return null;
}
-function writeStyleResourceAttribute(
+function writeStyleResourceAttributeInJS(
destination: Destination,
name: string,
value: string | boolean | number | Function | Object, // not null or undefined
@@ -3022,3 +3181,192 @@ function writeStyleResourceAttribute(
stringToChunk(escapeJSObjectForInstructionScripts(attributeValue)),
);
}
+
+// This function writes a 2D array of strings to be embedded in an attribute
+// value and read with JSON.parse in ReactDOMServerExternalRuntime.js
+// E.g.
+// [["JSON_escaped_string1", "JSON_escaped_string2"]]
+function writeStyleResourceDependenciesInAttr(
+ destination: Destination,
+ boundaryResources: BoundaryResources,
+): void {
+ writeChunk(destination, arrayFirstOpenBracket);
+
+ let nextArrayOpenBrackChunk = arrayFirstOpenBracket;
+ boundaryResources.forEach(resource => {
+ if (resource.inShell) {
+ // We can elide this dependency because it was flushed in the shell and
+ // should be ready before content is shown on the client
+ } else if (resource.flushed) {
+ writeChunk(destination, nextArrayOpenBrackChunk);
+ writeStyleResourceDependencyHrefOnlyInAttr(destination, resource.href);
+ writeChunk(destination, arrayCloseBracket);
+ nextArrayOpenBrackChunk = arraySubsequentOpenBracket;
+ } else {
+ writeChunk(destination, nextArrayOpenBrackChunk);
+ writeStyleResourceDependencyInAttr(
+ destination,
+ resource.href,
+ resource.precedence,
+ resource.props,
+ );
+ writeChunk(destination, arrayCloseBracket);
+ nextArrayOpenBrackChunk = arraySubsequentOpenBracket;
+
+ resource.flushed = true;
+ resource.hint.flushed = true;
+ }
+ });
+ writeChunk(destination, arrayCloseBracket);
+}
+
+/* Helper functions */
+function writeStyleResourceDependencyHrefOnlyInAttr(
+ destination: Destination,
+ href: string,
+) {
+ // We should actually enforce this earlier when the resource is created but for
+ // now we make sure we are actually dealing with a string here.
+ if (__DEV__) {
+ checkAttributeStringCoercion(href, 'href');
+ }
+ const coercedHref = '' + (href: any);
+ writeChunk(
+ destination,
+ stringToChunk(escapeTextForBrowser(JSON.stringify(coercedHref))),
+ );
+}
+
+function writeStyleResourceDependencyInAttr(
+ destination: Destination,
+ href: string,
+ precedence: string,
+ props: Object,
+) {
+ if (__DEV__) {
+ checkAttributeStringCoercion(href, 'href');
+ }
+ const coercedHref = '' + (href: any);
+ sanitizeURL(coercedHref);
+ writeChunk(
+ destination,
+ stringToChunk(escapeTextForBrowser(JSON.stringify(coercedHref))),
+ );
+
+ if (__DEV__) {
+ checkAttributeStringCoercion(precedence, 'precedence');
+ }
+ const coercedPrecedence = '' + (precedence: any);
+ writeChunk(destination, arrayInterstitial);
+ writeChunk(
+ destination,
+ stringToChunk(escapeTextForBrowser(JSON.stringify(coercedPrecedence))),
+ );
+
+ for (const propKey in props) {
+ if (hasOwnProperty.call(props, propKey)) {
+ const propValue = props[propKey];
+ if (propValue == null) {
+ continue;
+ }
+ switch (propKey) {
+ case 'href':
+ case 'rel':
+ case 'precedence':
+ case 'data-precedence': {
+ break;
+ }
+ case 'children':
+ case 'dangerouslySetInnerHTML':
+ throw new Error(
+ `${'link'} is a self-closing tag and must neither have \`children\` nor ` +
+ 'use `dangerouslySetInnerHTML`.',
+ );
+ // eslint-disable-next-line-no-fallthrough
+ default:
+ writeStyleResourceAttributeInAttr(destination, propKey, propValue);
+ break;
+ }
+ }
+ }
+ return null;
+}
+
+function writeStyleResourceAttributeInAttr(
+ destination: Destination,
+ name: string,
+ value: string | boolean | number | Function | Object, // not null or undefined
+): void {
+ let attributeName = name.toLowerCase();
+ let attributeValue;
+ switch (typeof value) {
+ case 'function':
+ case 'symbol':
+ return;
+ }
+
+ switch (name) {
+ // Reserved names
+ case 'innerHTML':
+ case 'dangerouslySetInnerHTML':
+ case 'suppressContentEditableWarning':
+ case 'suppressHydrationWarning':
+ case 'style':
+ // Ignored
+ return;
+
+ // Attribute renames
+ case 'className':
+ attributeName = 'class';
+ break;
+
+ // Booleans
+ case 'hidden':
+ if (value === false) {
+ return;
+ }
+ attributeValue = '';
+ break;
+
+ // Santized URLs
+ case 'src':
+ case 'href': {
+ if (__DEV__) {
+ checkAttributeStringCoercion(value, attributeName);
+ }
+ attributeValue = '' + (value: any);
+ sanitizeURL(attributeValue);
+ break;
+ }
+ default: {
+ if (!isAttributeNameSafe(name)) {
+ return;
+ }
+ }
+ }
+
+ if (
+ // shouldIgnoreAttribute
+ // We have already filtered out null/undefined and reserved words.
+ name.length > 2 &&
+ (name[0] === 'o' || name[0] === 'O') &&
+ (name[1] === 'n' || name[1] === 'N')
+ ) {
+ return;
+ }
+
+ if (__DEV__) {
+ checkAttributeStringCoercion(value, attributeName);
+ }
+ attributeValue = '' + (value: any);
+ writeChunk(destination, arrayInterstitial);
+ writeChunk(
+ destination,
+ stringToChunk(escapeTextForBrowser(JSON.stringify(attributeName))),
+ );
+ writeChunk(destination, arrayInterstitial);
+ writeChunk(
+ destination,
+ stringToChunk(escapeTextForBrowser(JSON.stringify(attributeValue))),
+ );
+}
diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
index 01e70273a656f..aa11451b1eeea 100644
--- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
+++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
@@ -7,7 +7,11 @@
* @flow
*/
-import type {FormatContext} from './ReactDOMServerFormatConfig';
+import type {
+ BootstrapScriptDescriptor,
+ FormatContext,
+ StreamingFormat,
+} from './ReactDOMServerFormatConfig';
import {
createResponseState as createResponseStateImpl,
@@ -31,16 +35,18 @@ export const isPrimaryRenderer = false;
export type ResponseState = {
// Keep this in sync with ReactDOMServerFormatConfig
bootstrapChunks: Array,
- startInlineScript: PrecomputedChunk,
placeholderPrefix: PrecomputedChunk,
segmentPrefix: PrecomputedChunk,
boundaryPrefix: string,
idPrefix: string,
nextSuspenseID: number,
+ streamingFormat: StreamingFormat,
+ startInlineScript: PrecomputedChunk,
sentCompleteSegmentFunction: boolean,
sentCompleteBoundaryFunction: boolean,
sentClientRenderFunction: boolean,
sentStyleInsertionFunction: boolean,
+ externalRuntimeConfig: BootstrapScriptDescriptor | null,
// This is an extra field for the legacy renderer
generateStaticMarkup: boolean,
};
@@ -48,21 +54,31 @@ export type ResponseState = {
export function createResponseState(
generateStaticMarkup: boolean,
identifierPrefix: string | void,
+ externalRuntimeConfig: string | BootstrapScriptDescriptor | void,
): ResponseState {
- const responseState = createResponseStateImpl(identifierPrefix, undefined);
+ const responseState = createResponseStateImpl(
+ identifierPrefix,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ externalRuntimeConfig,
+ );
return {
// Keep this in sync with ReactDOMServerFormatConfig
bootstrapChunks: responseState.bootstrapChunks,
- startInlineScript: responseState.startInlineScript,
placeholderPrefix: responseState.placeholderPrefix,
segmentPrefix: responseState.segmentPrefix,
boundaryPrefix: responseState.boundaryPrefix,
idPrefix: responseState.idPrefix,
nextSuspenseID: responseState.nextSuspenseID,
+ streamingFormat: responseState.streamingFormat,
+ startInlineScript: responseState.startInlineScript,
sentCompleteSegmentFunction: responseState.sentCompleteSegmentFunction,
sentCompleteBoundaryFunction: responseState.sentCompleteBoundaryFunction,
sentClientRenderFunction: responseState.sentClientRenderFunction,
sentStyleInsertionFunction: responseState.sentStyleInsertionFunction,
+ externalRuntimeConfig: responseState.externalRuntimeConfig,
// This is an extra field for the legacy renderer
generateStaticMarkup,
};
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 030e5093e52fe..b900f3452b4ee 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -8,7 +8,12 @@
*/
'use strict';
-import {replaceScriptsAndMove, mergeOptions} from '../test-utils/FizzTestUtils';
+import {
+ replaceScriptsAndMove,
+ mergeOptions,
+ stripExternalRuntimeInNodes,
+ withLoadingReadyState,
+} from '../test-utils/FizzTestUtils';
let JSDOM;
let Stream;
@@ -31,7 +36,7 @@ let container;
let buffer = '';
let hasErrored = false;
let fatalError = undefined;
-const renderOptions = {};
+let renderOptions;
describe('ReactDOMFizzServer', () => {
beforeEach(() => {
@@ -89,6 +94,12 @@ describe('ReactDOMFizzServer', () => {
hasErrored = true;
fatalError = error;
});
+
+ renderOptions = {};
+ if (gate(flags => flags.enableFizzExternalRuntime)) {
+ renderOptions.unstable_externalRuntimeSrc =
+ 'react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js';
+ }
});
function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
@@ -134,10 +145,13 @@ describe('ReactDOMFizzServer', () => {
fakeBody.innerHTML = bufferedContent;
const parent =
container.nodeName === '#document' ? container.body : container;
- while (fakeBody.firstChild) {
- const node = fakeBody.firstChild;
- await replaceScriptsAndMove(window, CSPnonce, node, parent);
- }
+
+ await withLoadingReadyState(async () => {
+ while (fakeBody.firstChild) {
+ const node = fakeBody.firstChild;
+ await replaceScriptsAndMove(window, CSPnonce, node, parent);
+ }
+ }, document);
}
async function actIntoEmptyDocument(callback) {
@@ -162,7 +176,9 @@ describe('ReactDOMFizzServer', () => {
document = jsdom.window.document;
container = document;
buffer = '';
- await replaceScriptsAndMove(window, CSPnonce, document.documentElement);
+ await withLoadingReadyState(async () => {
+ await replaceScriptsAndMove(window, CSPnonce, document.documentElement);
+ }, document);
}
function getVisibleChildren(element) {
@@ -595,7 +611,12 @@ describe('ReactDOMFizzServer', () => {
// Because there is no content inside the Suspense boundary that could've
// been written, we expect to not see any additional partial data flushed
// yet.
- expect(container.childNodes.length).toBe(1);
+ expect(
+ stripExternalRuntimeInNodes(
+ container.childNodes,
+ renderOptions.unstable_externalRuntimeSrc,
+ ).length,
+ ).toBe(1);
await act(async () => {
resolveElement({default: });
});
@@ -3490,7 +3511,10 @@ describe('ReactDOMFizzServer', () => {
,
);
expect(
- Array.from(document.getElementsByTagName('script')).map(n => n.outerHTML),
+ stripExternalRuntimeInNodes(
+ document.getElementsByTagName('script'),
+ renderOptions.unstable_externalRuntimeSrc,
+ ).map(n => n.outerHTML),
).toEqual([
'',
'',
@@ -3566,7 +3590,9 @@ describe('ReactDOMFizzServer', () => {
-
hello world
+
+
+
,
{
@@ -3576,17 +3602,68 @@ describe('ReactDOMFizzServer', () => {
pipe(writable);
});
+ // We want the external runtime to be sent in so the script can be
+ // fetched and executed as early as possible. For SSR pages using Suspense,
+ // this script execution would be render blocking.
+ expect(
+ Array.from(document.head.getElementsByTagName('script')).map(
+ n => n.outerHTML,
+ ),
+ ).toEqual(['']);
+
expect(getVisibleChildren(document)).toEqual(
-
-
hello world
-
+ loading...
,
);
- expect(
- Array.from(document.getElementsByTagName('script')).map(n => n.outerHTML),
- ).toEqual(['']);
+ });
+
+ // @gate enableFizzExternalRuntime
+ it('does not send script tags for SSR instructions when using the external runtime', async () => {
+ function App() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+ await actIntoEmptyDocument(() => {
+ const {pipe} = renderToPipeableStream();
+ pipe(writable);
+ });
+ await act(async () => {
+ resolveText('Hello');
+ });
+
+ // The only script elements sent should be from unstable_externalRuntimeSrc
+ expect(document.getElementsByTagName('script').length).toEqual(1);
+ });
+
+ it('does not send the external runtime for static pages', async () => {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = renderToPipeableStream(
+
+
+
+
hello world!
+
+ ,
+ );
+ pipe(writable);
+ });
+
+ // no scripts should be sent
+ expect(document.getElementsByTagName('script').length).toEqual(0);
+
+ // the html should be as-is
+ expect(document.documentElement.innerHTML).toEqual(
+ '
hello world!
',
+ );
});
it('#24384: Suspending should halt hydration warnings and not emit any if hydration completes successfully after unsuspending', async () => {
@@ -4527,12 +4604,21 @@ describe('ReactDOMFizzServer', () => {
await act(() => resolveText('Foo'));
- expect(container.firstElementChild.outerHTML).toEqual(
+ const div = stripExternalRuntimeInNodes(
+ container.children,
+ renderOptions.unstable_externalRuntimeSrc,
+ )[0];
+ expect(div.outerHTML).toEqual(
'
helloworld, Foo!
',
);
- // there are extra script nodes at the end of container
- expect(container.childNodes.length).toBe(5);
- const div = container.childNodes[1];
+ // there may be either:
+ // - an external runtime script and deleted nodes with data attributes
+ // - extra script nodes containing fizz instructions at the end of container
+ expect(
+ Array.from(container.childNodes).filter(e => e.tagName !== 'SCRIPT')
+ .length,
+ ).toBe(3);
+
expect(div.childNodes.length).toBe(3);
const b = div.childNodes[1];
expect(b.childNodes.length).toBe(2);
@@ -4582,9 +4668,12 @@ describe('ReactDOMFizzServer', () => {
);
await act(() => resolveText('ello'));
- expect(container.firstElementChild.outerHTML).toEqual(
- '