diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 1c65501f7b3824..411451a2feade7 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -437,7 +437,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.innerHTML).toContain('
Sibling
'); }); - it('recovers when server rendered additional nodes', async () => { + it('recovers with client render when server rendered additional nodes at suspense root', async () => { const ref = React.createRef(); function App({hasB}) { return ( @@ -462,15 +462,135 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.innerHTML).toContain('B'); expect(ref.current).toBe(null); - ReactDOM.hydrateRoot(container, ); expect(() => { - Scheduler.unstable_flushAll(); + act(() => { + ReactDOM.hydrateRoot(container, ); + }); }).toErrorDev('Did not expect server HTML to contain a in
'); + jest.runAllTimers(); expect(container.innerHTML).toContain('A'); expect(container.innerHTML).not.toContain('B'); - expect(ref.current).toBe(span); + + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(ref.current).not.toBe(span); + } else { + expect(ref.current).toBe(span); + } + }); + + it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => { + const ref = React.createRef(); + function App({hasB}) { + return ( +
+ + + A + {hasB ? B : null} + +
Sibling
+
+ ); + } + + let shouldSuspend = false; + let resolve; + const promise = new Promise(res => { + resolve = () => { + shouldSuspend = false; + res(); + }; + }); + function Suspender() { + if (shouldSuspend) { + throw promise; + } + return
; + } + + const finalHTML = ReactDOMServer.renderToString(); + + const container = document.createElement('div'); + container.innerHTML = finalHTML; + + const span = container.getElementsByTagName('span')[0]; + + expect(container.innerHTML).toContain('A'); + expect(container.innerHTML).toContain('B'); + expect(ref.current).toBe(null); + + shouldSuspend = true; + act(() => { + ReactDOM.hydrateRoot(container, ); + }); + + spyOnDev(console, 'error'); + + resolve(); + await promise; + Scheduler.unstable_flushAll(); + await null; + jest.runAllTimers(); + + expect(container.innerHTML).toContain('A'); + expect(container.innerHTML).not.toContain('B'); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(ref.current).not.toBe(span); + } else { + expect(ref.current).toBe(span); + } + + expect(console.error.calls.count()).toBe(1); + const errorArgs = console.error.calls.first().args; + expect(errorArgs[0]).toBe( + 'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s', + ); + expect(errorArgs[1]).toBe('span'); + expect(errorArgs[2]).toBe('div'); + }); + + it('recovers with client render when server rendered additional nodes deep inside suspense root', async () => { + const ref = React.createRef(); + function App({hasB}) { + return ( +
+ +
+ A + {hasB ? B : null} +
+
+
Sibling
+
+ ); + } + + const finalHTML = ReactDOMServer.renderToString(); + + const container = document.createElement('div'); + container.innerHTML = finalHTML; + + const span = container.getElementsByTagName('span')[0]; + + expect(container.innerHTML).toContain('A'); + expect(container.innerHTML).toContain('B'); + expect(ref.current).toBe(null); + + expect(() => { + act(() => { + ReactDOM.hydrateRoot(container, ); + }); + }).toErrorDev('Did not expect server HTML to contain a in
'); + + expect(container.innerHTML).toContain('A'); + expect(container.innerHTML).not.toContain('B'); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(ref.current).not.toBe(span); + } else { + expect(ref.current).toBe(span); + } }); it('calls the onDeleted hydration callback if the parent gets deleted', async () => { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 91c564dd465ba5..21f86645735dbb 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -29,7 +29,10 @@ import type { import type {SuspenseContext} from './ReactFiberSuspenseContext.new'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.new'; -import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; +import { + enableClientRenderFallbackOnHydrationMismatch, + enableSuspenseAvoidThisFallback, +} from 'shared/ReactFeatureFlags'; import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new'; @@ -74,6 +77,9 @@ import { StaticMask, MutationMask, Passive, + Incomplete, + ShouldCapture, + ForceClientRender, } from './ReactFiberFlags'; import { @@ -120,9 +126,11 @@ import { prepareToHydrateHostInstance, prepareToHydrateHostTextInstance, prepareToHydrateHostSuspenseInstance, + warnDeleteNextHydratableInstance, popHydrationState, resetHydrationState, getIsHydrating, + hasMore, } from './ReactFiberHydrationContext.new'; import { enableSuspenseCallback, @@ -828,7 +836,6 @@ function completeWork( // to the current tree provider fiber is just as fast and less error-prone. // Ideally we would have a special version of the work loop only // for hydration. - popTreeContext(workInProgress); switch (workInProgress.tag) { case IndeterminateComponent: case LazyComponent: @@ -840,9 +847,11 @@ function completeWork( case Profiler: case ContextConsumer: case MemoComponent: + popTreeContext(workInProgress); bubbleProperties(workInProgress); return null; case ClassComponent: { + popTreeContext(workInProgress); const Component = workInProgress.type; if (isLegacyContextProvider(Component)) { popLegacyContext(workInProgress); @@ -852,6 +861,23 @@ function completeWork( } case HostRoot: { const fiberRoot = (workInProgress.stateNode: FiberRoot); + if (current === null || current.child === null) { + // If we hydrated, pop so that we can delete any remaining children + // that weren't hydrated. + const wasHydrated = popHydrationState(workInProgress); + if (wasHydrated) { + // If we hydrated, then we'll need to schedule an update for + // the commit side-effects on the root. + markUpdate(workInProgress); + } else if (!fiberRoot.isDehydrated) { + // Schedule an effect to clear this container at the start of the next commit. + // This handles the case of React rendering into a container with previous children. + // It's also safe to do for updates too, because current.child would only be null + // if the previous render was null (so the container would already be empty). + workInProgress.flags |= Snapshot; + } + } + popTreeContext(workInProgress); if (enableCache) { popRootCachePool(fiberRoot, renderLanes); @@ -873,27 +899,13 @@ function completeWork( fiberRoot.context = fiberRoot.pendingContext; fiberRoot.pendingContext = null; } - if (current === null || current.child === null) { - // If we hydrated, pop so that we can delete any remaining children - // that weren't hydrated. - const wasHydrated = popHydrationState(workInProgress); - if (wasHydrated) { - // If we hydrated, then we'll need to schedule an update for - // the commit side-effects on the root. - markUpdate(workInProgress); - } else if (!fiberRoot.isDehydrated) { - // Schedule an effect to clear this container at the start of the next commit. - // This handles the case of React rendering into a container with previous children. - // It's also safe to do for updates too, because current.child would only be null - // if the previous render was null (so the container would already be empty). - workInProgress.flags |= Snapshot; - } - } updateHostContainer(current, workInProgress); bubbleProperties(workInProgress); return null; } case HostComponent: { + const wasHydrated = popHydrationState(workInProgress); + popTreeContext(workInProgress); popHostContext(workInProgress); const rootContainerInstance = getRootHostContainer(); const type = workInProgress.type; @@ -928,7 +940,6 @@ function completeWork( // "stack" as the parent. Then append children as we go in beginWork // or completeWork depending on whether we want to add them top->down or // bottom->up. Top->down is faster in IE11. - const wasHydrated = popHydrationState(workInProgress); if (wasHydrated) { // TODO: Move this and createInstance step into the beginPhase // to consolidate. @@ -981,6 +992,7 @@ function completeWork( return null; } case HostText: { + popTreeContext(workInProgress); const newText = newProps; if (current && workInProgress.stateNode != null) { const oldText = current.memoizedProps; @@ -1017,14 +1029,27 @@ function completeWork( return null; } case SuspenseComponent: { - popSuspenseContext(workInProgress); const nextState: null | SuspenseState = workInProgress.memoizedState; - if (enableSuspenseServerRenderer) { + if ( + enableClientRenderFallbackOnHydrationMismatch && + hasMore() && + (workInProgress.flags & DidCapture) === NoFlags + ) { + warnDeleteNextHydratableInstance(workInProgress); + resetHydrationState(); + workInProgress.flags |= + ForceClientRender | Incomplete | ShouldCapture; + popTreeContext(workInProgress); + popSuspenseContext(workInProgress); + return workInProgress; + } if (nextState !== null && nextState.dehydrated !== null) { // We might be inside a hydration state the first time we're picking up this // Suspense boundary, and also after we've reentered it for further hydration. const wasHydrated = popHydrationState(workInProgress); + popTreeContext(workInProgress); + popSuspenseContext(workInProgress); if (current === null) { if (!wasHydrated) { throw new Error( @@ -1091,6 +1116,8 @@ function completeWork( ) { transferActualDuration(workInProgress); } + popTreeContext(workInProgress); + popSuspenseContext(workInProgress); // Don't bubble properties in this case. return workInProgress; } @@ -1103,6 +1130,8 @@ function completeWork( const prevState: null | SuspenseState = current.memoizedState; prevDidTimeout = prevState !== null; } + popTreeContext(workInProgress); + popSuspenseContext(workInProgress); if (enableCache && nextDidTimeout) { const offscreenFiber: Fiber = (workInProgress.child: any); @@ -1207,6 +1236,7 @@ function completeWork( return null; } case HostPortal: + popTreeContext(workInProgress); popHostContainer(workInProgress); updateHostContainer(current, workInProgress); if (current === null) { @@ -1215,12 +1245,14 @@ function completeWork( bubbleProperties(workInProgress); return null; case ContextProvider: + popTreeContext(workInProgress); // Pop provider fiber const context: ReactContext = workInProgress.type._context; popProvider(context, workInProgress); bubbleProperties(workInProgress); return null; case IncompleteClassComponent: { + popTreeContext(workInProgress); // Same as class component case. I put it down here so that the tags are // sequential to ensure this switch is compiled to a jump table. const Component = workInProgress.type; @@ -1231,6 +1263,7 @@ function completeWork( return null; } case SuspenseListComponent: { + popTreeContext(workInProgress); popSuspenseContext(workInProgress); const renderState: null | SuspenseListRenderState = @@ -1440,6 +1473,7 @@ function completeWork( return null; } case ScopeComponent: { + popTreeContext(workInProgress); if (enableScopeAPI) { if (current === null) { const scopeInstance: ReactScopeInstance = createScopeInstance(); @@ -1464,6 +1498,7 @@ function completeWork( } case OffscreenComponent: case LegacyHiddenComponent: { + popTreeContext(workInProgress); popRenderLanes(workInProgress); const nextState: OffscreenState | null = workInProgress.memoizedState; const nextIsHidden = nextState !== null; @@ -1532,6 +1567,7 @@ function completeWork( return null; } case CacheComponent: { + popTreeContext(workInProgress); if (enableCache) { let previousCache: Cache | null = null; if (workInProgress.alternate !== null) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index ea994583cfe8dd..9830f51a10fa7a 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -29,7 +29,10 @@ import type { import type {SuspenseContext} from './ReactFiberSuspenseContext.old'; import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent.old'; -import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; +import { + enableClientRenderFallbackOnHydrationMismatch, + enableSuspenseAvoidThisFallback, +} from 'shared/ReactFeatureFlags'; import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.old'; @@ -74,6 +77,9 @@ import { StaticMask, MutationMask, Passive, + Incomplete, + ShouldCapture, + ForceClientRender, } from './ReactFiberFlags'; import { @@ -120,9 +126,11 @@ import { prepareToHydrateHostInstance, prepareToHydrateHostTextInstance, prepareToHydrateHostSuspenseInstance, + warnDeleteNextHydratableInstance, popHydrationState, resetHydrationState, getIsHydrating, + hasMore, } from './ReactFiberHydrationContext.old'; import { enableSuspenseCallback, @@ -828,7 +836,6 @@ function completeWork( // to the current tree provider fiber is just as fast and less error-prone. // Ideally we would have a special version of the work loop only // for hydration. - popTreeContext(workInProgress); switch (workInProgress.tag) { case IndeterminateComponent: case LazyComponent: @@ -840,9 +847,11 @@ function completeWork( case Profiler: case ContextConsumer: case MemoComponent: + popTreeContext(workInProgress); bubbleProperties(workInProgress); return null; case ClassComponent: { + popTreeContext(workInProgress); const Component = workInProgress.type; if (isLegacyContextProvider(Component)) { popLegacyContext(workInProgress); @@ -852,6 +861,23 @@ function completeWork( } case HostRoot: { const fiberRoot = (workInProgress.stateNode: FiberRoot); + if (current === null || current.child === null) { + // If we hydrated, pop so that we can delete any remaining children + // that weren't hydrated. + const wasHydrated = popHydrationState(workInProgress); + if (wasHydrated) { + // If we hydrated, then we'll need to schedule an update for + // the commit side-effects on the root. + markUpdate(workInProgress); + } else if (!fiberRoot.isDehydrated) { + // Schedule an effect to clear this container at the start of the next commit. + // This handles the case of React rendering into a container with previous children. + // It's also safe to do for updates too, because current.child would only be null + // if the previous render was null (so the container would already be empty). + workInProgress.flags |= Snapshot; + } + } + popTreeContext(workInProgress); if (enableCache) { popRootCachePool(fiberRoot, renderLanes); @@ -873,27 +899,13 @@ function completeWork( fiberRoot.context = fiberRoot.pendingContext; fiberRoot.pendingContext = null; } - if (current === null || current.child === null) { - // If we hydrated, pop so that we can delete any remaining children - // that weren't hydrated. - const wasHydrated = popHydrationState(workInProgress); - if (wasHydrated) { - // If we hydrated, then we'll need to schedule an update for - // the commit side-effects on the root. - markUpdate(workInProgress); - } else if (!fiberRoot.isDehydrated) { - // Schedule an effect to clear this container at the start of the next commit. - // This handles the case of React rendering into a container with previous children. - // It's also safe to do for updates too, because current.child would only be null - // if the previous render was null (so the container would already be empty). - workInProgress.flags |= Snapshot; - } - } updateHostContainer(current, workInProgress); bubbleProperties(workInProgress); return null; } case HostComponent: { + const wasHydrated = popHydrationState(workInProgress); + popTreeContext(workInProgress); popHostContext(workInProgress); const rootContainerInstance = getRootHostContainer(); const type = workInProgress.type; @@ -928,7 +940,6 @@ function completeWork( // "stack" as the parent. Then append children as we go in beginWork // or completeWork depending on whether we want to add them top->down or // bottom->up. Top->down is faster in IE11. - const wasHydrated = popHydrationState(workInProgress); if (wasHydrated) { // TODO: Move this and createInstance step into the beginPhase // to consolidate. @@ -981,6 +992,7 @@ function completeWork( return null; } case HostText: { + popTreeContext(workInProgress); const newText = newProps; if (current && workInProgress.stateNode != null) { const oldText = current.memoizedProps; @@ -1017,14 +1029,23 @@ function completeWork( return null; } case SuspenseComponent: { - popSuspenseContext(workInProgress); const nextState: null | SuspenseState = workInProgress.memoizedState; - if (enableSuspenseServerRenderer) { if (nextState !== null && nextState.dehydrated !== null) { + if (enableClientRenderFallbackOnHydrationMismatch && hasMore()) { + warnDeleteNextHydratableInstance(workInProgress); + resetHydrationState(); + workInProgress.flags |= + ForceClientRender | Incomplete | ShouldCapture; + popTreeContext(workInProgress); + popSuspenseContext(workInProgress); + return workInProgress; + } // We might be inside a hydration state the first time we're picking up this // Suspense boundary, and also after we've reentered it for further hydration. const wasHydrated = popHydrationState(workInProgress); + popTreeContext(workInProgress); + popSuspenseContext(workInProgress); if (current === null) { if (!wasHydrated) { throw new Error( @@ -1091,6 +1112,8 @@ function completeWork( ) { transferActualDuration(workInProgress); } + popTreeContext(workInProgress); + popSuspenseContext(workInProgress); // Don't bubble properties in this case. return workInProgress; } @@ -1104,6 +1127,9 @@ function completeWork( prevDidTimeout = prevState !== null; } + popTreeContext(workInProgress); + popSuspenseContext(workInProgress); + if (enableCache && nextDidTimeout) { const offscreenFiber: Fiber = (workInProgress.child: any); let previousCache: Cache | null = null; @@ -1207,6 +1233,7 @@ function completeWork( return null; } case HostPortal: + popTreeContext(workInProgress); popHostContainer(workInProgress); updateHostContainer(current, workInProgress); if (current === null) { @@ -1215,12 +1242,14 @@ function completeWork( bubbleProperties(workInProgress); return null; case ContextProvider: + popTreeContext(workInProgress); // Pop provider fiber const context: ReactContext = workInProgress.type._context; popProvider(context, workInProgress); bubbleProperties(workInProgress); return null; case IncompleteClassComponent: { + popTreeContext(workInProgress); // Same as class component case. I put it down here so that the tags are // sequential to ensure this switch is compiled to a jump table. const Component = workInProgress.type; @@ -1231,6 +1260,7 @@ function completeWork( return null; } case SuspenseListComponent: { + popTreeContext(workInProgress); popSuspenseContext(workInProgress); const renderState: null | SuspenseListRenderState = @@ -1440,6 +1470,7 @@ function completeWork( return null; } case ScopeComponent: { + popTreeContext(workInProgress); if (enableScopeAPI) { if (current === null) { const scopeInstance: ReactScopeInstance = createScopeInstance(); @@ -1464,6 +1495,7 @@ function completeWork( } case OffscreenComponent: case LegacyHiddenComponent: { + popTreeContext(workInProgress); popRenderLanes(workInProgress); const nextState: OffscreenState | null = workInProgress.memoizedState; const nextIsHidden = nextState !== null; @@ -1532,6 +1564,7 @@ function completeWork( return null; } case CacheComponent: { + popTreeContext(workInProgress); if (enableCache) { let previousCache: Cache | null = null; if (workInProgress.alternate !== null) { diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 614c6c9d946ca9..582a39f756e1fa 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -121,7 +121,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( return true; } -function deleteHydratableInstance( +function warnDeleteHydratableInstance( returnFiber: Fiber, instance: HydratableInstance, ) { @@ -151,7 +151,13 @@ function deleteHydratableInstance( break; } } +} +function deleteHydratableInstance( + returnFiber: Fiber, + instance: HydratableInstance, +) { + warnDeleteHydratableInstance(returnFiber, instance); const childToDelete = createFiberFromHostInstanceForDeletion(); childToDelete.stateNode = instance; childToDelete.return = returnFiber; @@ -539,12 +545,15 @@ function popHydrationState(fiber: Fiber): boolean { !shouldSetTextContent(fiber.type, fiber.memoizedProps))) ) { let nextInstance = nextHydratableInstance; - while (nextInstance) { - deleteHydratableInstance(fiber, nextInstance); - nextInstance = getNextHydratableSibling(nextInstance); + if (nextInstance) { + warnDeleteNextHydratableInstance(fiber); + throwOnHydrationMismatchIfConcurrentMode(fiber); + while (nextInstance) { + deleteHydratableInstance(fiber, nextInstance); + nextInstance = getNextHydratableSibling(nextInstance); + } } } - popToNextHostParent(fiber); if (fiber.tag === SuspenseComponent) { nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber); @@ -556,6 +565,16 @@ function popHydrationState(fiber: Fiber): boolean { return true; } +function hasMore() { + return isHydrating && nextHydratableInstance !== null; +} + +function warnDeleteNextHydratableInstance(fiber: Fiber) { + if (nextHydratableInstance) { + warnDeleteHydratableInstance(fiber, nextHydratableInstance); + } +} + function resetHydrationState(): void { if (!supportsHydration) { return; @@ -581,4 +600,6 @@ export { prepareToHydrateHostTextInstance, prepareToHydrateHostSuspenseInstance, popHydrationState, + hasMore, + warnDeleteNextHydratableInstance, }; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index b7bca8217d979f..fb2db55b1b57ae 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -121,7 +121,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( return true; } -function deleteHydratableInstance( +function warnDeleteHydratableInstance( returnFiber: Fiber, instance: HydratableInstance, ) { @@ -151,7 +151,13 @@ function deleteHydratableInstance( break; } } +} +function deleteHydratableInstance( + returnFiber: Fiber, + instance: HydratableInstance, +) { + warnDeleteHydratableInstance(returnFiber, instance); const childToDelete = createFiberFromHostInstanceForDeletion(); childToDelete.stateNode = instance; childToDelete.return = returnFiber; @@ -539,12 +545,15 @@ function popHydrationState(fiber: Fiber): boolean { !shouldSetTextContent(fiber.type, fiber.memoizedProps))) ) { let nextInstance = nextHydratableInstance; - while (nextInstance) { - deleteHydratableInstance(fiber, nextInstance); - nextInstance = getNextHydratableSibling(nextInstance); + if (nextInstance) { + warnDeleteNextHydratableInstance(fiber); + throwOnHydrationMismatchIfConcurrentMode(fiber); + while (nextInstance) { + deleteHydratableInstance(fiber, nextInstance); + nextInstance = getNextHydratableSibling(nextInstance); + } } } - popToNextHostParent(fiber); if (fiber.tag === SuspenseComponent) { nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber); @@ -556,6 +565,16 @@ function popHydrationState(fiber: Fiber): boolean { return true; } +function hasMore() { + return isHydrating && nextHydratableInstance !== null; +} + +function warnDeleteNextHydratableInstance(fiber: Fiber) { + if (nextHydratableInstance) { + warnDeleteHydratableInstance(fiber, nextHydratableInstance); + } +} + function resetHydrationState(): void { if (!supportsHydration) { return; @@ -581,4 +600,6 @@ export { prepareToHydrateHostTextInstance, prepareToHydrateHostSuspenseInstance, popHydrationState, + hasMore, + warnDeleteNextHydratableInstance, };