diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
index 22e5f30f0009..b0e62e2fc789 100644
--- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
@@ -703,6 +703,126 @@ describe('ReactDOMServerPartialHydration', () => {
expect(ref.current).toBe(span);
});
+ it('replaces the fallback within the maxDuration if there is a nested suspense', async () => {
+ let suspend = false;
+ let promise = new Promise(resolvePromise => {});
+ let ref = React.createRef();
+
+ function Child() {
+ if (suspend) {
+ throw promise;
+ } else {
+ return 'Hello';
+ }
+ }
+
+ function InnerChild() {
+ // Always suspends indefinitely
+ throw promise;
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // First we render the final HTML. With the streaming renderer
+ // this may have suspense points on the server but here we want
+ // to test the completed HTML. Don't suspend on the server.
+ suspend = true;
+ let finalHTML = ReactDOMServer.renderToString();
+ let container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ expect(container.getElementsByTagName('span').length).toBe(0);
+
+ // On the client we have the data available quickly for some reason.
+ suspend = false;
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ root.render();
+ Scheduler.flushAll();
+ // This will have exceeded the maxDuration so we should timeout.
+ jest.advanceTimersByTime(500);
+ // The boundary should longer be suspended for the middle content
+ // even though the inner boundary is still suspended.
+
+ expect(container.textContent).toBe('Hello');
+
+ let span = container.getElementsByTagName('span')[0];
+ expect(ref.current).toBe(span);
+ });
+
+ it('replaces the fallback within the maxDuration if there is a nested suspense in a nested suspense', async () => {
+ let suspend = false;
+ let promise = new Promise(resolvePromise => {});
+ let ref = React.createRef();
+
+ function Child() {
+ if (suspend) {
+ throw promise;
+ } else {
+ return 'Hello';
+ }
+ }
+
+ function InnerChild() {
+ // Always suspends indefinitely
+ throw promise;
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // First we render the final HTML. With the streaming renderer
+ // this may have suspense points on the server but here we want
+ // to test the completed HTML. Don't suspend on the server.
+ suspend = true;
+ let finalHTML = ReactDOMServer.renderToString();
+ let container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ expect(container.getElementsByTagName('span').length).toBe(0);
+
+ // On the client we have the data available quickly for some reason.
+ suspend = false;
+ let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+ root.render();
+ Scheduler.flushAll();
+ // This will have exceeded the maxDuration so we should timeout.
+ jest.advanceTimersByTime(500);
+ // The boundary should longer be suspended for the middle content
+ // even though the inner boundary is still suspended.
+
+ expect(container.textContent).toBe('Hello');
+
+ let span = container.getElementsByTagName('span')[0];
+ expect(ref.current).toBe(span);
+ });
+
it('waits for pending content to come in from the server and then hydrates it', async () => {
let suspend = false;
let promise = new Promise(resolvePromise => {});
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index e615c98c9f16..f1b65633324c 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -74,7 +74,11 @@ import {
cloneChildFibers,
} from './ReactChildFiber';
import {processUpdateQueue} from './ReactUpdateQueue';
-import {NoWork, Never} from './ReactFiberExpirationTime';
+import {
+ NoWork,
+ Never,
+ computeAsyncExpiration,
+} from './ReactFiberExpirationTime';
import {
ConcurrentMode,
NoContext,
@@ -133,7 +137,7 @@ import {
createWorkInProgress,
isSimpleFunctionComponent,
} from './ReactFiber';
-import {retryTimedOutBoundary} from './ReactFiberScheduler';
+import {requestCurrentTime, retryTimedOutBoundary} from './ReactFiberScheduler';
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -1631,15 +1635,71 @@ function updateSuspenseComponent(
return next;
}
+function retrySuspenseComponentWithoutHydrating(
+ current: Fiber,
+ workInProgress: Fiber,
+ renderExpirationTime: ExpirationTime,
+) {
+ // Detach from the current dehydrated boundary.
+ current.alternate = null;
+ workInProgress.alternate = null;
+
+ // Insert a deletion in the effect list.
+ let returnFiber = workInProgress.return;
+ invariant(
+ returnFiber !== null,
+ 'Suspense boundaries are never on the root. ' +
+ 'This is probably a bug in React.',
+ );
+ const last = returnFiber.lastEffect;
+ if (last !== null) {
+ last.nextEffect = current;
+ returnFiber.lastEffect = current;
+ } else {
+ returnFiber.firstEffect = returnFiber.lastEffect = current;
+ }
+ current.nextEffect = null;
+ current.effectTag = Deletion;
+
+ // Upgrade this work in progress to a real Suspense component.
+ workInProgress.tag = SuspenseComponent;
+ workInProgress.stateNode = null;
+ workInProgress.memoizedState = null;
+ // This is now an insertion.
+ workInProgress.effectTag |= Placement;
+ // Retry as a real Suspense component.
+ return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
+}
+
function updateDehydratedSuspenseComponent(
current: Fiber | null,
workInProgress: Fiber,
renderExpirationTime: ExpirationTime,
) {
+ const suspenseInstance = (workInProgress.stateNode: SuspenseInstance);
if (current === null) {
// During the first pass, we'll bail out and not drill into the children.
// Instead, we'll leave the content in place and try to hydrate it later.
- workInProgress.expirationTime = Never;
+ if (isSuspenseInstanceFallback(suspenseInstance)) {
+ // This is a client-only boundary. Since we won't get any content from the server
+ // for this, we need to schedule that at a higher priority based on when it would
+ // have timed out. In theory we could render it in this pass but it would have the
+ // wrong priority associated with it and will prevent hydration of parent path.
+ // Instead, we'll leave work left on it to render it in a separate commit.
+
+ // TODO This time should be the time at which the server rendered response that is
+ // a parent to this boundary was displayed. However, since we currently don't have
+ // a protocol to transfer that time, we'll just estimate it by using the current
+ // time. This will mean that Suspense timeouts are slightly shifted to later than
+ // they should be.
+ let serverDisplayTime = requestCurrentTime();
+ // Schedule a normal pri update to render this content.
+ workInProgress.expirationTime = computeAsyncExpiration(serverDisplayTime);
+ } else {
+ // We'll continue hydrating the rest at offscreen priority since we'll already
+ // be showing the right content coming from the server, it is no rush.
+ workInProgress.expirationTime = Never;
+ }
return null;
}
if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
@@ -1648,55 +1708,31 @@ function updateDehydratedSuspenseComponent(
workInProgress.child = null;
return null;
}
+ if (isSuspenseInstanceFallback(suspenseInstance)) {
+ // This boundary is in a permanent fallback state. In this case, we'll never
+ // get an update and we'll never be able to hydrate the final content. Let's just try the
+ // client side render instead.
+ return retrySuspenseComponentWithoutHydrating(
+ current,
+ workInProgress,
+ renderExpirationTime,
+ );
+ }
// We use childExpirationTime to indicate that a child might depend on context, so if
// any context has changed, we need to treat is as if the input might have changed.
const hasContextChanged = current.childExpirationTime >= renderExpirationTime;
- const suspenseInstance = (current.stateNode: SuspenseInstance);
- if (
- didReceiveUpdate ||
- hasContextChanged ||
- isSuspenseInstanceFallback(suspenseInstance)
- ) {
+ if (didReceiveUpdate || hasContextChanged) {
// This boundary has changed since the first render. This means that we are now unable to
// hydrate it. We might still be able to hydrate it using an earlier expiration time but
// during this render we can't. Instead, we're going to delete the whole subtree and
// instead inject a new real Suspense boundary to take its place, which may render content
// or fallback. The real Suspense boundary will suspend for a while so we have some time
// to ensure it can produce real content, but all state and pending events will be lost.
-
- // Alternatively, this boundary is in a permanent fallback state. In this case, we'll never
- // get an update and we'll never be able to hydrate the final content. Let's just try the
- // client side render instead.
-
- // Detach from the current dehydrated boundary.
- current.alternate = null;
- workInProgress.alternate = null;
-
- // Insert a deletion in the effect list.
- let returnFiber = workInProgress.return;
- invariant(
- returnFiber !== null,
- 'Suspense boundaries are never on the root. ' +
- 'This is probably a bug in React.',
+ return retrySuspenseComponentWithoutHydrating(
+ current,
+ workInProgress,
+ renderExpirationTime,
);
- const last = returnFiber.lastEffect;
- if (last !== null) {
- last.nextEffect = current;
- returnFiber.lastEffect = current;
- } else {
- returnFiber.firstEffect = returnFiber.lastEffect = current;
- }
- current.nextEffect = null;
- current.effectTag = Deletion;
-
- // Upgrade this work in progress to a real Suspense component.
- workInProgress.tag = SuspenseComponent;
- workInProgress.stateNode = null;
- workInProgress.memoizedState = null;
- // This is now an insertion.
- workInProgress.effectTag |= Placement;
- // Retry as a real Suspense component.
- return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
} else if (isSuspenseInstancePending(suspenseInstance)) {
// This component is still pending more data from the server, so we can't hydrate its
// content. We treat it as if this component suspended itself. It might seem as if