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;