diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js index 06fa2de298..72942d4f2d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js @@ -114,4 +114,180 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); + + it('hydrates at higher pri if sync did not work first time', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child({text}) { + if ((text === 'A' || text === 'D') && suspend) { + throw promise; + } + Scheduler.unstable_yieldValue(text); + return ( + { + e.preventDefault(); + Scheduler.unstable_yieldValue('Clicked ' + text); + }}> + {text} + + ); + } + + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
+ + + + + + + + + + + + +
+ ); + } + + let finalHTML = ReactDOMServer.renderToString(); + + expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); + + let container = document.createElement('div'); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + container.innerHTML = finalHTML; + + let spanD = container.getElementsByTagName('span')[3]; + + suspend = true; + + // A and D will be suspended. We'll click on D which should take + // priority, after we unsuspend. + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + + // Nothing has been hydrated so far. + expect(Scheduler).toHaveYielded([]); + + // This click target cannot be hydrated yet because it's suspended. + let result = dispatchClickEvent(spanD); + + expect(Scheduler).toHaveYielded(['App']); + + expect(result).toBe(true); + + // Continuing rendering will render B next. + expect(Scheduler).toFlushAndYield(['B', 'C']); + + suspend = false; + resolve(); + await promise; + + // After the click, we should prioritize D and the Click first, + // and only after that render A and C. + expect(Scheduler).toFlushAndYield(['D', 'Clicked D', 'A']); + + document.body.removeChild(container); + }); + + it('hydrates at higher pri for secondary discrete events', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child({text}) { + if ((text === 'A' || text === 'D') && suspend) { + throw promise; + } + Scheduler.unstable_yieldValue(text); + return ( + { + e.preventDefault(); + Scheduler.unstable_yieldValue('Clicked ' + text); + }}> + {text} + + ); + } + + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
+ + + + + + + + + + + + +
+ ); + } + + let finalHTML = ReactDOMServer.renderToString(); + + expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); + + let container = document.createElement('div'); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + container.innerHTML = finalHTML; + + let spanA = container.getElementsByTagName('span')[0]; + let spanC = container.getElementsByTagName('span')[2]; + let spanD = container.getElementsByTagName('span')[3]; + + suspend = true; + + // A and D will be suspended. We'll click on D which should take + // priority, after we unsuspend. + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + + // Nothing has been hydrated so far. + expect(Scheduler).toHaveYielded([]); + + // This click target cannot be hydrated yet because the first is Suspended. + dispatchClickEvent(spanA); + dispatchClickEvent(spanC); + dispatchClickEvent(spanD); + + expect(Scheduler).toHaveYielded(['App']); + + suspend = false; + resolve(); + await promise; + + // We should prioritize hydrating A, C and D first since we clicked in + // them. Only after they're done will we hydrate B. + expect(Scheduler).toFlushAndYield([ + 'A', + 'Clicked A', + 'C', + 'Clicked C', + 'D', + 'Clicked D', + // B should render last since it wasn't clicked. + 'B', + ]); + + document.body.removeChild(container); + }); }); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 7a7b4de04a..27b0eb88f1 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -40,6 +40,7 @@ import { flushPassiveEffects, IsThisRendererActing, attemptSynchronousHydration, + attemptUserBlockingHydration, } from 'react-reconciler/inline.dom'; import {createPortal as createPortalImpl} from 'shared/ReactPortal'; import {canUseDOM} from 'shared/ExecutionEnvironment'; @@ -75,7 +76,10 @@ import { } from './ReactDOMComponentTree'; import {restoreControlledState} from './ReactDOMComponent'; import {dispatchEvent} from '../events/ReactDOMEventListener'; -import {setAttemptSynchronousHydration} from '../events/ReactDOMEventReplaying'; +import { + setAttemptSynchronousHydration, + setAttemptUserBlockingHydration, +} from '../events/ReactDOMEventReplaying'; import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying'; import { ELEMENT_NODE, @@ -86,6 +90,7 @@ import { import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty'; setAttemptSynchronousHydration(attemptSynchronousHydration); +setAttemptUserBlockingHydration(attemptUserBlockingHydration); const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 76ce3c7158..866b8a492e 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -37,6 +37,12 @@ export function setAttemptSynchronousHydration(fn: (fiber: Object) => void) { attemptSynchronousHydration = fn; } +let attemptUserBlockingHydration: (fiber: Object) => void; + +export function setAttemptUserBlockingHydration(fn: (fiber: Object) => void) { + attemptUserBlockingHydration = fn; +} + // TODO: Upgrade this definition once we're on a newer version of Flow that // has this definition built-in. type PointerEvent = Event & { @@ -436,6 +442,12 @@ function replayUnblockedEvents() { let nextDiscreteEvent = queuedDiscreteEvents[0]; if (nextDiscreteEvent.blockedOn !== null) { // We're still blocked. + // Increase the priority of this boundary to unblock + // the next discrete event. + let fiber = getInstanceFromNode(nextDiscreteEvent.blockedOn); + if (fiber !== null) { + attemptUserBlockingHydration(fiber); + } break; } let nextBlockedOn = attemptToDispatchEvent( diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 4aa0219ca9..e008d202ab 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -20,7 +20,10 @@ import {FundamentalComponent} from 'shared/ReactWorkTags'; import type {ReactNodeList} from 'shared/ReactTypes'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; -import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent'; +import type { + SuspenseHydrationCallbacks, + SuspenseState, +} from './ReactFiberSuspenseComponent'; import { findCurrentHostFiber, @@ -75,7 +78,7 @@ import { current as ReactCurrentFiberCurrent, } from './ReactCurrentFiber'; import {StrictMode} from './ReactTypeOfMode'; -import {Sync} from './ReactFiberExpirationTime'; +import {Sync, computeInteractiveExpiration} from './ReactFiberExpirationTime'; import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; import { scheduleRefresh, @@ -378,10 +381,46 @@ export function attemptSynchronousHydration(fiber: Fiber): void { break; case SuspenseComponent: flushSync(() => scheduleWork(fiber, Sync)); + // If we're still blocked after this, we need to increase + // the priority of any promises resolving within this + // boundary so that they next attempt also has higher pri. + let retryExpTime = computeInteractiveExpiration(requestCurrentTime()); + markRetryTimeIfNotHydrated(fiber, retryExpTime); break; } } +function markRetryTimeImpl(fiber: Fiber, retryTime: ExpirationTime) { + let suspenseState: null | SuspenseState = fiber.memoizedState; + if (suspenseState !== null && suspenseState.dehydrated !== null) { + if (suspenseState.retryTime < retryTime) { + suspenseState.retryTime = retryTime; + } + } +} + +// Increases the priority of thennables when they resolve within this boundary. +function markRetryTimeIfNotHydrated(fiber: Fiber, retryTime: ExpirationTime) { + markRetryTimeImpl(fiber, retryTime); + let alternate = fiber.alternate; + if (alternate) { + markRetryTimeImpl(alternate, retryTime); + } +} + +export function attemptUserBlockingHydration(fiber: Fiber): void { + if (fiber.tag !== SuspenseComponent) { + // We ignore HostRoots here because we can't increase + // their priority and they should not suspend on I/O, + // since you have to wrap anything that might suspend in + // Suspense. + return; + } + let expTime = computeInteractiveExpiration(requestCurrentTime()); + scheduleWork(fiber, expTime); + markRetryTimeIfNotHydrated(fiber, expTime); +} + export {findHostInstance}; export {findHostInstanceWithWarning};