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