From 0df3dc3c06aaa79f9126b7fae5c1066fc85cdf39 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 22 Nov 2024 10:44:30 -0800 Subject: [PATCH] Client render dehydrated Suspense boundaries on document load When streaming SSR while hydrating React will wait for Suspense boundaries to be revealed by the SSR stream before attempting to hydrate them. The rationale here is that the Server render is likely further ahead of whatever the client would produce so waiting to let the server stream in the UI is preferable to retrying on the client and possibly delaying how quickly the primary content becomes available. However If the connection closes early (user hits stop for instance) or there is a server error which prevents additional HTML from being delivered to the client this can put React into a broken state where the boundary never resolves nor errors and the hydration never retries that boundary freezing it in it's fallback state. Once the document has fully loaded we know there is not way any additional Suspense boundaries can arrive. This update changes react-dom on the client to schedule client renders for any unfinished Suspense boundaries upon document loading. The technique for client rendering a fallback is pretty straight forward. When hydrating a Suspense boundary if the Document is in 'complete' readyState we interpret pending boundaries as fallback boundaries. If the readyState is not 'complete' we register an event to retry the boundary when the DOMContentLoaded event fires. To test this I needed JSDOM to model readyState. We previously had a temporary implementation of readyState for SSR streaming but I ended up implementing this as a mock of JSDOM that implements a fake readyState that is mutable. It starts off in 'loading' readyState and you can advance it by mutating document.readyState. You can also reset it to 'loading'. It fires events when changing states. This seems like the least invasive way to get closer-to-real-browser behavior in a way that won't require remenbering this subtle detail every time you create a test that asserts Suspense resolution order. --- packages/internal-test-utils/ReactJSDOM.js | 20 + .../internal-test-utils/ReactJSDOMUtils.js | 33 ++ .../src/client/ReactFiberConfigDOM.js | 22 +- .../src/__tests__/ReactDOMFizzServer-test.js | 349 +++++++++++++----- .../src/__tests__/ReactDOMFloat-test.js | 179 +++++---- .../react-dom/src/test-utils/FizzTestUtils.js | 34 -- .../ReactDOMServerIntegrationEnvironment.js | 4 +- scripts/jest/ReactJSDOMEnvironment.js | 19 + scripts/jest/config.base.js | 2 +- scripts/jest/setupTests.js | 7 + 10 files changed, 445 insertions(+), 224 deletions(-) create mode 100644 packages/internal-test-utils/ReactJSDOM.js create mode 100644 packages/internal-test-utils/ReactJSDOMUtils.js create mode 100644 scripts/jest/ReactJSDOMEnvironment.js 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( +
+

outside

+

loading...

+
, + ); + + 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( +
+

outside

+

loading...

+
, + ); + + 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( +
+

outside

+

inside

+
, + ); + 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( +
+

outside

+

loading...

+
, + ); + + 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( +
+

outside

+

loading...

+
, + ); + + 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( +
+

outside

+

loading...

+
, + ); + + 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( +
+

outside

+

inside

+
, + ); + 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'); + }); }