From 2af4a79333108f59600970ef23e2614c3a5324e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 18 Oct 2021 20:44:34 -0400 Subject: [PATCH] Hydrate using SuspenseComponent as the parent (#22582) * Add a failing test for Suspense hydration * Include salazarm's changes to the test * The hydration parent of a suspense boundary should be the boundary itself This eventually got set when we popped back out of its children but it doesn't start out that way. This fixes it so that the boundary parent is always the suspense boundary. * We now need to log errors with a suspense boundary as a parent For now, we just log this with commentNode.parentNode as the parent for purposes of the error message. * Make a special getFirstHydratableChildWithinSuspenseInstance We currently call getNextHydratableSibling but conceptually it's the child of the boundary. It just happens to be that when we use comment nodes, we need to call nextSibling in the DOM. This makes this separation a bit clearer. * Sync old fork Co-authored-by: Dan Abramov --- ...DOMServerPartialHydration-test.internal.js | 58 ++++++++++++++ .../src/client/ReactDOMHostConfig.js | 74 +++++++++++++++-- .../ReactFiberHostConfigWithNoHydration.js | 14 +++- .../src/ReactFiberHydrationContext.new.js | 80 ++++++++++++++++--- .../src/ReactFiberHydrationContext.old.js | 80 ++++++++++++++++--- .../src/forks/ReactFiberHostConfig.custom.js | 28 +++++-- 6 files changed, 293 insertions(+), 41 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index b1a88868b3389..4b647e00032bc 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -160,6 +160,64 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); }); + it('can hydrate siblings of a suspended component without errors', async () => { + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + function Child() { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + function App() { + return ( + + + +
Hello
+
+
+ ); + } + + // 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 = false; + const finalHTML = ReactDOMServer.renderToString(); + + const container = document.createElement('div'); + container.innerHTML = finalHTML; + expect(container.textContent).toBe('HelloHello'); + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + ReactDOM.hydrateRoot(container, ); + expect(() => { + Scheduler.unstable_flushAll(); + }).toErrorDev( + // TODO: This error should not be logged in this case. It's a false positive. + 'Did not expect server HTML to contain the text node "Hello" in
.', + ); + jest.runAllTimers(); + + // Expect the server-generated HTML to stay intact. + expect(container.textContent).toBe('HelloHello'); + + // Resolving the promise should continue hydration + suspend = false; + resolve(); + await promise; + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + // Hydration should not change anything. + expect(container.textContent).toBe('HelloHello'); + }); + it('calls the hydration callbacks after hydration or deletion', async () => { let suspend = false; let resolve; diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 74877c1591e75..79b534d1e1812 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -764,11 +764,23 @@ export function getNextHydratableSibling( } export function getFirstHydratableChild( - parentInstance: Container | Instance, + parentInstance: Instance, ): null | HydratableInstance { return getNextHydratable(parentInstance.firstChild); } +export function getFirstHydratableChildWithinContainer( + parentContainer: Container, +): null | HydratableInstance { + return getNextHydratable(parentContainer.firstChild); +} + +export function getFirstHydratableChildWithinSuspenseInstance( + parentInstance: SuspenseInstance, +): null | HydratableInstance { + return getNextHydratable(parentInstance.nextSibling); +} + export function hydrateInstance( instance: Instance, type: string, @@ -917,7 +929,7 @@ export function didNotMatchHydratedTextInstance( } } -export function didNotHydrateContainerInstance( +export function didNotHydrateInstanceWithinContainer( parentContainer: Container, instance: HydratableInstance, ) { @@ -932,6 +944,25 @@ export function didNotHydrateContainerInstance( } } +export function didNotHydrateInstanceWithinSuspenseInstance( + parentInstance: SuspenseInstance, + instance: HydratableInstance, +) { + if (__DEV__) { + // $FlowFixMe: Only Element or Document can be parent nodes. + const parentNode: Element | Document | null = parentInstance.parentNode; + if (parentNode !== null) { + if (instance.nodeType === ELEMENT_NODE) { + warnForDeletedHydratableElement(parentNode, (instance: any)); + } else if (instance.nodeType === COMMENT_NODE) { + // TODO: warnForDeletedHydratableSuspenseBoundary + } else { + warnForDeletedHydratableText(parentNode, (instance: any)); + } + } + } +} + export function didNotHydrateInstance( parentType: string, parentProps: Props, @@ -949,7 +980,7 @@ export function didNotHydrateInstance( } } -export function didNotFindHydratableContainerInstance( +export function didNotFindHydratableInstanceWithinContainer( parentContainer: Container, type: string, props: Props, @@ -959,7 +990,7 @@ export function didNotFindHydratableContainerInstance( } } -export function didNotFindHydratableContainerTextInstance( +export function didNotFindHydratableTextInstanceWithinContainer( parentContainer: Container, text: string, ) { @@ -968,7 +999,7 @@ export function didNotFindHydratableContainerTextInstance( } } -export function didNotFindHydratableContainerSuspenseInstance( +export function didNotFindHydratableSuspenseInstanceWithinContainer( parentContainer: Container, ) { if (__DEV__) { @@ -976,6 +1007,39 @@ export function didNotFindHydratableContainerSuspenseInstance( } } +export function didNotFindHydratableInstanceWithinSuspenseInstance( + parentInstance: SuspenseInstance, + type: string, + props: Props, +) { + if (__DEV__) { + // $FlowFixMe: Only Element or Document can be parent nodes. + const parentNode: Element | Document | null = parentInstance.parentNode; + if (parentNode !== null) + warnForInsertedHydratedElement(parentNode, type, props); + } +} + +export function didNotFindHydratableTextInstanceWithinSuspenseInstance( + parentInstance: SuspenseInstance, + text: string, +) { + if (__DEV__) { + // $FlowFixMe: Only Element or Document can be parent nodes. + const parentNode: Element | Document | null = parentInstance.parentNode; + if (parentNode !== null) warnForInsertedHydratedText(parentNode, text); + } +} + +export function didNotFindHydratableSuspenseInstanceWithinSuspenseInstance( + parentInstance: SuspenseInstance, +) { + if (__DEV__) { + // const parentNode: Element | Document | null = parentInstance.parentNode; + // TODO: warnForInsertedHydratedSuspense(parentNode); + } +} + export function didNotFindHydratableInstance( parentType: string, parentProps: Props, diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index b95fd52d189b5..1392cf8a26083 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -29,6 +29,8 @@ export const isSuspenseInstanceFallback = shim; export const registerSuspenseInstanceRetry = shim; export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; +export const getFirstHydratableChildWithinContainer = shim; +export const getFirstHydratableChildWithinSuspenseInstance = shim; export const hydrateInstance = shim; export const hydrateTextInstance = shim; export const hydrateSuspenseInstance = shim; @@ -40,11 +42,15 @@ export const clearSuspenseBoundaryFromContainer = shim; export const shouldDeleteUnhydratedTailInstances = shim; export const didNotMatchHydratedContainerTextInstance = shim; export const didNotMatchHydratedTextInstance = shim; -export const didNotHydrateContainerInstance = shim; +export const didNotHydrateInstanceWithinContainer = shim; +export const didNotHydrateInstanceWithinSuspenseInstance = shim; export const didNotHydrateInstance = shim; -export const didNotFindHydratableContainerInstance = shim; -export const didNotFindHydratableContainerTextInstance = shim; -export const didNotFindHydratableContainerSuspenseInstance = shim; +export const didNotFindHydratableInstanceWithinContainer = shim; +export const didNotFindHydratableTextInstanceWithinContainer = shim; +export const didNotFindHydratableSuspenseInstanceWithinContainer = shim; +export const didNotFindHydratableInstanceWithinSuspenseInstance = shim; +export const didNotFindHydratableTextInstanceWithinSuspenseInstance = shim; +export const didNotFindHydratableSuspenseInstanceWithinSuspenseInstance = shim; export const didNotFindHydratableInstance = shim; export const didNotFindHydratableTextInstance = shim; export const didNotFindHydratableSuspenseInstance = shim; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index f694d8407ecdf..6aad7f03339f5 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -38,6 +38,8 @@ import { canHydrateSuspenseInstance, getNextHydratableSibling, getFirstHydratableChild, + getFirstHydratableChildWithinContainer, + getFirstHydratableChildWithinSuspenseInstance, hydrateInstance, hydrateTextInstance, hydrateSuspenseInstance, @@ -45,11 +47,15 @@ import { shouldDeleteUnhydratedTailInstances, didNotMatchHydratedContainerTextInstance, didNotMatchHydratedTextInstance, - didNotHydrateContainerInstance, + didNotHydrateInstanceWithinContainer, + didNotHydrateInstanceWithinSuspenseInstance, didNotHydrateInstance, - didNotFindHydratableContainerInstance, - didNotFindHydratableContainerTextInstance, - didNotFindHydratableContainerSuspenseInstance, + didNotFindHydratableInstanceWithinContainer, + didNotFindHydratableTextInstanceWithinContainer, + didNotFindHydratableSuspenseInstanceWithinContainer, + didNotFindHydratableInstanceWithinSuspenseInstance, + didNotFindHydratableTextInstanceWithinSuspenseInstance, + didNotFindHydratableSuspenseInstanceWithinSuspenseInstance, didNotFindHydratableInstance, didNotFindHydratableTextInstance, didNotFindHydratableSuspenseInstance, @@ -78,8 +84,10 @@ function enterHydrationState(fiber: Fiber): boolean { return false; } - const parentInstance = fiber.stateNode.containerInfo; - nextHydratableInstance = getFirstHydratableChild(parentInstance); + const parentInstance: Container = fiber.stateNode.containerInfo; + nextHydratableInstance = getFirstHydratableChildWithinContainer( + parentInstance, + ); hydrationParentFiber = fiber; isHydrating = true; return true; @@ -92,8 +100,10 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( if (!supportsHydration) { return false; } - nextHydratableInstance = getNextHydratableSibling(suspenseInstance); - popToNextHostParent(fiber); + nextHydratableInstance = getFirstHydratableChildWithinSuspenseInstance( + suspenseInstance, + ); + hydrationParentFiber = fiber; isHydrating = true; return true; } @@ -105,7 +115,7 @@ function deleteHydratableInstance( if (__DEV__) { switch (returnFiber.tag) { case HostRoot: - didNotHydrateContainerInstance( + didNotHydrateInstanceWithinContainer( returnFiber.stateNode.containerInfo, instance, ); @@ -118,6 +128,14 @@ function deleteHydratableInstance( instance, ); break; + case SuspenseComponent: + const suspenseState: SuspenseState = returnFiber.memoizedState; + if (suspenseState.dehydrated !== null) + didNotHydrateInstanceWithinSuspenseInstance( + suspenseState.dehydrated, + instance, + ); + break; } } @@ -144,14 +162,23 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { case HostComponent: const type = fiber.type; const props = fiber.pendingProps; - didNotFindHydratableContainerInstance(parentContainer, type, props); + didNotFindHydratableInstanceWithinContainer( + parentContainer, + type, + props, + ); break; case HostText: const text = fiber.pendingProps; - didNotFindHydratableContainerTextInstance(parentContainer, text); + didNotFindHydratableTextInstanceWithinContainer( + parentContainer, + text, + ); break; case SuspenseComponent: - didNotFindHydratableContainerSuspenseInstance(parentContainer); + didNotFindHydratableSuspenseInstanceWithinContainer( + parentContainer, + ); break; } break; @@ -191,6 +218,35 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { } break; } + case SuspenseComponent: { + const suspenseState: SuspenseState = returnFiber.memoizedState; + const parentInstance = suspenseState.dehydrated; + if (parentInstance !== null) + switch (fiber.tag) { + case HostComponent: + const type = fiber.type; + const props = fiber.pendingProps; + didNotFindHydratableInstanceWithinSuspenseInstance( + parentInstance, + type, + props, + ); + break; + case HostText: + const text = fiber.pendingProps; + didNotFindHydratableTextInstanceWithinSuspenseInstance( + parentInstance, + text, + ); + break; + case SuspenseComponent: + didNotFindHydratableSuspenseInstanceWithinSuspenseInstance( + parentInstance, + ); + break; + } + break; + } default: return; } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 3918c014ca146..fd0dd8e99a5b0 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -38,6 +38,8 @@ import { canHydrateSuspenseInstance, getNextHydratableSibling, getFirstHydratableChild, + getFirstHydratableChildWithinContainer, + getFirstHydratableChildWithinSuspenseInstance, hydrateInstance, hydrateTextInstance, hydrateSuspenseInstance, @@ -45,11 +47,15 @@ import { shouldDeleteUnhydratedTailInstances, didNotMatchHydratedContainerTextInstance, didNotMatchHydratedTextInstance, - didNotHydrateContainerInstance, + didNotHydrateInstanceWithinContainer, + didNotHydrateInstanceWithinSuspenseInstance, didNotHydrateInstance, - didNotFindHydratableContainerInstance, - didNotFindHydratableContainerTextInstance, - didNotFindHydratableContainerSuspenseInstance, + didNotFindHydratableInstanceWithinContainer, + didNotFindHydratableTextInstanceWithinContainer, + didNotFindHydratableSuspenseInstanceWithinContainer, + didNotFindHydratableInstanceWithinSuspenseInstance, + didNotFindHydratableTextInstanceWithinSuspenseInstance, + didNotFindHydratableSuspenseInstanceWithinSuspenseInstance, didNotFindHydratableInstance, didNotFindHydratableTextInstance, didNotFindHydratableSuspenseInstance, @@ -78,8 +84,10 @@ function enterHydrationState(fiber: Fiber): boolean { return false; } - const parentInstance = fiber.stateNode.containerInfo; - nextHydratableInstance = getFirstHydratableChild(parentInstance); + const parentInstance: Container = fiber.stateNode.containerInfo; + nextHydratableInstance = getFirstHydratableChildWithinContainer( + parentInstance, + ); hydrationParentFiber = fiber; isHydrating = true; return true; @@ -92,8 +100,10 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( if (!supportsHydration) { return false; } - nextHydratableInstance = getNextHydratableSibling(suspenseInstance); - popToNextHostParent(fiber); + nextHydratableInstance = getFirstHydratableChildWithinSuspenseInstance( + suspenseInstance, + ); + hydrationParentFiber = fiber; isHydrating = true; return true; } @@ -105,7 +115,7 @@ function deleteHydratableInstance( if (__DEV__) { switch (returnFiber.tag) { case HostRoot: - didNotHydrateContainerInstance( + didNotHydrateInstanceWithinContainer( returnFiber.stateNode.containerInfo, instance, ); @@ -118,6 +128,14 @@ function deleteHydratableInstance( instance, ); break; + case SuspenseComponent: + const suspenseState: SuspenseState = returnFiber.memoizedState; + if (suspenseState.dehydrated !== null) + didNotHydrateInstanceWithinSuspenseInstance( + suspenseState.dehydrated, + instance, + ); + break; } } @@ -144,14 +162,23 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { case HostComponent: const type = fiber.type; const props = fiber.pendingProps; - didNotFindHydratableContainerInstance(parentContainer, type, props); + didNotFindHydratableInstanceWithinContainer( + parentContainer, + type, + props, + ); break; case HostText: const text = fiber.pendingProps; - didNotFindHydratableContainerTextInstance(parentContainer, text); + didNotFindHydratableTextInstanceWithinContainer( + parentContainer, + text, + ); break; case SuspenseComponent: - didNotFindHydratableContainerSuspenseInstance(parentContainer); + didNotFindHydratableSuspenseInstanceWithinContainer( + parentContainer, + ); break; } break; @@ -191,6 +218,35 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { } break; } + case SuspenseComponent: { + const suspenseState: SuspenseState = returnFiber.memoizedState; + const parentInstance = suspenseState.dehydrated; + if (parentInstance !== null) + switch (fiber.tag) { + case HostComponent: + const type = fiber.type; + const props = fiber.pendingProps; + didNotFindHydratableInstanceWithinSuspenseInstance( + parentInstance, + type, + props, + ); + break; + case HostText: + const text = fiber.pendingProps; + didNotFindHydratableTextInstanceWithinSuspenseInstance( + parentInstance, + text, + ); + break; + case SuspenseComponent: + didNotFindHydratableSuspenseInstanceWithinSuspenseInstance( + parentInstance, + ); + break; + } + break; + } default: return; } diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 1cc10a78cb013..29d4fd7e79893 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -150,6 +150,10 @@ export const registerSuspenseInstanceRetry = $$$hostConfig.registerSuspenseInstanceRetry; export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling; export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild; +export const getFirstHydratableChildWithinContainer = + $$$hostConfig.getFirstHydratableChildWithinContainer; +export const getFirstHydratableChildWithinSuspenseInstance = + $$$hostConfig.getFirstHydratableChildWithinSuspenseInstance; export const hydrateInstance = $$$hostConfig.hydrateInstance; export const hydrateTextInstance = $$$hostConfig.hydrateTextInstance; export const hydrateSuspenseInstance = $$$hostConfig.hydrateSuspenseInstance; @@ -167,15 +171,23 @@ export const didNotMatchHydratedContainerTextInstance = $$$hostConfig.didNotMatchHydratedContainerTextInstance; export const didNotMatchHydratedTextInstance = $$$hostConfig.didNotMatchHydratedTextInstance; -export const didNotHydrateContainerInstance = - $$$hostConfig.didNotHydrateContainerInstance; +export const didNotHydrateInstanceWithinContainer = + $$$hostConfig.didNotHydrateInstanceWithinContainer; +export const didNotHydrateInstanceWithinSuspenseInstance = + $$$hostConfig.didNotHydrateInstanceWithinSuspenseInstance; export const didNotHydrateInstance = $$$hostConfig.didNotHydrateInstance; -export const didNotFindHydratableContainerInstance = - $$$hostConfig.didNotFindHydratableContainerInstance; -export const didNotFindHydratableContainerTextInstance = - $$$hostConfig.didNotFindHydratableContainerTextInstance; -export const didNotFindHydratableContainerSuspenseInstance = - $$$hostConfig.didNotFindHydratableContainerSuspenseInstance; +export const didNotFindHydratableInstanceWithinContainer = + $$$hostConfig.didNotFindHydratableInstanceWithinContainer; +export const didNotFindHydratableTextInstanceWithinContainer = + $$$hostConfig.didNotFindHydratableTextInstanceWithinContainer; +export const didNotFindHydratableSuspenseInstanceWithinContainer = + $$$hostConfig.didNotFindHydratableSuspenseInstanceWithinContainer; +export const didNotFindHydratableInstanceWithinSuspenseInstance = + $$$hostConfig.didNotFindHydratableInstanceWithinSuspenseInstance; +export const didNotFindHydratableTextInstanceWithinSuspenseInstance = + $$$hostConfig.didNotFindHydratableTextInstanceWithinSuspenseInstance; +export const didNotFindHydratableSuspenseInstanceWithinSuspenseInstance = + $$$hostConfig.didNotFindHydratableSuspenseInstanceWithinSuspenseInstance; export const didNotFindHydratableInstance = $$$hostConfig.didNotFindHydratableInstance; export const didNotFindHydratableTextInstance =