@samitouri / QOS-React-1 / commits / 16d2bbbd1f

Client render dehydrated Suspense boundaries on document load (#31620)

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 remembering this subtle detail every time you create a test that asserts Suspense resolution order.

Josh Story committed Dec 3, 2024 at 13:13 UTC 16d2bbbd1f1617d636ea0fd271b902a12a763c27
10 files changed +445 -224
packages/internal-test-utils/ReactJSDOM.js new
+20
@@ -0,0 +1,20 @@
1 +const JSDOMModule = jest.requireActual('jsdom');
2 +
3 +const OriginalJSDOM = JSDOMModule.JSDOM;
4 +
5 +module.exports = JSDOMModule;
6 +module.exports.JSDOM = function JSDOM() {
7 + let result;
8 + if (new.target) {
9 + result = Reflect.construct(OriginalJSDOM, arguments);
10 + } else {
11 + result = JSDOM.apply(undefined, arguments);
12 + }
13 +
14 + require('./ReactJSDOMUtils').setupDocumentReadyState(
15 + result.window.document,
16 + result.window.Event,
17 + );
18 +
19 + return result;
20 +};
packages/internal-test-utils/ReactJSDOMUtils.js new
+33
@@ -0,0 +1,33 @@
1 +export function setupDocumentReadyState(
2 + document: Document,
3 + Event: typeof Event,
4 +) {
5 + let readyState: 0 | 1 | 2 = 0;
6 + Object.defineProperty(document, 'readyState', {
7 + get() {
8 + switch (readyState) {
9 + case 0:
10 + return 'loading';
11 + case 1:
12 + return 'interactive';
13 + case 2:
14 + return 'complete';
15 + }
16 + },
17 + set(value) {
18 + if (value === 'interactive' && readyState < 1) {
19 + readyState = 1;
20 + document.dispatchEvent(new Event('readystatechange'));
21 + } else if (value === 'complete' && readyState < 2) {
22 + readyState = 2;
23 + document.dispatchEvent(new Event('readystatechange'));
24 + document.dispatchEvent(new Event('DOMContentLoaded'));
25 + } else if (value === 'loading') {
26 + // We allow resetting the readyState to loading mostly for pragamtism.
27 + // tests that use this environment don't reset the document between tests.
28 + readyState = 0;
29 + }
30 + },
31 + configurable: true,
32 + });
33 +}
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+21 -1
@@ -194,6 +194,8 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
194 const FORM_STATE_IS_MATCHING = 'F!';
195 const FORM_STATE_IS_NOT_MATCHING = 'F';
196
197 +const DOCUMENT_READY_STATE_COMPLETE = 'complete';
198 +
199 const STYLE = 'style';
200
201 opaque type HostContextNamespace = 0 | 1 | 2;
@@ -1262,7 +1264,11 @@ export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
1264 export function isSuspenseInstanceFallback(
1265 instance: SuspenseInstance,
1266 ): boolean {
1265 - return instance.data === SUSPENSE_FALLBACK_START_DATA;
1267 + return (
1268 + instance.data === SUSPENSE_FALLBACK_START_DATA ||
1269 + (instance.data === SUSPENSE_PENDING_START_DATA &&
1270 + instance.ownerDocument.readyState === DOCUMENT_READY_STATE_COMPLETE)
1271 + );
1272 }
1273
1274 export function getSuspenseInstanceFallbackErrorDetails(
@@ -1303,6 +1309,20 @@ export function registerSuspenseInstanceRetry(
1309 instance: SuspenseInstance,
1310 callback: () => void,
1311 ) {
1312 + const ownerDocument = instance.ownerDocument;
1313 + if (ownerDocument.readyState !== DOCUMENT_READY_STATE_COMPLETE) {
1314 + ownerDocument.addEventListener(
1315 + 'DOMContentLoaded',
1316 + () => {
1317 + if (instance.data === SUSPENSE_PENDING_START_DATA) {
1318 + callback();
1319 + }
1320 + },
1321 + {
1322 + once: true,
1323 + },
1324 + );
1325 + }
1326 instance._reactRetry = callback;
1327 }
1328
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+256 -93
@@ -13,7 +13,6 @@ import {
13 insertNodesAndExecuteScripts,
14 mergeOptions,
15 stripExternalRuntimeInNodes,
16 - withLoadingReadyState,
16 getVisibleChildren,
17 } from '../test-utils/FizzTestUtils';
18
@@ -210,117 +209,111 @@ describe('ReactDOMFizzServer', () => {
209 return;
210 }
211
213 - await withLoadingReadyState(async () => {
214 - const bodyMatch = bufferedContent.match(bodyStartMatch);
215 - const headMatch = bufferedContent.match(headStartMatch);
216 -
217 - if (streamingContainer === null) {
218 - // This is the first streamed content. We decide here where to insert it. If we get <html>, <head>, or <body>
219 - // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
220 - // container. This is not really production behavior because you can't correctly stream into a deep div effectively
221 - // but it's pragmatic for tests.
222 -
223 - if (
224 - bufferedContent.startsWith('<head>') ||
225 - bufferedContent.startsWith('<head ') ||
226 - bufferedContent.startsWith('<body>') ||
227 - bufferedContent.startsWith('<body ')
228 - ) {
229 - // wrap in doctype to normalize the parsing process
230 - bufferedContent = '<!DOCTYPE html><html>' + bufferedContent;
231 - } else if (
232 - bufferedContent.startsWith('<html>') ||
233 - bufferedContent.startsWith('<html ')
234 - ) {
235 - throw new Error(
236 - 'Recieved <html> without a <!DOCTYPE html> which is almost certainly a bug in React',
237 - );
238 - }
239 -
240 - if (bufferedContent.startsWith('<!DOCTYPE html>')) {
241 - // we can just use the whole document
242 - const tempDom = new JSDOM(bufferedContent);
243 -
244 - // Wipe existing head and body content
245 - document.head.innerHTML = '';
246 - document.body.innerHTML = '';
212 + const bodyMatch = bufferedContent.match(bodyStartMatch);
213 + const headMatch = bufferedContent.match(headStartMatch);
214 +
215 + if (streamingContainer === null) {
216 + // This is the first streamed content. We decide here where to insert it. If we get <html>, <head>, or <body>
217 + // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
218 + // container. This is not really production behavior because you can't correctly stream into a deep div effectively
219 + // but it's pragmatic for tests.
220 +
221 + if (
222 + bufferedContent.startsWith('<head>') ||
223 + bufferedContent.startsWith('<head ') ||
224 + bufferedContent.startsWith('<body>') ||
225 + bufferedContent.startsWith('<body ')
226 + ) {
227 + // wrap in doctype to normalize the parsing process
228 + bufferedContent = '<!DOCTYPE html><html>' + bufferedContent;
229 + } else if (
230 + bufferedContent.startsWith('<html>') ||
231 + bufferedContent.startsWith('<html ')
232 + ) {
233 + throw new Error(
234 + 'Recieved <html> without a <!DOCTYPE html> which is almost certainly a bug in React',
235 + );
236 + }
237
248 - // Copy the <html> attributes over
249 - const tempHtmlNode = tempDom.window.document.documentElement;
250 - for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
251 - const attr = tempHtmlNode.attributes[i];
252 - document.documentElement.setAttribute(attr.name, attr.value);
253 - }
238 + if (bufferedContent.startsWith('<!DOCTYPE html>')) {
239 + // we can just use the whole document
240 + const tempDom = new JSDOM(bufferedContent);
241
255 - if (headMatch) {
256 - // We parsed a head open tag. we need to copy head attributes and insert future
257 - // content into <head>
258 - streamingContainer = document.head;
259 - const tempHeadNode = tempDom.window.document.head;
260 - for (let i = 0; i < tempHeadNode.attributes.length; i++) {
261 - const attr = tempHeadNode.attributes[i];
262 - document.head.setAttribute(attr.name, attr.value);
263 - }
264 - const source = document.createElement('head');
265 - source.innerHTML = tempHeadNode.innerHTML;
266 - await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
267 - }
242 + // Wipe existing head and body content
243 + document.head.innerHTML = '';
244 + document.body.innerHTML = '';
245
269 - if (bodyMatch) {
270 - // We parsed a body open tag. we need to copy head attributes and insert future
271 - // content into <body>
272 - streamingContainer = document.body;
273 - const tempBodyNode = tempDom.window.document.body;
274 - for (let i = 0; i < tempBodyNode.attributes.length; i++) {
275 - const attr = tempBodyNode.attributes[i];
276 - document.body.setAttribute(attr.name, attr.value);
277 - }
278 - const source = document.createElement('body');
279 - source.innerHTML = tempBodyNode.innerHTML;
280 - await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
281 - }
246 + // Copy the <html> attributes over
247 + const tempHtmlNode = tempDom.window.document.documentElement;
248 + for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
249 + const attr = tempHtmlNode.attributes[i];
250 + document.documentElement.setAttribute(attr.name, attr.value);
251 + }
252
283 - if (!headMatch && !bodyMatch) {
284 - throw new Error('expected <head> or <body> after <html>');
253 + if (headMatch) {
254 + // We parsed a head open tag. we need to copy head attributes and insert future
255 + // content into <head>
256 + streamingContainer = document.head;
257 + const tempHeadNode = tempDom.window.document.head;
258 + for (let i = 0; i < tempHeadNode.attributes.length; i++) {
259 + const attr = tempHeadNode.attributes[i];
260 + document.head.setAttribute(attr.name, attr.value);
261 }
286 - } else {
287 - // we assume we are streaming into the default container'
288 - streamingContainer = container;
289 - const div = document.createElement('div');
290 - div.innerHTML = bufferedContent;
291 - await insertNodesAndExecuteScripts(div, container, CSPnonce);
262 + const source = document.createElement('head');
263 + source.innerHTML = tempHeadNode.innerHTML;
264 + await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
265 }
293 - } else if (streamingContainer === document.head) {
294 - bufferedContent = '<!DOCTYPE html><html><head>' + bufferedContent;
295 - const tempDom = new JSDOM(bufferedContent);
296 -
297 - const tempHeadNode = tempDom.window.document.head;
298 - const source = document.createElement('head');
299 - source.innerHTML = tempHeadNode.innerHTML;
300 - await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
266
267 if (bodyMatch) {
268 + // We parsed a body open tag. we need to copy head attributes and insert future
269 + // content into <body>
270 streamingContainer = document.body;
304 -
271 const tempBodyNode = tempDom.window.document.body;
272 for (let i = 0; i < tempBodyNode.attributes.length; i++) {
273 const attr = tempBodyNode.attributes[i];
274 document.body.setAttribute(attr.name, attr.value);
275 }
310 - const bodySource = document.createElement('body');
311 - bodySource.innerHTML = tempBodyNode.innerHTML;
312 - await insertNodesAndExecuteScripts(
313 - bodySource,
314 - document.body,
315 - CSPnonce,
316 - );
276 + const source = document.createElement('body');
277 + source.innerHTML = tempBodyNode.innerHTML;
278 + await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
279 + }
280 +
281 + if (!headMatch && !bodyMatch) {
282 + throw new Error('expected <head> or <body> after <html>');
283 }
284 } else {
285 + // we assume we are streaming into the default container'
286 + streamingContainer = container;
287 const div = document.createElement('div');
288 div.innerHTML = bufferedContent;
321 - await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
289 + await insertNodesAndExecuteScripts(div, container, CSPnonce);
290 }
323 - }, document);
291 + } else if (streamingContainer === document.head) {
292 + bufferedContent = '<!DOCTYPE html><html><head>' + bufferedContent;
293 + const tempDom = new JSDOM(bufferedContent);
294 +
295 + const tempHeadNode = tempDom.window.document.head;
296 + const source = document.createElement('head');
297 + source.innerHTML = tempHeadNode.innerHTML;
298 + await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
299 +
300 + if (bodyMatch) {
301 + streamingContainer = document.body;
302 +
303 + const tempBodyNode = tempDom.window.document.body;
304 + for (let i = 0; i < tempBodyNode.attributes.length; i++) {
305 + const attr = tempBodyNode.attributes[i];
306 + document.body.setAttribute(attr.name, attr.value);
307 + }
308 + const bodySource = document.createElement('body');
309 + bodySource.innerHTML = tempBodyNode.innerHTML;
310 + await insertNodesAndExecuteScripts(bodySource, document.body, CSPnonce);
311 + }
312 + } else {
313 + const div = document.createElement('div');
314 + div.innerHTML = bufferedContent;
315 + await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
316 + }
317 }
318
319 function resolveText(text) {
@@ -8738,4 +8731,174 @@ describe('ReactDOMFizzServer', () => {
8731
8732 expect(caughtError.message).toBe('Maximum call stack size exceeded');
8733 });
8734 +
8735 + it('client renders incomplete Suspense boundaries when the document is no longer loading when hydration begins', async () => {
8736 + let resolve;
8737 + const promise = new Promise(r => {
8738 + resolve = r;
8739 + });
8740 +
8741 + function Blocking() {
8742 + React.use(promise);
8743 + return null;
8744 + }
8745 +
8746 + function App() {
8747 + return (
8748 + <div>
8749 + <p>outside</p>
8750 + <Suspense fallback={<p>loading...</p>}>
8751 + <Blocking />
8752 + <p>inside</p>
8753 + </Suspense>
8754 + </div>
8755 + );
8756 + }
8757 +
8758 + const errors = [];
8759 + await act(() => {
8760 + const {pipe} = renderToPipeableStream(<App />, {
8761 + onError(err) {
8762 + errors.push(err.message);
8763 + },
8764 + });
8765 + pipe(writable);
8766 + });
8767 +
8768 + expect(getVisibleChildren(container)).toEqual(
8769 + <div>
8770 + <p>outside</p>
8771 + <p>loading...</p>
8772 + </div>,
8773 + );
8774 +
8775 + await act(() => {
8776 + // We now end the stream and resolve the promise that was blocking the boundary
8777 + // Because the stream is ended it won't actually propagate to the client
8778 + writable.end();
8779 + document.readyState = 'complete';
8780 + resolve();
8781 + });
8782 + // ending the stream early will cause it to error on the server
8783 + expect(errors).toEqual([
8784 + expect.stringContaining('The destination stream closed early'),
8785 + ]);
8786 + expect(getVisibleChildren(container)).toEqual(
8787 + <div>
8788 + <p>outside</p>
8789 + <p>loading...</p>
8790 + </div>,
8791 + );
8792 +
8793 + const clientErrors = [];
8794 + ReactDOMClient.hydrateRoot(container, <App />, {
8795 + onRecoverableError(error, errorInfo) {
8796 + clientErrors.push(error.message);
8797 + },
8798 + });
8799 + await waitForAll([]);
8800 + // When we hydrate the client the document is already not loading
8801 + // so we client render the boundary in fallback
8802 + expect(getVisibleChildren(container)).toEqual(
8803 + <div>
8804 + <p>outside</p>
8805 + <p>inside</p>
8806 + </div>,
8807 + );
8808 + expect(clientErrors).toEqual([
8809 + expect.stringContaining(
8810 + 'The server could not finish this Suspense boundar',
8811 + ),
8812 + ]);
8813 + });
8814 +
8815 + it('client renders incomplete Suspense boundaries when the document stops loading during hydration', async () => {
8816 + let resolve;
8817 + const promise = new Promise(r => {
8818 + resolve = r;
8819 + });
8820 +
8821 + function Blocking() {
8822 + React.use(promise);
8823 + return null;
8824 + }
8825 +
8826 + function App() {
8827 + return (
8828 + <div>
8829 + <p>outside</p>
8830 + <Suspense fallback={<p>loading...</p>}>
8831 + <Blocking />
8832 + <p>inside</p>
8833 + </Suspense>
8834 + </div>
8835 + );
8836 + }
8837 +
8838 + const errors = [];
8839 + await act(() => {
8840 + const {pipe} = renderToPipeableStream(<App />, {
8841 + onError(err) {
8842 + errors.push(err.message);
8843 + },
8844 + });
8845 + pipe(writable);
8846 + });
8847 +
8848 + expect(getVisibleChildren(container)).toEqual(
8849 + <div>
8850 + <p>outside</p>
8851 + <p>loading...</p>
8852 + </div>,
8853 + );
8854 +
8855 + await act(() => {
8856 + // We now end the stream and resolve the promise that was blocking the boundary
8857 + // Because the stream is ended it won't actually propagate to the client
8858 + writable.end();
8859 + resolve();
8860 + });
8861 + // ending the stream early will cause it to error on the server
8862 + expect(errors).toEqual([
8863 + expect.stringContaining('The destination stream closed early'),
8864 + ]);
8865 + expect(getVisibleChildren(container)).toEqual(
8866 + <div>
8867 + <p>outside</p>
8868 + <p>loading...</p>
8869 + </div>,
8870 + );
8871 +
8872 + const clientErrors = [];
8873 + ReactDOMClient.hydrateRoot(container, <App />, {
8874 + onRecoverableError(error, errorInfo) {
8875 + clientErrors.push(error.message);
8876 + },
8877 + });
8878 + await waitForAll([]);
8879 + // When we hydrate the client is still waiting for the blocked boundary
8880 + // and won't client render unless the document is no longer loading
8881 + expect(getVisibleChildren(container)).toEqual(
8882 + <div>
8883 + <p>outside</p>
8884 + <p>loading...</p>
8885 + </div>,
8886 + );
8887 +
8888 + document.readyState = 'complete';
8889 + await waitForAll([]);
8890 + // Now that the document is no longer in loading readyState it will client
8891 + // render the boundary in fallback
8892 + expect(getVisibleChildren(container)).toEqual(
8893 + <div>
8894 + <p>outside</p>
8895 + <p>inside</p>
8896 + </div>,
8897 + );
8898 + expect(clientErrors).toEqual([
8899 + expect.stringContaining(
8900 + 'The server could not finish this Suspense boundar',
8901 + ),
8902 + ]);
8903 + });
8904 });
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+86 -93
@@ -12,7 +12,6 @@
12 import {
13 insertNodesAndExecuteScripts,
14 mergeOptions,
15 - withLoadingReadyState,
15 } from '../test-utils/FizzTestUtils';
16
17 let JSDOM;
@@ -126,117 +125,111 @@ describe('ReactDOMFloat', () => {
125 return;
126 }
127
129 - await withLoadingReadyState(async () => {
130 - const bodyMatch = bufferedContent.match(bodyStartMatch);
131 - const headMatch = bufferedContent.match(headStartMatch);
132 -
133 - if (streamingContainer === null) {
134 - // This is the first streamed content. We decide here where to insert it. If we get <html>, <head>, or <body>
135 - // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
136 - // container. This is not really production behavior because you can't correctly stream into a deep div effectively
137 - // but it's pragmatic for tests.
138 -
139 - if (
140 - bufferedContent.startsWith('<head>') ||
141 - bufferedContent.startsWith('<head ') ||
142 - bufferedContent.startsWith('<body>') ||
143 - bufferedContent.startsWith('<body ')
144 - ) {
145 - // wrap in doctype to normalize the parsing process
146 - bufferedContent = '<!DOCTYPE html><html>' + bufferedContent;
147 - } else if (
148 - bufferedContent.startsWith('<html>') ||
149 - bufferedContent.startsWith('<html ')
150 - ) {
151 - throw new Error(
152 - 'Recieved <html> without a <!DOCTYPE html> which is almost certainly a bug in React',
153 - );
154 - }
155 -
156 - if (bufferedContent.startsWith('<!DOCTYPE html>')) {
157 - // we can just use the whole document
158 - const tempDom = new JSDOM(bufferedContent);
159 -
160 - // Wipe existing head and body content
161 - document.head.innerHTML = '';
162 - document.body.innerHTML = '';
128 + const bodyMatch = bufferedContent.match(bodyStartMatch);
129 + const headMatch = bufferedContent.match(headStartMatch);
130 +
131 + if (streamingContainer === null) {
132 + // This is the first streamed content. We decide here where to insert it. If we get <html>, <head>, or <body>
133 + // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
134 + // container. This is not really production behavior because you can't correctly stream into a deep div effectively
135 + // but it's pragmatic for tests.
136 +
137 + if (
138 + bufferedContent.startsWith('<head>') ||
139 + bufferedContent.startsWith('<head ') ||
140 + bufferedContent.startsWith('<body>') ||
141 + bufferedContent.startsWith('<body ')
142 + ) {
143 + // wrap in doctype to normalize the parsing process
144 + bufferedContent = '<!DOCTYPE html><html>' + bufferedContent;
145 + } else if (
146 + bufferedContent.startsWith('<html>') ||
147 + bufferedContent.startsWith('<html ')
148 + ) {
149 + throw new Error(
150 + 'Recieved <html> without a <!DOCTYPE html> which is almost certainly a bug in React',
151 + );
152 + }
153
164 - // Copy the <html> attributes over
165 - const tempHtmlNode = tempDom.window.document.documentElement;
166 - for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
167 - const attr = tempHtmlNode.attributes[i];
168 - document.documentElement.setAttribute(attr.name, attr.value);
169 - }
154 + if (bufferedContent.startsWith('<!DOCTYPE html>')) {
155 + // we can just use the whole document
156 + const tempDom = new JSDOM(bufferedContent);
157
171 - if (headMatch) {
172 - // We parsed a head open tag. we need to copy head attributes and insert future
173 - // content into <head>
174 - streamingContainer = document.head;
175 - const tempHeadNode = tempDom.window.document.head;
176 - for (let i = 0; i < tempHeadNode.attributes.length; i++) {
177 - const attr = tempHeadNode.attributes[i];
178 - document.head.setAttribute(attr.name, attr.value);
179 - }
180 - const source = document.createElement('head');
181 - source.innerHTML = tempHeadNode.innerHTML;
182 - await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
183 - }
158 + // Wipe existing head and body content
159 + document.head.innerHTML = '';
160 + document.body.innerHTML = '';
161
185 - if (bodyMatch) {
186 - // We parsed a body open tag. we need to copy head attributes and insert future
187 - // content into <body>
188 - streamingContainer = document.body;
189 - const tempBodyNode = tempDom.window.document.body;
190 - for (let i = 0; i < tempBodyNode.attributes.length; i++) {
191 - const attr = tempBodyNode.attributes[i];
192 - document.body.setAttribute(attr.name, attr.value);
193 - }
194 - const source = document.createElement('body');
195 - source.innerHTML = tempBodyNode.innerHTML;
196 - await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
197 - }
162 + // Copy the <html> attributes over
163 + const tempHtmlNode = tempDom.window.document.documentElement;
164 + for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
165 + const attr = tempHtmlNode.attributes[i];
166 + document.documentElement.setAttribute(attr.name, attr.value);
167 + }
168
199 - if (!headMatch && !bodyMatch) {
200 - throw new Error('expected <head> or <body> after <html>');
169 + if (headMatch) {
170 + // We parsed a head open tag. we need to copy head attributes and insert future
171 + // content into <head>
172 + streamingContainer = document.head;
173 + const tempHeadNode = tempDom.window.document.head;
174 + for (let i = 0; i < tempHeadNode.attributes.length; i++) {
175 + const attr = tempHeadNode.attributes[i];
176 + document.head.setAttribute(attr.name, attr.value);
177 }
202 - } else {
203 - // we assume we are streaming into the default container'
204 - streamingContainer = container;
205 - const div = document.createElement('div');
206 - div.innerHTML = bufferedContent;
207 - await insertNodesAndExecuteScripts(div, container, CSPnonce);
178 + const source = document.createElement('head');
179 + source.innerHTML = tempHeadNode.innerHTML;
180 + await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
181 }
209 - } else if (streamingContainer === document.head) {
210 - bufferedContent = '<!DOCTYPE html><html><head>' + bufferedContent;
211 - const tempDom = new JSDOM(bufferedContent);
212 -
213 - const tempHeadNode = tempDom.window.document.head;
214 - const source = document.createElement('head');
215 - source.innerHTML = tempHeadNode.innerHTML;
216 - await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
182
183 if (bodyMatch) {
184 + // We parsed a body open tag. we need to copy head attributes and insert future
185 + // content into <body>
186 streamingContainer = document.body;
220 -
187 const tempBodyNode = tempDom.window.document.body;
188 for (let i = 0; i < tempBodyNode.attributes.length; i++) {
189 const attr = tempBodyNode.attributes[i];
190 document.body.setAttribute(attr.name, attr.value);
191 }
226 - const bodySource = document.createElement('body');
227 - bodySource.innerHTML = tempBodyNode.innerHTML;
228 - await insertNodesAndExecuteScripts(
229 - bodySource,
230 - document.body,
231 - CSPnonce,
232 - );
192 + const source = document.createElement('body');
193 + source.innerHTML = tempBodyNode.innerHTML;
194 + await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
195 + }
196 +
197 + if (!headMatch && !bodyMatch) {
198 + throw new Error('expected <head> or <body> after <html>');
199 }
200 } else {
201 + // we assume we are streaming into the default container'
202 + streamingContainer = container;
203 const div = document.createElement('div');
204 div.innerHTML = bufferedContent;
237 - await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
205 + await insertNodesAndExecuteScripts(div, container, CSPnonce);
206 }
239 - }, document);
207 + } else if (streamingContainer === document.head) {
208 + bufferedContent = '<!DOCTYPE html><html><head>' + bufferedContent;
209 + const tempDom = new JSDOM(bufferedContent);
210 +
211 + const tempHeadNode = tempDom.window.document.head;
212 + const source = document.createElement('head');
213 + source.innerHTML = tempHeadNode.innerHTML;
214 + await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
215 +
216 + if (bodyMatch) {
217 + streamingContainer = document.body;
218 +
219 + const tempBodyNode = tempDom.window.document.body;
220 + for (let i = 0; i < tempBodyNode.attributes.length; i++) {
221 + const attr = tempBodyNode.attributes[i];
222 + document.body.setAttribute(attr.name, attr.value);
223 + }
224 + const bodySource = document.createElement('body');
225 + bodySource.innerHTML = tempBodyNode.innerHTML;
226 + await insertNodesAndExecuteScripts(bodySource, document.body, CSPnonce);
227 + }
228 + } else {
229 + const div = document.createElement('div');
230 + div.innerHTML = bufferedContent;
231 + await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
232 + }
233 }
234
235 function getMeaningfulChildren(element) {
packages/react-dom/src/test-utils/FizzTestUtils.js
-34
@@ -139,39 +139,6 @@ function stripExternalRuntimeInNodes(
139 );
140 }
141
142 -// Since JSDOM doesn't implement a streaming HTML parser, we manually overwrite
143 -// readyState here (currently read by ReactDOMServerExternalRuntime). This does
144 -// not trigger event callbacks, but we do not rely on any right now.
145 -async function withLoadingReadyState<T>(
146 - fn: () => T,
147 - document: Document,
148 -): Promise<T> {
149 - // JSDOM implements readyState in document's direct prototype, but this may
150 - // change in later versions
151 - let prevDescriptor = null;
152 - let proto: Object = document;
153 - while (proto != null) {
154 - prevDescriptor = Object.getOwnPropertyDescriptor(proto, 'readyState');
155 - if (prevDescriptor != null) {
156 - break;
157 - }
158 - proto = Object.getPrototypeOf(proto);
159 - }
160 - Object.defineProperty(document, 'readyState', {
161 - get() {
162 - return 'loading';
163 - },
164 - configurable: true,
165 - });
166 - const result = await fn();
167 - // $FlowFixMe[incompatible-type]
168 - delete document.readyState;
169 - if (prevDescriptor) {
170 - Object.defineProperty(proto, 'readyState', prevDescriptor);
171 - }
172 - return result;
173 -}
174 -
142 function getVisibleChildren(element: Element): React$Node {
143 const children = [];
144 let node: any = element.firstChild;
@@ -218,6 +185,5 @@ export {
185 insertNodesAndExecuteScripts,
186 mergeOptions,
187 stripExternalRuntimeInNodes,
221 - withLoadingReadyState,
188 getVisibleChildren,
189 };
scripts/jest/ReactDOMServerIntegrationEnvironment.js
+2 -2
@@ -1,6 +1,6 @@
1 'use strict';
2
3 -const {TestEnvironment: JSDOMEnvironment} = require('jest-environment-jsdom');
3 +const ReactJSDOMEnvironment = require('./ReactJSDOMEnvironment');
4 const {TestEnvironment: NodeEnvironment} = require('jest-environment-node');
5
6 /**
@@ -10,7 +10,7 @@ class ReactDOMServerIntegrationEnvironment extends NodeEnvironment {
10 constructor(config, context) {
11 super(config, context);
12
13 - this.domEnvironment = new JSDOMEnvironment(config, context);
13 + this.domEnvironment = new ReactJSDOMEnvironment(config, context);
14
15 this.global.window = this.domEnvironment.dom.window;
16 this.global.document = this.global.window.document;
scripts/jest/ReactJSDOMEnvironment.js new
+19
@@ -0,0 +1,19 @@
1 +'use strict';
2 +
3 +const {TestEnvironment: JSDOMEnvironment} = require('jest-environment-jsdom');
4 +const {
5 + setupDocumentReadyState,
6 +} = require('internal-test-utils/ReactJSDOMUtils');
7 +
8 +/**
9 + * Test environment for testing integration of react-dom (browser) with react-dom/server (node)
10 + */
11 +class ReactJSDOMEnvironment extends JSDOMEnvironment {
12 + constructor(config, context) {
13 + super(config, context);
14 +
15 + setupDocumentReadyState(this.global.document, this.global.Event);
16 + }
17 +}
18 +
19 +module.exports = ReactJSDOMEnvironment;
scripts/jest/config.base.js
+1 -1
@@ -24,7 +24,7 @@ module.exports = {
24 },
25 snapshotSerializers: [require.resolve('jest-snapshot-serializer-raw')],
26
27 - testEnvironment: 'jsdom',
27 + testEnvironment: '<rootDir>/scripts/jest/ReactJSDOMEnvironment',
28
29 testRunner: 'jest-circus/runner',
30 };
scripts/jest/setupTests.js
+7
@@ -274,4 +274,11 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
274 const flags = getTestFlags();
275 return gateFn(flags);
276 };
277 +
278 + // We augment JSDOM to produce a document that has a loading readyState by default
279 + // and can be changed. We mock it here globally so we don't have to import our special
280 + // mock in every file.
281 + jest.mock('jsdom', () => {
282 + return require('internal-test-utils/ReactJSDOM.js');
283 + });
284 }