diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts index 5936b1bc95bc4..8c9823765bbb6 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferEffectDependencies.ts @@ -55,6 +55,8 @@ export function inferEffectDependencies(fn: HIRFunction): void { {pruned: boolean; deps: ReactiveScopeDependencies; hasSingleInstr: boolean} >(); + const loadGlobals = new Set(); + /** * When inserting LoadLocals, we need to retain the reactivity of the base * identifier, as later passes e.g. PruneNonReactiveDeps take the reactivity of @@ -87,26 +89,23 @@ export function inferEffectDependencies(fn: HIRFunction): void { lvalue.identifier.id, instr as TInstruction, ); - } else if ( - value.kind === 'LoadGlobal' && - value.binding.kind === 'ImportSpecifier' - ) { - const moduleTargets = autodepFnConfigs.get(value.binding.module); - if (moduleTargets != null) { - const numRequiredArgs = moduleTargets.get(value.binding.imported); - if (numRequiredArgs != null) { - autodepFnLoads.set(lvalue.identifier.id, numRequiredArgs); - } - } - } else if ( - value.kind === 'LoadGlobal' && - value.binding.kind === 'ImportDefault' - ) { - const moduleTargets = autodepFnConfigs.get(value.binding.module); - if (moduleTargets != null) { - const numRequiredArgs = moduleTargets.get(DEFAULT_EXPORT); - if (numRequiredArgs != null) { - autodepFnLoads.set(lvalue.identifier.id, numRequiredArgs); + } else if (value.kind === 'LoadGlobal') { + loadGlobals.add(lvalue.identifier.id); + + if ( + value.binding.kind === 'ImportSpecifier' || + value.binding.kind === 'ImportDefault' + ) { + const moduleTargets = autodepFnConfigs.get(value.binding.module); + if (moduleTargets != null) { + const importSpecifierName = + value.binding.kind === 'ImportSpecifier' + ? value.binding.imported + : DEFAULT_EXPORT; + const numRequiredArgs = moduleTargets.get(importSpecifierName); + if (numRequiredArgs != null) { + autodepFnLoads.set(lvalue.identifier.id, numRequiredArgs); + } } } } else if ( @@ -117,8 +116,19 @@ export function inferEffectDependencies(fn: HIRFunction): void { autodepFnLoads.get(value.callee.identifier.id) === value.args.length && value.args[0].kind === 'Identifier' ) { + const effectDeps: Array = []; + const newInstructions: Array = []; + const deps: ArrayExpression = { + kind: 'ArrayExpression', + elements: effectDeps, + loc: GeneratedSource, + }; + const depsPlace = createTemporaryPlace(fn.env, GeneratedSource); + depsPlace.effect = Effect.Read; + const fnExpr = fnExpressions.get(value.args[0].identifier.id); if (fnExpr != null) { + // We have a function expression, so we can infer its dependencies const scopeInfo = fnExpr.lvalue.identifier.scope != null ? scopeInfos.get(fnExpr.lvalue.identifier.scope.id) @@ -140,14 +150,12 @@ export function inferEffectDependencies(fn: HIRFunction): void { } /** - * Step 1: write new instructions to insert a dependency array + * Step 1: push dependencies to the effect deps array * * Note that it's invalid to prune non-reactive deps in this pass, see * the `infer-effect-deps/pruned-nonreactive-obj` fixture for an * explanation. */ - const effectDeps: Array = []; - const newInstructions: Array = []; for (const dep of scopeInfo.deps) { const {place, instructions} = writeDependencyToInstructions( dep, @@ -158,14 +166,6 @@ export function inferEffectDependencies(fn: HIRFunction): void { newInstructions.push(...instructions); effectDeps.push(place); } - const deps: ArrayExpression = { - kind: 'ArrayExpression', - elements: effectDeps, - loc: GeneratedSource, - }; - - const depsPlace = createTemporaryPlace(fn.env, GeneratedSource); - depsPlace.effect = Effect.Read; newInstructions.push({ id: makeInstructionId(0), @@ -177,6 +177,16 @@ export function inferEffectDependencies(fn: HIRFunction): void { // Step 2: push the inferred deps array as an argument of the useEffect value.args.push({...depsPlace, effect: Effect.Freeze}); rewriteInstrs.set(instr.id, newInstructions); + } else if (loadGlobals.has(value.args[0].identifier.id)) { + // Global functions have no reactive dependencies, so we can insert an empty array + newInstructions.push({ + id: makeInstructionId(0), + loc: GeneratedSource, + lvalue: {...depsPlace, effect: Effect.Mutate}, + value: deps, + }); + value.args.push({...depsPlace, effect: Effect.Freeze}); + rewriteInstrs.set(instr.id, newInstructions); } } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/outlined-function.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/outlined-function.expect.md index 8272d4793fb8a..e941ac1462bbe 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/outlined-function.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/outlined-function.expect.md @@ -34,7 +34,7 @@ import { print } from "shared-runtime"; * before OutlineFunctions */ function OutlinedFunctionInEffect() { - useEffect(_temp); + useEffect(_temp, []); } function _temp() { return print("hello world!"); 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'); + }); }