diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js new file mode 100644 index 0000000000000..5c44db2c3c38c --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js @@ -0,0 +1,197 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +let JSDOM; +let React; +let ReactDOM; +let Scheduler; +let clientAct; +let ReactDOMFizzServer; +let Stream; +let document; +let writable; +let container; +let buffer = ''; +let hasErrored = false; +let fatalError = undefined; +let textCache; + +describe('useId', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + clientAct = require('jest-react').act; + ReactDOMFizzServer = require('react-dom/server'); + Stream = require('stream'); + + textCache = new Map(); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + async function serverAct(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + if (node.nodeName === 'SCRIPT') { + const script = document.createElement('script'); + script.textContent = node.textContent; + fakeBody.removeChild(node); + container.appendChild(script); + } else { + container.appendChild(node); + } + } + } + + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + } + + function readText(text) { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + throw record.value; + case 'rejected': + throw record.value; + case 'resolved': + return record.value; + } + } else { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + + throw thenable; + } + } + + // function Text({text}) { + // Scheduler.unstable_yieldValue(text); + // return text; + // } + + function AsyncText({text}) { + readText(text); + Scheduler.unstable_yieldValue(text); + return text; + } + + function resetTextCache() { + textCache = new Map(); + } + + test('suspending in the shell', async () => { + const div = React.createRef(null); + + function App() { + return ( +
+ +
+ ); + } + + // Server render + await resolveText('Shell'); + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(Scheduler).toHaveYielded(['Shell']); + const dehydratedDiv = container.getElementsByTagName('div')[0]; + + // Clear the cache and start rendering on the client + resetTextCache(); + + // Hydration suspends because the data for the shell hasn't loaded yet + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + expect(Scheduler).toHaveYielded(['Suspend! [Shell]']); + expect(div.current).toBe(null); + expect(container.textContent).toBe('Shell'); + + // The shell loads and hydration finishes + await clientAct(async () => { + await resolveText('Shell'); + }); + expect(Scheduler).toHaveYielded(['Shell']); + expect(div.current).toBe(dehydratedDiv); + expect(container.textContent).toBe('Shell'); + }); +}); diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 824f89e967707..c78f47775eb14 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -483,12 +483,17 @@ function throwException( // No boundary was found. If we're inside startTransition, this is OK. // We can suspend and wait for more data to arrive. - if (includesOnlyTransitions(rootRenderLanes)) { + if (includesOnlyTransitions(rootRenderLanes) || getIsHydrating()) { // This is a transition. Suspend. Since we're not activating a Suspense // boundary, this will unwind all the way to the root without performing // a second pass to render a fallback. (This is arguably how refresh // transitions should work, too, since we're not going to commit the // fallbacks anyway.) + // + // This case also applies to initial hydration. + // + // TODO: Maybe we should expand this branch to cover all non-sync + // updates, including default. attachPingListener(root, wakeable, rootRenderLanes); renderDidSuspendDelayIfPossible(); return; diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 86a43a4dcaf5a..d4777bbe0858c 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -483,12 +483,17 @@ function throwException( // No boundary was found. If we're inside startTransition, this is OK. // We can suspend and wait for more data to arrive. - if (includesOnlyTransitions(rootRenderLanes)) { + if (includesOnlyTransitions(rootRenderLanes) || getIsHydrating()) { // This is a transition. Suspend. Since we're not activating a Suspense // boundary, this will unwind all the way to the root without performing // a second pass to render a fallback. (This is arguably how refresh // transitions should work, too, since we're not going to commit the // fallbacks anyway.) + // + // This case also applies to initial hydration. + // + // TODO: Maybe we should expand this branch to cover all non-sync + // updates, including default. attachPingListener(root, wakeable, rootRenderLanes); renderDidSuspendDelayIfPossible(); return;