13
insertNodesAndExecuteScripts,
14
mergeOptions,
15
stripExternalRuntimeInNodes,
16
- withLoadingReadyState,
16
getVisibleChildren,
17
} from '../test-utils/FizzTestUtils';
18
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) {
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
});