diff --git a/packages/internal-test-utils/ReactJSDOM.js b/packages/internal-test-utils/ReactJSDOM.js
new file mode 100644
index 0000000000000..12ce623718227
--- /dev/null
+++ b/packages/internal-test-utils/ReactJSDOM.js
@@ -0,0 +1,20 @@
+const JSDOMModule = jest.requireActual('jsdom');
+
+const OriginalJSDOM = JSDOMModule.JSDOM;
+
+module.exports = JSDOMModule;
+module.exports.JSDOM = function JSDOM() {
+ let result;
+ if (new.target) {
+ result = Reflect.construct(OriginalJSDOM, arguments);
+ } else {
+ result = JSDOM.apply(undefined, arguments);
+ }
+
+ require('./ReactJSDOMUtils').setupDocumentReadyState(
+ result.window.document,
+ result.window.Event,
+ );
+
+ return result;
+};
diff --git a/packages/internal-test-utils/ReactJSDOMUtils.js b/packages/internal-test-utils/ReactJSDOMUtils.js
new file mode 100644
index 0000000000000..04a0d18fc4221
--- /dev/null
+++ b/packages/internal-test-utils/ReactJSDOMUtils.js
@@ -0,0 +1,33 @@
+export function setupDocumentReadyState(
+ document: Document,
+ Event: typeof Event,
+) {
+ let readyState: 0 | 1 | 2 = 0;
+ Object.defineProperty(document, 'readyState', {
+ get() {
+ switch (readyState) {
+ case 0:
+ return 'loading';
+ case 1:
+ return 'interactive';
+ case 2:
+ return 'complete';
+ }
+ },
+ set(value) {
+ if (value === 'interactive' && readyState < 1) {
+ readyState = 1;
+ document.dispatchEvent(new Event('readystatechange'));
+ } else if (value === 'complete' && readyState < 2) {
+ readyState = 2;
+ document.dispatchEvent(new Event('readystatechange'));
+ document.dispatchEvent(new Event('DOMContentLoaded'));
+ } else if (value === 'loading') {
+ // We allow resetting the readyState to loading mostly for pragamtism.
+ // tests that use this environment don't reset the document between tests.
+ readyState = 0;
+ }
+ },
+ configurable: true,
+ });
+}
diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
index bfa9adb48d737..bac5ec8fb601b 100644
--- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
const FORM_STATE_IS_MATCHING = 'F!';
const FORM_STATE_IS_NOT_MATCHING = 'F';
+const DOCUMENT_READY_STATE_COMPLETE = 'complete';
+
const STYLE = 'style';
opaque type HostContextNamespace = 0 | 1 | 2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
export function isSuspenseInstanceFallback(
instance: SuspenseInstance,
): boolean {
- return instance.data === SUSPENSE_FALLBACK_START_DATA;
+ return (
+ instance.data === SUSPENSE_FALLBACK_START_DATA ||
+ (instance.data === SUSPENSE_PENDING_START_DATA &&
+ instance.ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE)
+ );
}
export function getSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
instance: SuspenseInstance,
callback: () => void,
) {
+ const ownerDocument = instance.ownerDocument;
+ if (ownerDocument.readyState !== DOCUMENT_READY_STATE_COMPLETE) {
+ ownerDocument.addEventListener(
+ 'DOMContentLoaded',
+ () => {
+ if (instance.data === SUSPENSE_PENDING_START_DATA) {
+ callback();
+ }
+ },
+ {
+ once: true,
+ },
+ );
+ }
instance._reactRetry = callback;
}
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index a2ca222daf04b..8a8619ff87e7a 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -13,7 +13,6 @@ import {
insertNodesAndExecuteScripts,
mergeOptions,
stripExternalRuntimeInNodes,
- withLoadingReadyState,
getVisibleChildren,
} from '../test-utils/FizzTestUtils';
@@ -210,117 +209,111 @@ describe('ReactDOMFizzServer', () => {
return;
}
- await withLoadingReadyState(async () => {
- const bodyMatch = bufferedContent.match(bodyStartMatch);
- const headMatch = bufferedContent.match(headStartMatch);
-
- if (streamingContainer === null) {
- // This is the first streamed content. We decide here where to insert it. If we get ,
, or
- // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
- // container. This is not really production behavior because you can't correctly stream into a deep div effectively
- // but it's pragmatic for tests.
-
- if (
- bufferedContent.startsWith('') ||
- bufferedContent.startsWith('') ||
- bufferedContent.startsWith('') ||
- bufferedContent.startsWith(' without a which is almost certainly a bug in React',
- );
- }
-
- if (bufferedContent.startsWith('')) {
- // we can just use the whole document
- const tempDom = new JSDOM(bufferedContent);
-
- // Wipe existing head and body content
- document.head.innerHTML = '';
- document.body.innerHTML = '';
+ const bodyMatch = bufferedContent.match(bodyStartMatch);
+ const headMatch = bufferedContent.match(headStartMatch);
+
+ if (streamingContainer === null) {
+ // This is the first streamed content. We decide here where to insert it. If we get , , or
+ // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
+ // container. This is not really production behavior because you can't correctly stream into a deep div effectively
+ // but it's pragmatic for tests.
+
+ if (
+ bufferedContent.startsWith('') ||
+ bufferedContent.startsWith('') ||
+ bufferedContent.startsWith('') ||
+ bufferedContent.startsWith(' without a which is almost certainly a bug in React',
+ );
+ }
- // Copy the attributes over
- const tempHtmlNode = tempDom.window.document.documentElement;
- for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
- const attr = tempHtmlNode.attributes[i];
- document.documentElement.setAttribute(attr.name, attr.value);
- }
+ if (bufferedContent.startsWith('')) {
+ // we can just use the whole document
+ const tempDom = new JSDOM(bufferedContent);
- if (headMatch) {
- // We parsed a head open tag. we need to copy head attributes and insert future
- // content into
- streamingContainer = document.head;
- const tempHeadNode = tempDom.window.document.head;
- for (let i = 0; i < tempHeadNode.attributes.length; i++) {
- const attr = tempHeadNode.attributes[i];
- document.head.setAttribute(attr.name, attr.value);
- }
- const source = document.createElement('head');
- source.innerHTML = tempHeadNode.innerHTML;
- await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
- }
+ // Wipe existing head and body content
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
- if (bodyMatch) {
- // We parsed a body open tag. we need to copy head attributes and insert future
- // content into
- streamingContainer = document.body;
- const tempBodyNode = tempDom.window.document.body;
- for (let i = 0; i < tempBodyNode.attributes.length; i++) {
- const attr = tempBodyNode.attributes[i];
- document.body.setAttribute(attr.name, attr.value);
- }
- const source = document.createElement('body');
- source.innerHTML = tempBodyNode.innerHTML;
- await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
- }
+ // Copy the attributes over
+ const tempHtmlNode = tempDom.window.document.documentElement;
+ for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
+ const attr = tempHtmlNode.attributes[i];
+ document.documentElement.setAttribute(attr.name, attr.value);
+ }
- if (!headMatch && !bodyMatch) {
- throw new Error('expected or after ');
+ if (headMatch) {
+ // We parsed a head open tag. we need to copy head attributes and insert future
+ // content into
+ streamingContainer = document.head;
+ const tempHeadNode = tempDom.window.document.head;
+ for (let i = 0; i < tempHeadNode.attributes.length; i++) {
+ const attr = tempHeadNode.attributes[i];
+ document.head.setAttribute(attr.name, attr.value);
}
- } else {
- // we assume we are streaming into the default container'
- streamingContainer = container;
- const div = document.createElement('div');
- div.innerHTML = bufferedContent;
- await insertNodesAndExecuteScripts(div, container, CSPnonce);
+ const source = document.createElement('head');
+ source.innerHTML = tempHeadNode.innerHTML;
+ await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
}
- } else if (streamingContainer === document.head) {
- bufferedContent = '' + bufferedContent;
- const tempDom = new JSDOM(bufferedContent);
-
- const tempHeadNode = tempDom.window.document.head;
- const source = document.createElement('head');
- source.innerHTML = tempHeadNode.innerHTML;
- await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
if (bodyMatch) {
+ // We parsed a body open tag. we need to copy head attributes and insert future
+ // content into
streamingContainer = document.body;
-
const tempBodyNode = tempDom.window.document.body;
for (let i = 0; i < tempBodyNode.attributes.length; i++) {
const attr = tempBodyNode.attributes[i];
document.body.setAttribute(attr.name, attr.value);
}
- const bodySource = document.createElement('body');
- bodySource.innerHTML = tempBodyNode.innerHTML;
- await insertNodesAndExecuteScripts(
- bodySource,
- document.body,
- CSPnonce,
- );
+ const source = document.createElement('body');
+ source.innerHTML = tempBodyNode.innerHTML;
+ await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
+ }
+
+ if (!headMatch && !bodyMatch) {
+ throw new Error('expected or after ');
}
} else {
+ // we assume we are streaming into the default container'
+ streamingContainer = container;
const div = document.createElement('div');
div.innerHTML = bufferedContent;
- await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
+ await insertNodesAndExecuteScripts(div, container, CSPnonce);
}
- }, document);
+ } else if (streamingContainer === document.head) {
+ bufferedContent = '' + bufferedContent;
+ const tempDom = new JSDOM(bufferedContent);
+
+ const tempHeadNode = tempDom.window.document.head;
+ const source = document.createElement('head');
+ source.innerHTML = tempHeadNode.innerHTML;
+ await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
+
+ if (bodyMatch) {
+ streamingContainer = document.body;
+
+ const tempBodyNode = tempDom.window.document.body;
+ for (let i = 0; i < tempBodyNode.attributes.length; i++) {
+ const attr = tempBodyNode.attributes[i];
+ document.body.setAttribute(attr.name, attr.value);
+ }
+ const bodySource = document.createElement('body');
+ bodySource.innerHTML = tempBodyNode.innerHTML;
+ await insertNodesAndExecuteScripts(bodySource, document.body, CSPnonce);
+ }
+ } else {
+ const div = document.createElement('div');
+ div.innerHTML = bufferedContent;
+ await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
+ }
}
function resolveText(text) {
@@ -8738,4 +8731,174 @@ describe('ReactDOMFizzServer', () => {
expect(caughtError.message).toBe('Maximum call stack size exceeded');
});
+
+ it('client renders incomplete Suspense boundaries when the document is no longer loading when hydration begins', async () => {
+ let resolve;
+ const promise = new Promise(r => {
+ resolve = r;
+ });
+
+ function Blocking() {
+ React.use(promise);
+ return null;
+ }
+
+ function App() {
+ return (
+
+
outside
+
loading...}>
+
+ inside
+
+
+ );
+ }
+
+ const errors = [];
+ await act(() => {
+ const {pipe} = renderToPipeableStream(, {
+ onError(err) {
+ errors.push(err.message);
+ },
+ });
+ pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ await act(() => {
+ // We now end the stream and resolve the promise that was blocking the boundary
+ // Because the stream is ended it won't actually propagate to the client
+ writable.end();
+ document.readyState = 'complete';
+ resolve();
+ });
+ // ending the stream early will cause it to error on the server
+ expect(errors).toEqual([
+ expect.stringContaining('The destination stream closed early'),
+ ]);
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ const clientErrors = [];
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error, errorInfo) {
+ clientErrors.push(error.message);
+ },
+ });
+ await waitForAll([]);
+ // When we hydrate the client the document is already not loading
+ // so we client render the boundary in fallback
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ expect(clientErrors).toEqual([
+ expect.stringContaining(
+ 'The server could not finish this Suspense boundar',
+ ),
+ ]);
+ });
+
+ it('client renders incomplete Suspense boundaries when the document stops loading during hydration', async () => {
+ let resolve;
+ const promise = new Promise(r => {
+ resolve = r;
+ });
+
+ function Blocking() {
+ React.use(promise);
+ return null;
+ }
+
+ function App() {
+ return (
+
+
outside
+
loading...}>
+
+ inside
+
+
+ );
+ }
+
+ const errors = [];
+ await act(() => {
+ const {pipe} = renderToPipeableStream(, {
+ onError(err) {
+ errors.push(err.message);
+ },
+ });
+ pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ await act(() => {
+ // We now end the stream and resolve the promise that was blocking the boundary
+ // Because the stream is ended it won't actually propagate to the client
+ writable.end();
+ resolve();
+ });
+ // ending the stream early will cause it to error on the server
+ expect(errors).toEqual([
+ expect.stringContaining('The destination stream closed early'),
+ ]);
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ const clientErrors = [];
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(error, errorInfo) {
+ clientErrors.push(error.message);
+ },
+ });
+ await waitForAll([]);
+ // When we hydrate the client is still waiting for the blocked boundary
+ // and won't client render unless the document is no longer loading
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+
+ document.readyState = 'complete';
+ await waitForAll([]);
+ // Now that the document is no longer in loading readyState it will client
+ // render the boundary in fallback
+ expect(getVisibleChildren(container)).toEqual(
+ ,
+ );
+ expect(clientErrors).toEqual([
+ expect.stringContaining(
+ 'The server could not finish this Suspense boundar',
+ ),
+ ]);
+ });
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
index 5de0cc9e2f49c..360d51973579d 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
@@ -12,7 +12,6 @@
import {
insertNodesAndExecuteScripts,
mergeOptions,
- withLoadingReadyState,
} from '../test-utils/FizzTestUtils';
let JSDOM;
@@ -126,117 +125,111 @@ describe('ReactDOMFloat', () => {
return;
}
- await withLoadingReadyState(async () => {
- const bodyMatch = bufferedContent.match(bodyStartMatch);
- const headMatch = bufferedContent.match(headStartMatch);
-
- if (streamingContainer === null) {
- // This is the first streamed content. We decide here where to insert it. If we get , , or
- // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
- // container. This is not really production behavior because you can't correctly stream into a deep div effectively
- // but it's pragmatic for tests.
-
- if (
- bufferedContent.startsWith('') ||
- bufferedContent.startsWith('') ||
- bufferedContent.startsWith('') ||
- bufferedContent.startsWith(' without a which is almost certainly a bug in React',
- );
- }
-
- if (bufferedContent.startsWith('')) {
- // we can just use the whole document
- const tempDom = new JSDOM(bufferedContent);
-
- // Wipe existing head and body content
- document.head.innerHTML = '';
- document.body.innerHTML = '';
+ const bodyMatch = bufferedContent.match(bodyStartMatch);
+ const headMatch = bufferedContent.match(headStartMatch);
+
+ if (streamingContainer === null) {
+ // This is the first streamed content. We decide here where to insert it. If we get , , or
+ // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
+ // container. This is not really production behavior because you can't correctly stream into a deep div effectively
+ // but it's pragmatic for tests.
+
+ if (
+ bufferedContent.startsWith('') ||
+ bufferedContent.startsWith('') ||
+ bufferedContent.startsWith('') ||
+ bufferedContent.startsWith(' without a which is almost certainly a bug in React',
+ );
+ }
- // Copy the attributes over
- const tempHtmlNode = tempDom.window.document.documentElement;
- for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
- const attr = tempHtmlNode.attributes[i];
- document.documentElement.setAttribute(attr.name, attr.value);
- }
+ if (bufferedContent.startsWith('')) {
+ // we can just use the whole document
+ const tempDom = new JSDOM(bufferedContent);
- if (headMatch) {
- // We parsed a head open tag. we need to copy head attributes and insert future
- // content into
- streamingContainer = document.head;
- const tempHeadNode = tempDom.window.document.head;
- for (let i = 0; i < tempHeadNode.attributes.length; i++) {
- const attr = tempHeadNode.attributes[i];
- document.head.setAttribute(attr.name, attr.value);
- }
- const source = document.createElement('head');
- source.innerHTML = tempHeadNode.innerHTML;
- await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
- }
+ // Wipe existing head and body content
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
- if (bodyMatch) {
- // We parsed a body open tag. we need to copy head attributes and insert future
- // content into
- streamingContainer = document.body;
- const tempBodyNode = tempDom.window.document.body;
- for (let i = 0; i < tempBodyNode.attributes.length; i++) {
- const attr = tempBodyNode.attributes[i];
- document.body.setAttribute(attr.name, attr.value);
- }
- const source = document.createElement('body');
- source.innerHTML = tempBodyNode.innerHTML;
- await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
- }
+ // Copy the attributes over
+ const tempHtmlNode = tempDom.window.document.documentElement;
+ for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
+ const attr = tempHtmlNode.attributes[i];
+ document.documentElement.setAttribute(attr.name, attr.value);
+ }
- if (!headMatch && !bodyMatch) {
- throw new Error('expected or after ');
+ if (headMatch) {
+ // We parsed a head open tag. we need to copy head attributes and insert future
+ // content into
+ streamingContainer = document.head;
+ const tempHeadNode = tempDom.window.document.head;
+ for (let i = 0; i < tempHeadNode.attributes.length; i++) {
+ const attr = tempHeadNode.attributes[i];
+ document.head.setAttribute(attr.name, attr.value);
}
- } else {
- // we assume we are streaming into the default container'
- streamingContainer = container;
- const div = document.createElement('div');
- div.innerHTML = bufferedContent;
- await insertNodesAndExecuteScripts(div, container, CSPnonce);
+ const source = document.createElement('head');
+ source.innerHTML = tempHeadNode.innerHTML;
+ await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
}
- } else if (streamingContainer === document.head) {
- bufferedContent = '' + bufferedContent;
- const tempDom = new JSDOM(bufferedContent);
-
- const tempHeadNode = tempDom.window.document.head;
- const source = document.createElement('head');
- source.innerHTML = tempHeadNode.innerHTML;
- await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
if (bodyMatch) {
+ // We parsed a body open tag. we need to copy head attributes and insert future
+ // content into
streamingContainer = document.body;
-
const tempBodyNode = tempDom.window.document.body;
for (let i = 0; i < tempBodyNode.attributes.length; i++) {
const attr = tempBodyNode.attributes[i];
document.body.setAttribute(attr.name, attr.value);
}
- const bodySource = document.createElement('body');
- bodySource.innerHTML = tempBodyNode.innerHTML;
- await insertNodesAndExecuteScripts(
- bodySource,
- document.body,
- CSPnonce,
- );
+ const source = document.createElement('body');
+ source.innerHTML = tempBodyNode.innerHTML;
+ await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
+ }
+
+ if (!headMatch && !bodyMatch) {
+ throw new Error('expected or after ');
}
} else {
+ // we assume we are streaming into the default container'
+ streamingContainer = container;
const div = document.createElement('div');
div.innerHTML = bufferedContent;
- await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
+ await insertNodesAndExecuteScripts(div, container, CSPnonce);
}
- }, document);
+ } else if (streamingContainer === document.head) {
+ bufferedContent = '' + bufferedContent;
+ const tempDom = new JSDOM(bufferedContent);
+
+ const tempHeadNode = tempDom.window.document.head;
+ const source = document.createElement('head');
+ source.innerHTML = tempHeadNode.innerHTML;
+ await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
+
+ if (bodyMatch) {
+ streamingContainer = document.body;
+
+ const tempBodyNode = tempDom.window.document.body;
+ for (let i = 0; i < tempBodyNode.attributes.length; i++) {
+ const attr = tempBodyNode.attributes[i];
+ document.body.setAttribute(attr.name, attr.value);
+ }
+ const bodySource = document.createElement('body');
+ bodySource.innerHTML = tempBodyNode.innerHTML;
+ await insertNodesAndExecuteScripts(bodySource, document.body, CSPnonce);
+ }
+ } else {
+ const div = document.createElement('div');
+ div.innerHTML = bufferedContent;
+ await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
+ }
}
function getMeaningfulChildren(element) {
diff --git a/packages/react-dom/src/test-utils/FizzTestUtils.js b/packages/react-dom/src/test-utils/FizzTestUtils.js
index b709cc50d91e1..537c64a889a7d 100644
--- a/packages/react-dom/src/test-utils/FizzTestUtils.js
+++ b/packages/react-dom/src/test-utils/FizzTestUtils.js
@@ -139,39 +139,6 @@ function stripExternalRuntimeInNodes(
);
}
-// Since JSDOM doesn't implement a streaming HTML parser, we manually overwrite
-// readyState here (currently read by ReactDOMServerExternalRuntime). This does
-// not trigger event callbacks, but we do not rely on any right now.
-async function withLoadingReadyState(
- fn: () => T,
- document: Document,
-): Promise {
- // JSDOM implements readyState in document's direct prototype, but this may
- // change in later versions
- let prevDescriptor = null;
- let proto: Object = document;
- while (proto != null) {
- prevDescriptor = Object.getOwnPropertyDescriptor(proto, 'readyState');
- if (prevDescriptor != null) {
- break;
- }
- proto = Object.getPrototypeOf(proto);
- }
- Object.defineProperty(document, 'readyState', {
- get() {
- return 'loading';
- },
- configurable: true,
- });
- const result = await fn();
- // $FlowFixMe[incompatible-type]
- delete document.readyState;
- if (prevDescriptor) {
- Object.defineProperty(proto, 'readyState', prevDescriptor);
- }
- return result;
-}
-
function getVisibleChildren(element: Element): React$Node {
const children = [];
let node: any = element.firstChild;
@@ -218,6 +185,5 @@ export {
insertNodesAndExecuteScripts,
mergeOptions,
stripExternalRuntimeInNodes,
- withLoadingReadyState,
getVisibleChildren,
};
diff --git a/scripts/jest/ReactDOMServerIntegrationEnvironment.js b/scripts/jest/ReactDOMServerIntegrationEnvironment.js
index 1d92807ee8ddd..5a0ab2f665389 100644
--- a/scripts/jest/ReactDOMServerIntegrationEnvironment.js
+++ b/scripts/jest/ReactDOMServerIntegrationEnvironment.js
@@ -1,6 +1,6 @@
'use strict';
-const {TestEnvironment: JSDOMEnvironment} = require('jest-environment-jsdom');
+const ReactJSDOMEnvironment = require('./ReactJSDOMEnvironment');
const {TestEnvironment: NodeEnvironment} = require('jest-environment-node');
/**
@@ -10,7 +10,7 @@ class ReactDOMServerIntegrationEnvironment extends NodeEnvironment {
constructor(config, context) {
super(config, context);
- this.domEnvironment = new JSDOMEnvironment(config, context);
+ this.domEnvironment = new ReactJSDOMEnvironment(config, context);
this.global.window = this.domEnvironment.dom.window;
this.global.document = this.global.window.document;
diff --git a/scripts/jest/ReactJSDOMEnvironment.js b/scripts/jest/ReactJSDOMEnvironment.js
new file mode 100644
index 0000000000000..eb686d6f4be27
--- /dev/null
+++ b/scripts/jest/ReactJSDOMEnvironment.js
@@ -0,0 +1,19 @@
+'use strict';
+
+const {TestEnvironment: JSDOMEnvironment} = require('jest-environment-jsdom');
+const {
+ setupDocumentReadyState,
+} = require('internal-test-utils/ReactJSDOMUtils');
+
+/**
+ * Test environment for testing integration of react-dom (browser) with react-dom/server (node)
+ */
+class ReactJSDOMEnvironment extends JSDOMEnvironment {
+ constructor(config, context) {
+ super(config, context);
+
+ setupDocumentReadyState(this.global.document, this.global.Event);
+ }
+}
+
+module.exports = ReactJSDOMEnvironment;
diff --git a/scripts/jest/config.base.js b/scripts/jest/config.base.js
index 263010a87b451..6fa4a3619429f 100644
--- a/scripts/jest/config.base.js
+++ b/scripts/jest/config.base.js
@@ -24,7 +24,7 @@ module.exports = {
},
snapshotSerializers: [require.resolve('jest-snapshot-serializer-raw')],
- testEnvironment: 'jsdom',
+ testEnvironment: '/scripts/jest/ReactJSDOMEnvironment',
testRunner: 'jest-circus/runner',
};
diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js
index d339c86433a41..d642e77a2a09e 100644
--- a/scripts/jest/setupTests.js
+++ b/scripts/jest/setupTests.js
@@ -274,4 +274,11 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
const flags = getTestFlags();
return gateFn(flags);
};
+
+ // We augment JSDOM to produce a document that has a loading readyState by default
+ // and can be changed. We mock it here globally so we don't have to import our special
+ // mock in every file.
+ jest.mock('jsdom', () => {
+ return require('internal-test-utils/ReactJSDOM.js');
+ });
}