From 5aab6c820e4d9cce716fd1b716b150348a76a2ed Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 12 Feb 2019 15:33:50 -0800 Subject: [PATCH 1/4] Replace SSR fallback content with new suspense content --- ...DOMServerPartialHydration-test.internal.js | 52 +++++++++++++++++++ .../src/server/ReactPartialRenderer.js | 4 +- .../src/ReactFiberCompleteWork.js | 7 ++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 516fa404175..b99ef40f280 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -634,4 +634,56 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); expect(container.textContent).toBe('Hi'); }); + + it('replaces the fallback with client content if it is not rendered by the server', async () => { + let suspend = false; + let promise = new Promise(resolvePromise => {}); + let ref = React.createRef(); + + function Child() { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + 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(); + expect(() => jest.runAllTimers()).toWarnDev( + [ + 'Warning: Did not expect server HTML to contain the text node "Loading..." in
.', + ], + {withoutStack: true}, + ); + + expect(container.textContent).toBe('Hello'); + + let span = container.getElementsByTagName('span')[0]; + expect(ref.current).toBe(span); + }); }); diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 120403e89dc..425d4a97765 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -835,6 +835,7 @@ class ReactDOMServerRenderer { 'suspense fallback not found, something is broken', ); this.stack.push(fallbackFrame); + out[this.suspenseDepth] += ''; // Skip flushing output since we're switching to the fallback continue; } else { @@ -996,8 +997,7 @@ class ReactDOMServerRenderer { children: fallbackChildren, childIndex: 0, context: context, - footer: '', - out: '', + footer: '', }; const frame: Frame = { fallbackFrame, diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 506be3a0476..86d3ec7d864 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -710,7 +710,12 @@ function completeWork( const nextDidTimeout = nextState !== null; const prevDidTimeout = current !== null && current.memoizedState !== null; - if (current !== null && !nextDidTimeout && prevDidTimeout) { + if (current === null) { + // In cases where we didn't find a suitable hydration boundary we never + // downgraded this to a DehydratedSuspenseComponent, but we still need to + // pop the hydration state since we might be inside the insertion tree. + popHydrationState(workInProgress); + } else if (!nextDidTimeout && prevDidTimeout) { // We just switched from the fallback to the normal children. Delete // the fallback. // TODO: Would it be better to store the fallback fragment on From 37f74c4fa900db2ccb20b9db81ad437f77055a25 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 13 Feb 2019 10:38:48 -0800 Subject: [PATCH 2/4] The three states of a Dehydrated Suspense This introduces three states for dehydrated suspense boundaries Pending - This means that the tree is still waiting for additional data or to be populated with its final content. Fallback - This means that the tree has entered a permanent fallback state and no more data from the server is to be expected. This means that the client should take over and try to render instead. The fallback nodes will be deleted. Normal - The node has entered its final content and is now ready to be hydrated. --- ...DOMServerPartialHydration-test.internal.js | 7 +--- .../ReactDOMServerSuspense-test.internal.js | 4 +- .../src/client/ReactDOMHostConfig.js | 24 +++++++++-- .../src/server/ReactPartialRenderer.js | 4 +- .../src/ReactFiberBeginWork.js | 40 +++++++++++++++---- .../src/ReactFiberHydrationContext.js | 12 ++++++ .../src/forks/ReactFiberHostConfig.custom.js | 4 ++ packages/shared/HostConfigWithNoHydration.js | 2 + 8 files changed, 77 insertions(+), 20 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index b99ef40f280..52ae8aa25ae 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -674,12 +674,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = false; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); - expect(() => jest.runAllTimers()).toWarnDev( - [ - 'Warning: Did not expect server HTML to contain the text node "Loading..." in
.', - ], - {withoutStack: true}, - ); + jest.runAllTimers(); expect(container.textContent).toBe('Hello'); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js index 06c8a9151c1..a6a3cc37bd7 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js @@ -94,6 +94,8 @@ describe('ReactDOMServerSuspense', () => { ); const e = c.children[0]; - expect(e.innerHTML).toBe('
Children
Fallback
'); + expect(e.innerHTML).toBe( + '
Children
Fallback
', + ); }); }); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 5462c42bacf..175a3103cb5 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -89,6 +89,8 @@ if (__DEV__) { const SUSPENSE_START_DATA = '$'; const SUSPENSE_END_DATA = '/$'; +const SUSPENSE_PENDING_START_DATA = '$?'; +const SUSPENSE_FALLBACK_START_DATA = '$!'; const STYLE = 'style'; @@ -458,7 +460,11 @@ export function clearSuspenseBoundary( } else { depth--; } - } else if (data === SUSPENSE_START_DATA) { + } else if ( + data === SUSPENSE_START_DATA || + data === SUSPENSE_PENDING_START_DATA || + data === SUSPENSE_FALLBACK_START_DATA + ) { depth++; } } @@ -554,6 +560,14 @@ export function canHydrateSuspenseInstance( return ((instance: any): SuspenseInstance); } +export function isSuspenseInstancePending(instance: SuspenseInstance) { + return instance.data === SUSPENSE_PENDING_START_DATA; +} + +export function isSuspenseInstanceFallback(instance: SuspenseInstance) { + return instance.data === SUSPENSE_FALLBACK_START_DATA; +} + export function getNextHydratableSibling( instance: HydratableInstance, ): null | HydratableInstance { @@ -565,7 +579,9 @@ export function getNextHydratableSibling( node.nodeType !== TEXT_NODE && (!enableSuspenseServerRenderer || node.nodeType !== COMMENT_NODE || - (node: any).data !== SUSPENSE_START_DATA) + ((node: any).data !== SUSPENSE_START_DATA && + (node: any).data !== SUSPENSE_PENDING_START_DATA && + (node: any).data !== SUSPENSE_FALLBACK_START_DATA)) ) { node = node.nextSibling; } @@ -583,7 +599,9 @@ export function getFirstHydratableChild( next.nodeType !== TEXT_NODE && (!enableSuspenseServerRenderer || next.nodeType !== COMMENT_NODE || - (next: any).data !== SUSPENSE_START_DATA) + ((next: any).data !== SUSPENSE_START_DATA && + (next: any).data !== SUSPENSE_FALLBACK_START_DATA && + (next: any).data !== SUSPENSE_PENDING_START_DATA)) ) { next = next.nextSibling; } diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 425d4a97765..142f932cf78 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -835,7 +835,7 @@ class ReactDOMServerRenderer { 'suspense fallback not found, something is broken', ); this.stack.push(fallbackFrame); - out[this.suspenseDepth] += ''; + out[this.suspenseDepth] += ''; // Skip flushing output since we're switching to the fallback continue; } else { @@ -997,7 +997,7 @@ class ReactDOMServerRenderer { children: fallbackChildren, childIndex: 0, context: context, - footer: '', + footer: '', }; const frame: Frame = { fallbackFrame, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 611554594fc..362094e191a 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -107,6 +107,8 @@ import { import { enterHydrationState, reenterHydrationStateFromDehydratedSuspenseInstance, + isDehydratedSuspenseComponentPending, + isDehydratedSuspenseComponentFallback, resetHydrationState, tryToClaimNextHydratableInstance, } from './ReactFiberHydrationContext'; @@ -1637,10 +1639,20 @@ function updateDehydratedSuspenseComponent( workInProgress.expirationTime = Never; return null; } + if ((workInProgress.effectTag & DidCapture) !== NoEffect) { + // Something suspended. Leave the existing children in place. + // TODO: In non-concurrent mode, should we commit the nodes we have hydrated so far? + workInProgress.child = null; + return null; + } // 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; - if (didReceiveUpdate || hasContextChanged) { + if ( + didReceiveUpdate || + hasContextChanged || + isDehydratedSuspenseComponentFallback(current) + ) { // 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 @@ -1648,6 +1660,10 @@ function updateDehydratedSuspenseComponent( // 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; @@ -1677,8 +1693,21 @@ function updateDehydratedSuspenseComponent( workInProgress.effectTag |= Placement; // Retry as a real Suspense component. return updateSuspenseComponent(null, workInProgress, renderExpirationTime); - } - if ((workInProgress.effectTag & DidCapture) === NoEffect) { + } else if (isDehydratedSuspenseComponentPending(current)) { + // 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 + // we could just try to render it client-side instead. However, this will perform a + // lot of unnecessary work and is unlikely to complete since it often will suspend + // on missing data anyway. Additionally, the server might be able to render more + // than we can on the client yet. In that case we'd end up with more fallback states + // on the client than if we just leave it alone. If the server times out or errors + // these should update this boundary to the permanent Fallback state instead. + // Mark it as having captured (i.e. suspended). + workInProgress.effectTag |= DidCapture; + // Leave the children in place. I.e. empty. + workInProgress.child = null; + return null; + } else { // This is the first attempt. reenterHydrationStateFromDehydratedSuspenseInstance(workInProgress); const nextProps = workInProgress.pendingProps; @@ -1690,11 +1719,6 @@ function updateDehydratedSuspenseComponent( renderExpirationTime, ); return workInProgress.child; - } else { - // Something suspended. Leave the existing children in place. - // TODO: In non-concurrent mode, should we commit the nodes we have hydrated so far? - workInProgress.child = null; - return null; } } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 07647d523c4..61e59b7b02f 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -34,6 +34,8 @@ import { canHydrateInstance, canHydrateTextInstance, canHydrateSuspenseInstance, + isSuspenseInstancePending, + isSuspenseInstanceFallback, getNextHydratableSibling, getFirstHydratableChild, hydrateInstance, @@ -84,6 +86,14 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( return true; } +function isDehydratedSuspenseComponentPending(fiber: Fiber) { + return isSuspenseInstancePending((fiber.stateNode: SuspenseInstance)); +} + +function isDehydratedSuspenseComponentFallback(fiber: Fiber) { + return isSuspenseInstanceFallback((fiber.stateNode: SuspenseInstance)); +} + function deleteHydratableInstance( returnFiber: Fiber, instance: HydratableInstance, @@ -434,6 +444,8 @@ function resetHydrationState(): void { export { enterHydrationState, reenterHydrationStateFromDehydratedSuspenseInstance, + isDehydratedSuspenseComponentPending, + isDehydratedSuspenseComponentFallback, resetHydrationState, tryToClaimNextHydratableInstance, prepareToHydrateHostInstance, diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 1cb343db25e..dcd1b89fc8b 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -107,6 +107,10 @@ export const canHydrateInstance = $$$hostConfig.canHydrateInstance; export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance; export const canHydrateSuspenseInstance = $$$hostConfig.canHydrateSuspenseInstance; +export const isSuspenseInstancePending = + $$$hostConfig.isSuspenseInstancePending; +export const isSuspenseInstanceFallback = + $$$hostConfig.isSuspenseInstanceFallback; export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling; export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild; export const hydrateInstance = $$$hostConfig.hydrateInstance; diff --git a/packages/shared/HostConfigWithNoHydration.js b/packages/shared/HostConfigWithNoHydration.js index b8e57f80889..7446dc67aea 100644 --- a/packages/shared/HostConfigWithNoHydration.js +++ b/packages/shared/HostConfigWithNoHydration.js @@ -27,6 +27,8 @@ export const supportsHydration = false; export const canHydrateInstance = shim; export const canHydrateTextInstance = shim; export const canHydrateSuspenseInstance = shim; +export const isSuspenseInstancePending = shim; +export const isSuspenseInstanceFallback = shim; export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; export const hydrateInstance = shim; From dea6d03587da16a321c89b5403f7430be4b5d84b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 18 Feb 2019 19:45:20 -0800 Subject: [PATCH 3/4] Rename retryTimedOutBoundary to resolveRetryThenable This doesn't just retry. It assumes that resolving a thenable means that it is ok to clear it from the thenable cache. We'll reuse the retryTimedOutBoundary logic separately. --- .../src/ReactFiberBeginWork.js | 10 ++++--- .../src/ReactFiberCommitWork.js | 4 +-- .../src/ReactFiberHydrationContext.js | 12 -------- .../src/ReactFiberScheduler.js | 28 +++++++++++-------- .../src/ReactFiberUnwindWork.js | 8 ++---- 5 files changed, 26 insertions(+), 36 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 362094e191a..f2b6c015872 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -84,7 +84,10 @@ import { import { shouldSetTextContent, shouldDeprioritizeSubtree, + isSuspenseInstancePending, + isSuspenseInstanceFallback, } from './ReactFiberHostConfig'; +import type {SuspenseInstance} from './ReactFiberHostConfig'; import {pushHostContext, pushHostContainer} from './ReactFiberHostContext'; import { pushProvider, @@ -107,8 +110,6 @@ import { import { enterHydrationState, reenterHydrationStateFromDehydratedSuspenseInstance, - isDehydratedSuspenseComponentPending, - isDehydratedSuspenseComponentFallback, resetHydrationState, tryToClaimNextHydratableInstance, } from './ReactFiberHydrationContext'; @@ -1648,10 +1649,11 @@ function updateDehydratedSuspenseComponent( // 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 || - isDehydratedSuspenseComponentFallback(current) + isSuspenseInstanceFallback(suspenseInstance) ) { // 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 @@ -1693,7 +1695,7 @@ function updateDehydratedSuspenseComponent( workInProgress.effectTag |= Placement; // Retry as a real Suspense component. return updateSuspenseComponent(null, workInProgress, renderExpirationTime); - } else if (isDehydratedSuspenseComponentPending(current)) { + } 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 // we could just try to render it client-side instead. However, this will perform a diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index cd402d0e3ac..13e79828a92 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -94,7 +94,7 @@ import { import { captureCommitPhaseError, requestCurrentTime, - retryTimedOutBoundary, + resolveRetryThenable, } from './ReactFiberScheduler'; import { NoEffect as NoHookEffect, @@ -1232,7 +1232,7 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { } thenables.forEach(thenable => { // Memoize using the boundary fiber to prevent redundant listeners. - let retry = retryTimedOutBoundary.bind(null, finishedWork, thenable); + let retry = resolveRetryThenable.bind(null, finishedWork, thenable); if (enableSchedulerTracing) { retry = Schedule_tracing_wrap(retry); } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 61e59b7b02f..07647d523c4 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -34,8 +34,6 @@ import { canHydrateInstance, canHydrateTextInstance, canHydrateSuspenseInstance, - isSuspenseInstancePending, - isSuspenseInstanceFallback, getNextHydratableSibling, getFirstHydratableChild, hydrateInstance, @@ -86,14 +84,6 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( return true; } -function isDehydratedSuspenseComponentPending(fiber: Fiber) { - return isSuspenseInstancePending((fiber.stateNode: SuspenseInstance)); -} - -function isDehydratedSuspenseComponentFallback(fiber: Fiber) { - return isSuspenseInstanceFallback((fiber.stateNode: SuspenseInstance)); -} - function deleteHydratableInstance( returnFiber: Fiber, instance: HydratableInstance, @@ -444,8 +434,6 @@ function resetHydrationState(): void { export { enterHydrationState, reenterHydrationStateFromDehydratedSuspenseInstance, - isDehydratedSuspenseComponentPending, - isDehydratedSuspenseComponentFallback, resetHydrationState, tryToClaimNextHydratableInstance, prepareToHydrateHostInstance, diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 9fe67f90aa7..0dc3823e910 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -1698,7 +1698,20 @@ function pingSuspendedRoot( } } -function retryTimedOutBoundary(boundaryFiber: Fiber, thenable: Thenable) { +function retryTimedOutBoundary(boundaryFiber: Fiber) { + const currentTime = requestCurrentTime(); + const retryTime = computeExpirationForFiber(currentTime, boundaryFiber); + const root = scheduleWorkToRoot(boundaryFiber, retryTime); + if (root !== null) { + markPendingPriorityLevel(root, retryTime); + const rootExpirationTime = root.expirationTime; + if (rootExpirationTime !== NoWork) { + requestWork(root, rootExpirationTime); + } + } +} + +function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) { // The boundary fiber (a Suspense component) previously timed out and was // rendered in its fallback state. One of the promises that suspended it has // resolved, which means at least part of the tree was likely unblocked. Try @@ -1729,16 +1742,7 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, thenable: Thenable) { retryCache.delete(thenable); } - const currentTime = requestCurrentTime(); - const retryTime = computeExpirationForFiber(currentTime, boundaryFiber); - const root = scheduleWorkToRoot(boundaryFiber, retryTime); - if (root !== null) { - markPendingPriorityLevel(root, retryTime); - const rootExpirationTime = root.expirationTime; - if (rootExpirationTime !== NoWork) { - requestWork(root, rootExpirationTime); - } - } + retryTimedOutBoundary(boundaryFiber); } function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null { @@ -2588,7 +2592,7 @@ export { renderDidSuspend, renderDidError, pingSuspendedRoot, - retryTimedOutBoundary, + resolveRetryThenable, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, scheduleWork, diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index e0cb09cb256..2d76a3b6b53 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -66,7 +66,7 @@ import { markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, pingSuspendedRoot, - retryTimedOutBoundary, + resolveRetryThenable, } from './ReactFiberScheduler'; import invariant from 'shared/invariant'; @@ -372,11 +372,7 @@ function throwException( // Memoize using the boundary fiber to prevent redundant listeners. if (!retryCache.has(thenable)) { retryCache.add(thenable); - let retry = retryTimedOutBoundary.bind( - null, - workInProgress, - thenable, - ); + let retry = resolveRetryThenable.bind(null, workInProgress, thenable); if (enableSchedulerTracing) { retry = Schedule_tracing_wrap(retry); } From 6d626fb4f9fdea0cdc01cd37db6fb6e458c08e57 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 18 Feb 2019 20:11:21 -0800 Subject: [PATCH 4/4] Register a callback to be fired when a boundary changes away from pending It's now possible to switch from a pending state to either hydrating or replacing the content. --- ...DOMServerPartialHydration-test.internal.js | 177 ++++++++++++++++++ .../src/client/ReactDOMHostConfig.js | 9 +- .../src/ReactFiberBeginWork.js | 7 + .../src/ReactFiberScheduler.js | 1 + .../src/forks/ReactFiberHostConfig.custom.js | 2 + packages/shared/HostConfigWithNoHydration.js | 1 + 6 files changed, 196 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 52ae8aa25ae..c0d18287499 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -681,4 +681,181 @@ describe('ReactDOMServerPartialHydration', () => { 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 => {}); + let ref = React.createRef(); + + function Child() { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + function App() { + return ( +
+ + + + + +
+ ); + } + + // We're going to simulate what Fizz will do during streaming rendering. + + // First we generate the HTML of the loading state. + suspend = true; + let loadingHTML = ReactDOMServer.renderToString(); + // Then we generate the HTML of the final content. + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + + let container = document.createElement('div'); + container.innerHTML = loadingHTML; + + let suspenseNode = container.firstChild.firstChild; + expect(suspenseNode.nodeType).toBe(8); + // Put the suspense node in hydration state. + suspenseNode.data = '$?'; + + // This will simulates new content streaming into the document and + // replacing the fallback with final content. + function streamInContent() { + let temp = document.createElement('div'); + temp.innerHTML = finalHTML; + let finalSuspenseNode = temp.firstChild.firstChild; + let fallbackContent = suspenseNode.nextSibling; + let finalContent = finalSuspenseNode.nextSibling; + suspenseNode.parentNode.replaceChild(finalContent, fallbackContent); + suspenseNode.data = '$'; + if (suspenseNode._reactRetry) { + suspenseNode._reactRetry(); + } + } + + // We're still showing a fallback. + expect(container.getElementsByTagName('span').length).toBe(0); + + // Attempt to hydrate the content. + suspend = false; + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + jest.runAllTimers(); + + // We're still loading because we're waiting for the server to stream more content. + expect(container.textContent).toBe('Loading...'); + + // The server now updates the content in place in the fallback. + streamInContent(); + + // The final HTML is now in place. + expect(container.textContent).toBe('Hello'); + let span = container.getElementsByTagName('span')[0]; + + // But it is not yet hydrated. + expect(ref.current).toBe(null); + + jest.runAllTimers(); + + // Now it's hydrated. + expect(ref.current).toBe(span); + }); + + it('handles an error on the client if the server ends up erroring', async () => { + let suspend = false; + let promise = new Promise(resolvePromise => {}); + let ref = React.createRef(); + + function Child() { + if (suspend) { + throw promise; + } else { + throw new Error('Error Message'); + } + } + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return
{this.state.error.message}
; + } + return this.props.children; + } + } + + function App() { + return ( + +
+ + + + + +
+
+ ); + } + + // We're going to simulate what Fizz will do during streaming rendering. + + // First we generate the HTML of the loading state. + suspend = true; + let loadingHTML = ReactDOMServer.renderToString(); + + let container = document.createElement('div'); + container.innerHTML = loadingHTML; + + let suspenseNode = container.firstChild.firstChild; + expect(suspenseNode.nodeType).toBe(8); + // Put the suspense node in hydration state. + suspenseNode.data = '$?'; + + // This will simulates the server erroring and putting the fallback + // as the final state. + function streamInError() { + suspenseNode.data = '$!'; + if (suspenseNode._reactRetry) { + suspenseNode._reactRetry(); + } + } + + // We're still showing a fallback. + expect(container.getElementsByTagName('span').length).toBe(0); + + // Attempt to hydrate the content. + suspend = false; + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + jest.runAllTimers(); + + // We're still loading because we're waiting for the server to stream more content. + expect(container.textContent).toBe('Loading...'); + + // The server now updates the content in place in the fallback. + streamInError(); + + // The server errored, but we still haven't hydrated. We don't know if the + // client will succeed yet, so we still show the loading state. + expect(container.textContent).toBe('Loading...'); + expect(ref.current).toBe(null); + + jest.runAllTimers(); + + // Hydrating should've generated an error and replaced the suspense boundary. + expect(container.textContent).toBe('Error Message'); + + let div = container.getElementsByTagName('div')[0]; + expect(ref.current).toBe(div); + }); }); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 175a3103cb5..7196a28e80c 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -56,7 +56,7 @@ export type Props = { export type Container = Element | Document; export type Instance = Element; export type TextInstance = Text; -export type SuspenseInstance = Comment; +export type SuspenseInstance = Comment & {_reactRetry?: () => void}; export type HydratableInstance = Instance | TextInstance | SuspenseInstance; export type PublicInstance = Element | Text; type HostContextDev = { @@ -568,6 +568,13 @@ export function isSuspenseInstanceFallback(instance: SuspenseInstance) { return instance.data === SUSPENSE_FALLBACK_START_DATA; } +export function registerSuspenseInstanceRetry( + instance: SuspenseInstance, + callback: () => void, +) { + instance._reactRetry = callback; +} + export function getNextHydratableSibling( instance: HydratableInstance, ): null | HydratableInstance { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index f2b6c015872..e615c98c9f1 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -86,6 +86,7 @@ import { shouldDeprioritizeSubtree, isSuspenseInstancePending, isSuspenseInstanceFallback, + registerSuspenseInstanceRetry, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {pushHostContext, pushHostContainer} from './ReactFiberHostContext'; @@ -132,6 +133,7 @@ import { createWorkInProgress, isSimpleFunctionComponent, } from './ReactFiber'; +import {retryTimedOutBoundary} from './ReactFiberScheduler'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -1708,6 +1710,11 @@ function updateDehydratedSuspenseComponent( workInProgress.effectTag |= DidCapture; // Leave the children in place. I.e. empty. workInProgress.child = null; + // Register a callback to retry this boundary once the server has sent the result. + registerSuspenseInstanceRetry( + suspenseInstance, + retryTimedOutBoundary.bind(null, current), + ); return null; } else { // This is the first attempt. diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 0dc3823e910..8ee9f00dbd3 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -2592,6 +2592,7 @@ export { renderDidSuspend, renderDidError, pingSuspendedRoot, + retryTimedOutBoundary, resolveRetryThenable, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index dcd1b89fc8b..7dde2231715 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -111,6 +111,8 @@ export const isSuspenseInstancePending = $$$hostConfig.isSuspenseInstancePending; export const isSuspenseInstanceFallback = $$$hostConfig.isSuspenseInstanceFallback; +export const registerSuspenseInstanceRetry = + $$$hostConfig.registerSuspenseInstanceRetry; export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling; export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild; export const hydrateInstance = $$$hostConfig.hydrateInstance; diff --git a/packages/shared/HostConfigWithNoHydration.js b/packages/shared/HostConfigWithNoHydration.js index 7446dc67aea..1be5f0b8a98 100644 --- a/packages/shared/HostConfigWithNoHydration.js +++ b/packages/shared/HostConfigWithNoHydration.js @@ -29,6 +29,7 @@ export const canHydrateTextInstance = shim; export const canHydrateSuspenseInstance = shim; export const isSuspenseInstancePending = shim; export const isSuspenseInstanceFallback = shim; +export const registerSuspenseInstanceRetry = shim; export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; export const hydrateInstance = shim;