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 =