diff --git a/packages/jest-react/src/internalAct.js b/packages/jest-react/src/internalAct.js index 9e6bf0e4fd66b..52b1d6a7c2fc4 100644 --- a/packages/jest-react/src/internalAct.js +++ b/packages/jest-react/src/internalAct.js @@ -22,7 +22,7 @@ import enqueueTask from 'shared/enqueueTask'; let actingUpdatesScopeDepth = 0; -export function act(scope: () => Thenable | void) { +export function act(scope: () => Thenable | T): Thenable { if (Scheduler.unstable_flushAllWithoutAsserting === undefined) { throw Error( 'This version of `act` requires a special mock build of Scheduler.', @@ -66,20 +66,21 @@ export function act(scope: () => Thenable | void) { // returned and 2) we could use async/await. Since it's only our used in // our test suite, we should be able to. try { - const thenable = scope(); + const result = scope(); if ( - typeof thenable === 'object' && - thenable !== null && - typeof thenable.then === 'function' + typeof result === 'object' && + result !== null && + typeof result.then === 'function' ) { + const thenableResult: Thenable = (result: any); return { - then(resolve: () => void, reject: (error: mixed) => void) { - thenable.then( - () => { + then(resolve, reject) { + thenableResult.then( + returnValue => { flushActWork( () => { unwind(); - resolve(); + resolve(returnValue); }, error => { unwind(); @@ -95,6 +96,7 @@ export function act(scope: () => Thenable | void) { }, }; } else { + const returnValue: T = (result: any); try { // TODO: Let's not support non-async scopes at all in our tests. Need to // migrate existing tests. @@ -102,6 +104,11 @@ export function act(scope: () => Thenable | void) { do { didFlushWork = Scheduler.unstable_flushAllWithoutAsserting(); } while (didFlushWork); + return { + then(resolve, reject) { + resolve(returnValue); + }, + }; } finally { unwind(); } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js index 728a50986d646..1a32db7bb83df 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js @@ -140,10 +140,10 @@ describe('ReactDOMFizzShellHydration', () => { } } - // function Text({text}) { - // Scheduler.unstable_yieldValue(text); - // return text; - // } + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } function AsyncText({text}) { readText(text); @@ -213,4 +213,34 @@ describe('ReactDOMFizzShellHydration', () => { expect(Scheduler).toHaveYielded(['Shell']); expect(container.textContent).toBe('Shell'); }); + + test('updating the root before the shell hydrates forces a client render', async () => { + function App() { + return ; + } + + // Server render + await resolveText('Shell'); + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(Scheduler).toHaveYielded(['Shell']); + + // Clear the cache and start rendering on the client + resetTextCache(); + + // Hydration suspends because the data for the shell hasn't loaded yet + const root = await clientAct(async () => { + return ReactDOM.hydrateRoot(container, ); + }); + expect(Scheduler).toHaveYielded(['Suspend! [Shell]']); + expect(container.textContent).toBe('Shell'); + + await clientAct(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['New screen']); + expect(container.textContent).toBe('New screen'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 586901baa14c3..bd0ff3f8aacb3 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -1966,6 +1966,9 @@ describe('ReactDOMServerPartialHydration', () => { expect(b.textContent).toBe('B'); const root = ReactDOM.hydrateRoot(container, ); + + // Commit the shell + // Increase hydration priority to higher than "offscreen". root.unstable_scheduleHydration(b); @@ -1973,14 +1976,8 @@ describe('ReactDOMServerPartialHydration', () => { await act(async () => { if (gate(flags => flags.enableSyncDefaultUpdates)) { - React.startTransition(() => { - root.render(); - }); - expect(Scheduler).toFlushAndYieldThrough(['Before', 'After']); } else { - root.render(); - expect(Scheduler).toFlushAndYieldThrough(['Before']); // This took a long time to render. Scheduler.unstable_advanceTime(1000); diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 1fe4557cb8bc1..e10f9e5448176 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -60,6 +60,7 @@ import { import { createContainer, + createHydrationContainer, updateContainer, findHostInstanceWithNoPortals, registerMutableSourceForHydration, @@ -261,10 +262,10 @@ export function hydrateRoot( } } - const root = createContainer( + const root = createHydrationContainer( + initialChildren, container, ConcurrentRoot, - true, // hydrate hydrationCallbacks, isStrictMode, concurrentUpdatesByDefaultOverride, @@ -284,9 +285,6 @@ export function hydrateRoot( } } - // Render the initial children - updateContainer(initialChildren, root, null, null); - return new ReactDOMHydrationRoot(root); } diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 6e992ddd7d3b1..abc676ff23963 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -16,6 +16,7 @@ import {enableNewReconciler} from 'shared/ReactFeatureFlags'; import { createContainer as createContainer_old, + createHydrationContainer as createHydrationContainer_old, updateContainer as updateContainer_old, batchedUpdates as batchedUpdates_old, deferredUpdates as deferredUpdates_old, @@ -53,6 +54,7 @@ import { import { createContainer as createContainer_new, + createHydrationContainer as createHydrationContainer_new, updateContainer as updateContainer_new, batchedUpdates as batchedUpdates_new, deferredUpdates as deferredUpdates_new, @@ -91,6 +93,9 @@ import { export const createContainer = enableNewReconciler ? createContainer_new : createContainer_old; +export const createHydrationContainer = enableNewReconciler + ? createHydrationContainer_new + : createHydrationContainer_old; export const updateContainer = enableNewReconciler ? updateContainer_new : updateContainer_old; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index 6db63352bfc25..c1842404358ca 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -57,6 +57,7 @@ import { requestEventTime, requestUpdateLane, scheduleUpdateOnFiber, + scheduleInitialHydrationOnRoot, flushRoot, batchedUpdates, flushSync, @@ -244,6 +245,8 @@ function findHostInstanceWithWarning( export function createContainer( containerInfo: Container, tag: RootTag, + // TODO: We can remove hydration-specific stuff from createContainer once + // we delete legacy mode. The new root API uses createDehydratedContainer. hydrate: boolean, hydrationCallbacks: null | SuspenseHydrationCallbacks, isStrictMode: boolean, @@ -265,6 +268,49 @@ export function createContainer( ); } +export function createHydrationContainer( + initialChildren: ReactNodeList, + containerInfo: Container, + tag: RootTag, + hydrationCallbacks: null | SuspenseHydrationCallbacks, + isStrictMode: boolean, + concurrentUpdatesByDefaultOverride: null | boolean, + identifierPrefix: string, + onRecoverableError: (error: mixed) => void, + transitionCallbacks: null | TransitionTracingCallbacks, +): OpaqueRoot { + const hydrate = true; + const root = createFiberRoot( + containerInfo, + tag, + hydrate, + hydrationCallbacks, + isStrictMode, + concurrentUpdatesByDefaultOverride, + identifierPrefix, + onRecoverableError, + transitionCallbacks, + ); + + // TODO: Move this to FiberRoot constructor + root.context = getContextForSubtree(null); + + // Schedule the initial render. In a hydration root, this is different from + // a regular update because the initial render must match was was rendered + // on the server. + const current = root.current; + const eventTime = requestEventTime(); + const lane = requestUpdateLane(current); + const update = createUpdate(eventTime, lane); + // Caution: React DevTools currently depends on this property + // being called "element". + update.payload = {element: initialChildren}; + enqueueUpdate(current, update, lane); + scheduleInitialHydrationOnRoot(root, lane, eventTime); + + return root; +} + export function updateContainer( element: ReactNodeList, container: OpaqueRoot, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.old.js b/packages/react-reconciler/src/ReactFiberReconciler.old.js index d8be40ba02673..f8abfd9b5deb6 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.old.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.old.js @@ -57,6 +57,7 @@ import { requestEventTime, requestUpdateLane, scheduleUpdateOnFiber, + scheduleInitialHydrationOnRoot, flushRoot, batchedUpdates, flushSync, @@ -244,6 +245,8 @@ function findHostInstanceWithWarning( export function createContainer( containerInfo: Container, tag: RootTag, + // TODO: We can remove hydration-specific stuff from createContainer once + // we delete legacy mode. The new root API uses createDehydratedContainer. hydrate: boolean, hydrationCallbacks: null | SuspenseHydrationCallbacks, isStrictMode: boolean, @@ -265,6 +268,49 @@ export function createContainer( ); } +export function createHydrationContainer( + initialChildren: ReactNodeList, + containerInfo: Container, + tag: RootTag, + hydrationCallbacks: null | SuspenseHydrationCallbacks, + isStrictMode: boolean, + concurrentUpdatesByDefaultOverride: null | boolean, + identifierPrefix: string, + onRecoverableError: (error: mixed) => void, + transitionCallbacks: null | TransitionTracingCallbacks, +): OpaqueRoot { + const hydrate = true; + const root = createFiberRoot( + containerInfo, + tag, + hydrate, + hydrationCallbacks, + isStrictMode, + concurrentUpdatesByDefaultOverride, + identifierPrefix, + onRecoverableError, + transitionCallbacks, + ); + + // TODO: Move this to FiberRoot constructor + root.context = getContextForSubtree(null); + + // Schedule the initial render. In a hydration root, this is different from + // a regular update because the initial render must match was was rendered + // on the server. + const current = root.current; + const eventTime = requestEventTime(); + const lane = requestUpdateLane(current); + const update = createUpdate(eventTime, lane); + // Caution: React DevTools currently depends on this property + // being called "element". + update.payload = {element: initialChildren}; + enqueueUpdate(current, update, lane); + scheduleInitialHydrationOnRoot(root, lane, eventTime); + + return root; +} + export function updateContainer( element: ReactNodeList, container: OpaqueRoot, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 42d230a4a655f..4a1880e0cf848 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -517,8 +517,31 @@ export function scheduleUpdateOnFiber( } } - // TODO: Consolidate with `isInterleavedUpdate` check - if (root === workInProgressRoot) { + if (root.isDehydrated && root.tag !== LegacyRoot) { + // This root's shell hasn't hydrated yet. Revert to client rendering. + // TODO: Log a recoverable error + if (workInProgressRoot === root) { + // If this happened during an interleaved event, interrupt the + // in-progress hydration. Theoretically, we could attempt to force a + // synchronous hydration before switching to client rendering, but the + // most common reason the shell hasn't hydrated yet is because it + // suspended. So it's very likely to suspend again anyway. For + // simplicity, we'll skip that atttempt and go straight to + // client rendering. + // + // Another way to model this would be to give the initial hydration its + // own special lane. However, it may not be worth adding a lane solely + // for this purpose, so we'll wait until we find another use case before + // adding it. + // + // TODO: Consider only interrupting hydration if the priority of the + // update is higher than default. + prepareFreshStack(root, NoLanes); + } + root.isDehydrated = false; + } else if (root === workInProgressRoot) { + // TODO: Consolidate with `isInterleavedUpdate` check + // Received an update to a tree that's in the middle of rendering. Mark // that there was an interleaved update work on this root. Unless the // `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render @@ -564,6 +587,26 @@ export function scheduleUpdateOnFiber( return root; } +export function scheduleInitialHydrationOnRoot( + root: FiberRoot, + lane: Lane, + eventTime: number, +) { + // This is a special fork of scheduleUpdateOnFiber that is only used to + // schedule the initial hydration of a root that has just been created. Most + // of the stuff in scheduleUpdateOnFiber can be skipped. + // + // The main reason for this separate path, though, is to distinguish the + // initial children from subsequent updates. In fully client-rendered roots + // (createRoot instead of hydrateRoot), all top-level renders are modeled as + // updates, but hydration roots are special because the initial render must + // match what was rendered on the server. + const current = root.current; + current.lanes = lane; + markRootUpdated(root, lane, eventTime); + ensureRootIsScheduled(root, eventTime); +} + // This is split into a separate function so we can mark a fiber with pending // work without treating it as a typical update that originates from an event; // e.g. retrying a Suspense boundary isn't an update, but it does schedule work diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 4098e196488b4..b9fada5bd00b0 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -517,8 +517,31 @@ export function scheduleUpdateOnFiber( } } - // TODO: Consolidate with `isInterleavedUpdate` check - if (root === workInProgressRoot) { + if (root.isDehydrated && root.tag !== LegacyRoot) { + // This root's shell hasn't hydrated yet. Revert to client rendering. + // TODO: Log a recoverable error + if (workInProgressRoot === root) { + // If this happened during an interleaved event, interrupt the + // in-progress hydration. Theoretically, we could attempt to force a + // synchronous hydration before switching to client rendering, but the + // most common reason the shell hasn't hydrated yet is because it + // suspended. So it's very likely to suspend again anyway. For + // simplicity, we'll skip that atttempt and go straight to + // client rendering. + // + // Another way to model this would be to give the initial hydration its + // own special lane. However, it may not be worth adding a lane solely + // for this purpose, so we'll wait until we find another use case before + // adding it. + // + // TODO: Consider only interrupting hydration if the priority of the + // update is higher than default. + prepareFreshStack(root, NoLanes); + } + root.isDehydrated = false; + } else if (root === workInProgressRoot) { + // TODO: Consolidate with `isInterleavedUpdate` check + // Received an update to a tree that's in the middle of rendering. Mark // that there was an interleaved update work on this root. Unless the // `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render @@ -564,6 +587,26 @@ export function scheduleUpdateOnFiber( return root; } +export function scheduleInitialHydrationOnRoot( + root: FiberRoot, + lane: Lane, + eventTime: number, +) { + // This is a special fork of scheduleUpdateOnFiber that is only used to + // schedule the initial hydration of a root that has just been created. Most + // of the stuff in scheduleUpdateOnFiber can be skipped. + // + // The main reason for this separate path, though, is to distinguish the + // initial children from subsequent updates. In fully client-rendered roots + // (createRoot instead of hydrateRoot), all top-level renders are modeled as + // updates, but hydration roots are special because the initial render must + // match what was rendered on the server. + const current = root.current; + current.lanes = lane; + markRootUpdated(root, lane, eventTime); + ensureRootIsScheduled(root, eventTime); +} + // This is split into a separate function so we can mark a fiber with pending // work without treating it as a typical update that originates from an event; // e.g. retrying a Suspense boundary isn't an update, but it does schedule work diff --git a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js index 16ebb4657d28d..bbaed39d15ed2 100644 --- a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js +++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js @@ -35,15 +35,6 @@ describe('useMutableSourceHydration', () => { React.useMutableSource || React.unstable_useMutableSource; }); - function dispatchAndSetCurrentEvent(el, event) { - try { - window.event = event; - el.dispatchEvent(event); - } finally { - window.event = undefined; - } - } - const defaultGetSnapshot = source => source.value; const defaultSubscribe = (source, callback) => source.subscribe(callback); @@ -380,79 +371,4 @@ describe('useMutableSourceHydration', () => { 'an issue.', ]); }); - - // @gate !enableSyncDefaultUpdates - // @gate enableUseMutableSource - it('should detect a tear during a higher priority interruption', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source, param => param.version); - - function Unrelated({flag}) { - Scheduler.unstable_yieldValue(flag); - return flag; - } - - function TestComponent({flag}) { - return ( - <> - - - - ); - } - - const container = document.createElement('div'); - document.body.appendChild(container); - - const htmlString = ReactDOMServer.renderToString( - , - ); - container.innerHTML = htmlString; - expect(Scheduler).toHaveYielded([1, 'a:one']); - expect(source.listenerCount).toBe(0); - - expect(() => { - act(() => { - let root; - if (gate(flags => flags.enableSyncDefaultUpdates)) { - React.startTransition(() => { - root = ReactDOM.hydrateRoot(container, , { - mutableSources: [mutableSource], - }); - }); - } else { - root = ReactDOM.hydrateRoot(container, , { - mutableSources: [mutableSource], - }); - } - expect(Scheduler).toFlushAndYieldThrough([1]); - - // Render an update which will be higher priority than the hydration. - // We can do this by scheduling the update inside a mouseover event. - const arbitraryElement = document.createElement('div'); - const mouseOverEvent = document.createEvent('MouseEvents'); - mouseOverEvent.initEvent('mouseover', true, true); - arbitraryElement.addEventListener('mouseover', () => { - root.render(); - }); - dispatchAndSetCurrentEvent(arbitraryElement, mouseOverEvent); - - expect(Scheduler).toFlushAndYieldThrough([2]); - source.value = 'two'; - }); - }).toErrorDev( - 'Warning: Text content did not match. Server: "1" Client: "2"', - ); - expect(source.listenerCount).toBe(1); - if (gate(flags => flags.enableSyncDefaultUpdates)) { - expect(Scheduler).toHaveYielded([2, 'a:two']); - } else { - expect(Scheduler).toHaveYielded(['a:two']); - } - }); });