diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
index ca94bc3bc1e0b..02d1dff1a9ec6 100644
--- a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
@@ -14,6 +14,7 @@ import {createEventTarget} from 'dom-event-testing-library';
let React;
let ReactDOM;
let ReactDOMServer;
+let ReactTestUtils;
let Scheduler;
let Suspense;
let usePress;
@@ -102,6 +103,7 @@ describe('ReactDOMServerSelectiveHydration', () => {
React = require('react');
ReactDOM = require('react-dom');
ReactDOMServer = require('react-dom/server');
+ ReactTestUtils = require('react-dom/test-utils');
Scheduler = require('scheduler');
Suspense = React.Suspense;
usePress = require('react-interactions/events/press').usePress;
@@ -585,7 +587,7 @@ describe('ReactDOMServerSelectiveHydration', () => {
document.body.removeChild(container);
});
- it('hydrates the last target as higher priority for continuous events', async () => {
+ it('hydrates the hovered targets as higher priority for continuous events', async () => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
@@ -669,21 +671,107 @@ describe('ReactDOMServerSelectiveHydration', () => {
// We should prioritize hydrating D first because we clicked it.
// Next we should hydrate C since that's the current hover target.
- // Next it doesn't matter if we hydrate A or B first but as an
- // implementation detail we're currently hydrating B first since
- // we at one point hovered over it and we never deprioritized it.
+ // To simplify implementation details we hydrate both B and C at
+ // the same time since B was already scheduled.
+ // This is ok because it will at least not continue for nested
+ // boundary. See the next test below.
expect(Scheduler).toFlushAndYield([
'D',
'Clicked D',
+ 'B', // Ideally this should be later.
'C',
'Hover C',
- 'B',
'A',
]);
document.body.removeChild(container);
});
+ it('hydrates the last target path first for continuous 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('Hover ' + 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 spanB = container.getElementsByTagName('span')[1];
+ 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.createRoot(container, {hydrate: true});
+ root.render();
+
+ // Nothing has been hydrated so far.
+ expect(Scheduler).toHaveYielded([]);
+
+ // Hover over B and then C.
+ dispatchMouseHoverEvent(spanB, spanD);
+ dispatchMouseHoverEvent(spanC, spanB);
+
+ suspend = false;
+ resolve();
+ await promise;
+
+ // We should prioritize hydrating D first because we clicked it.
+ // Next we should hydrate C since that's the current hover target.
+ // Next it doesn't matter if we hydrate A or B first but as an
+ // implementation detail we're currently hydrating B first since
+ // we at one point hovered over it and we never deprioritized it.
+ expect(Scheduler).toFlushAndYield(['App', 'C', 'Hover C', 'A', 'B', 'D']);
+
+ document.body.removeChild(container);
+ });
+
it('hydrates the last explicitly hydrated target at higher priority', async () => {
function Child({text}) {
Scheduler.unstable_yieldValue(text);
@@ -731,4 +819,110 @@ describe('ReactDOMServerSelectiveHydration', () => {
// gets highest priority followed by the next added.
expect(Scheduler).toFlushAndYield(['App', 'C', 'B', 'A']);
});
+
+ it('hydrates before an update even if hydration moves away from it', async () => {
+ function Child({text}) {
+ Scheduler.unstable_yieldValue(text);
+ return {text};
+ }
+ let ChildWithBoundary = React.memo(function({text}) {
+ return (
+
+
+
+
+ );
+ });
+
+ function App({a}) {
+ Scheduler.unstable_yieldValue('App');
+ React.useEffect(() => {
+ Scheduler.unstable_yieldValue('Commit');
+ });
+ return (
+
+
+
+
+
+ );
+ }
+
+ let finalHTML = ReactDOMServer.renderToString();
+
+ expect(Scheduler).toHaveYielded(['App', 'A', 'a', 'B', 'b', 'C', 'c']);
+
+ let container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ // We need this to be in the document since we'll dispatch events on it.
+ document.body.appendChild(container);
+
+ let spanA = container.getElementsByTagName('span')[0];
+ let spanB = container.getElementsByTagName('span')[2];
+ let spanC = container.getElementsByTagName('span')[4];
+
+ let root = ReactDOM.createRoot(container, {hydrate: true});
+ ReactTestUtils.act(() => {
+ root.render();
+
+ // Hydrate the shell.
+ expect(Scheduler).toFlushAndYieldThrough(['App', 'Commit']);
+
+ // Render an update at Idle priority that needs to update A.
+ Scheduler.unstable_runWithPriority(
+ Scheduler.unstable_IdlePriority,
+ () => {
+ root.render();
+ },
+ );
+
+ // Start rendering. This will force the first boundary to hydrate
+ // by scheduling it at one higher pri than Idle.
+ expect(Scheduler).toFlushAndYieldThrough(['App', 'A']);
+
+ // Hover over A which (could) schedule at one higher pri than Idle.
+ dispatchMouseHoverEvent(spanA, null);
+
+ // Before, we're done we now switch to hover over B.
+ // This is meant to test that this doesn't cause us to forget that
+ // we still have to hydrate A. The first boundary.
+ // This also tests that we don't do the -1 down-prioritization of
+ // continuous hover events because that would decrease its priority
+ // to Idle.
+ dispatchMouseHoverEvent(spanB, spanA);
+
+ // Also click C to prioritize that even higher which resets the
+ // priority levels.
+ dispatchClickEvent(spanC);
+
+ expect(Scheduler).toHaveYielded([
+ // Hydrate C first since we clicked it.
+ 'C',
+ 'c',
+ ]);
+
+ expect(Scheduler).toFlushAndYield([
+ // Finish hydration of A since we forced it to hydrate.
+ 'A',
+ 'a',
+ // Also, hydrate B since we hovered over it.
+ // It's not important which one comes first. A or B.
+ // As long as they both happen before the Idle update.
+ 'B',
+ 'b',
+ // Begin the Idle update again.
+ 'App',
+ 'AA',
+ 'aa',
+ 'Commit',
+ ]);
+ });
+
+ let spanA2 = container.getElementsByTagName('span')[0];
+ // This is supposed to have been hydrated, not replaced.
+ expect(spanA).toBe(spanA2);
+
+ document.body.removeChild(container);
+ });
});
diff --git a/packages/react-reconciler/src/ReactFiberExpirationTime.js b/packages/react-reconciler/src/ReactFiberExpirationTime.js
index 5f4660dd5175f..72d58dff4eb9f 100644
--- a/packages/react-reconciler/src/ReactFiberExpirationTime.js
+++ b/packages/react-reconciler/src/ReactFiberExpirationTime.js
@@ -32,10 +32,9 @@ export const Never = 1;
// Idle is slightly higher priority than Never. It must completely finish in
// order to be consistent.
export const Idle = 2;
-// Continuous Hydration is a moving priority. It is slightly higher than Idle
-// and is used to increase priority of hover targets. It is increasing with
-// each usage so that last always wins.
-let ContinuousHydration = 3;
+// Continuous Hydration is slightly higher than Idle and is used to increase
+// priority of hover targets.
+export const ContinuousHydration = 3;
export const Sync = MAX_SIGNED_31_BIT_INT;
export const Batched = Sync - 1;
@@ -119,15 +118,6 @@ export function computeInteractiveExpiration(currentTime: ExpirationTime) {
);
}
-export function computeContinuousHydrationExpiration(
- currentTime: ExpirationTime,
-) {
- // Each time we ask for a new one of these we increase the priority.
- // This ensures that the last one always wins since we can't deprioritize
- // once we've scheduled work already.
- return ContinuousHydration++;
-}
-
export function inferPriorityFromExpirationTime(
currentTime: ExpirationTime,
expirationTime: ExpirationTime,
diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js
index fb46524645aee..8a91ea9b4c25c 100644
--- a/packages/react-reconciler/src/ReactFiberReconciler.js
+++ b/packages/react-reconciler/src/ReactFiberReconciler.js
@@ -76,8 +76,8 @@ import {
import {StrictMode} from './ReactTypeOfMode';
import {
Sync,
+ ContinuousHydration,
computeInteractiveExpiration,
- computeContinuousHydrationExpiration,
} from './ReactFiberExpirationTime';
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
import {
@@ -384,11 +384,8 @@ export function attemptContinuousHydration(fiber: Fiber): void {
// Suspense.
return;
}
- let expTime = computeContinuousHydrationExpiration(
- requestCurrentTimeForUpdate(),
- );
- scheduleWork(fiber, expTime);
- markRetryTimeIfNotHydrated(fiber, expTime);
+ scheduleWork(fiber, ContinuousHydration);
+ markRetryTimeIfNotHydrated(fiber, ContinuousHydration);
}
export function attemptHydrationAtCurrentPriority(fiber: Fiber): void {