Skip to content

Commit fe31cc7

Browse files
authored
[Selective Hydration] Increase priority for non-synchronous discrete events and retries (#16935)
* Increase retryTime for increased priority dehydrated boundaries * Increaese the priority to user blocking for every next discrete boundary
1 parent b550679 commit fe31cc7

File tree

4 files changed

+235
-3
lines changed

4 files changed

+235
-3
lines changed

packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,180 @@ describe('ReactDOMServerSelectiveHydration', () => {
114114

115115
document.body.removeChild(container);
116116
});
117+
118+
it('hydrates at higher pri if sync did not work first time', async () => {
119+
let suspend = false;
120+
let resolve;
121+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
122+
123+
function Child({text}) {
124+
if ((text === 'A' || text === 'D') && suspend) {
125+
throw promise;
126+
}
127+
Scheduler.unstable_yieldValue(text);
128+
return (
129+
<span
130+
onClick={e => {
131+
e.preventDefault();
132+
Scheduler.unstable_yieldValue('Clicked ' + text);
133+
}}>
134+
{text}
135+
</span>
136+
);
137+
}
138+
139+
function App() {
140+
Scheduler.unstable_yieldValue('App');
141+
return (
142+
<div>
143+
<Suspense fallback="Loading...">
144+
<Child text="A" />
145+
</Suspense>
146+
<Suspense fallback="Loading...">
147+
<Child text="B" />
148+
</Suspense>
149+
<Suspense fallback="Loading...">
150+
<Child text="C" />
151+
</Suspense>
152+
<Suspense fallback="Loading...">
153+
<Child text="D" />
154+
</Suspense>
155+
</div>
156+
);
157+
}
158+
159+
let finalHTML = ReactDOMServer.renderToString(<App />);
160+
161+
expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']);
162+
163+
let container = document.createElement('div');
164+
// We need this to be in the document since we'll dispatch events on it.
165+
document.body.appendChild(container);
166+
167+
container.innerHTML = finalHTML;
168+
169+
let spanD = container.getElementsByTagName('span')[3];
170+
171+
suspend = true;
172+
173+
// A and D will be suspended. We'll click on D which should take
174+
// priority, after we unsuspend.
175+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
176+
root.render(<App />);
177+
178+
// Nothing has been hydrated so far.
179+
expect(Scheduler).toHaveYielded([]);
180+
181+
// This click target cannot be hydrated yet because it's suspended.
182+
let result = dispatchClickEvent(spanD);
183+
184+
expect(Scheduler).toHaveYielded(['App']);
185+
186+
expect(result).toBe(true);
187+
188+
// Continuing rendering will render B next.
189+
expect(Scheduler).toFlushAndYield(['B', 'C']);
190+
191+
suspend = false;
192+
resolve();
193+
await promise;
194+
195+
// After the click, we should prioritize D and the Click first,
196+
// and only after that render A and C.
197+
expect(Scheduler).toFlushAndYield(['D', 'Clicked D', 'A']);
198+
199+
document.body.removeChild(container);
200+
});
201+
202+
it('hydrates at higher pri for secondary discrete events', async () => {
203+
let suspend = false;
204+
let resolve;
205+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
206+
207+
function Child({text}) {
208+
if ((text === 'A' || text === 'D') && suspend) {
209+
throw promise;
210+
}
211+
Scheduler.unstable_yieldValue(text);
212+
return (
213+
<span
214+
onClick={e => {
215+
e.preventDefault();
216+
Scheduler.unstable_yieldValue('Clicked ' + text);
217+
}}>
218+
{text}
219+
</span>
220+
);
221+
}
222+
223+
function App() {
224+
Scheduler.unstable_yieldValue('App');
225+
return (
226+
<div>
227+
<Suspense fallback="Loading...">
228+
<Child text="A" />
229+
</Suspense>
230+
<Suspense fallback="Loading...">
231+
<Child text="B" />
232+
</Suspense>
233+
<Suspense fallback="Loading...">
234+
<Child text="C" />
235+
</Suspense>
236+
<Suspense fallback="Loading...">
237+
<Child text="D" />
238+
</Suspense>
239+
</div>
240+
);
241+
}
242+
243+
let finalHTML = ReactDOMServer.renderToString(<App />);
244+
245+
expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']);
246+
247+
let container = document.createElement('div');
248+
// We need this to be in the document since we'll dispatch events on it.
249+
document.body.appendChild(container);
250+
251+
container.innerHTML = finalHTML;
252+
253+
let spanA = container.getElementsByTagName('span')[0];
254+
let spanC = container.getElementsByTagName('span')[2];
255+
let spanD = container.getElementsByTagName('span')[3];
256+
257+
suspend = true;
258+
259+
// A and D will be suspended. We'll click on D which should take
260+
// priority, after we unsuspend.
261+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
262+
root.render(<App />);
263+
264+
// Nothing has been hydrated so far.
265+
expect(Scheduler).toHaveYielded([]);
266+
267+
// This click target cannot be hydrated yet because the first is Suspended.
268+
dispatchClickEvent(spanA);
269+
dispatchClickEvent(spanC);
270+
dispatchClickEvent(spanD);
271+
272+
expect(Scheduler).toHaveYielded(['App']);
273+
274+
suspend = false;
275+
resolve();
276+
await promise;
277+
278+
// We should prioritize hydrating A, C and D first since we clicked in
279+
// them. Only after they're done will we hydrate B.
280+
expect(Scheduler).toFlushAndYield([
281+
'A',
282+
'Clicked A',
283+
'C',
284+
'Clicked C',
285+
'D',
286+
'Clicked D',
287+
// B should render last since it wasn't clicked.
288+
'B',
289+
]);
290+
291+
document.body.removeChild(container);
292+
});
117293
});

packages/react-dom/src/client/ReactDOM.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
flushPassiveEffects,
4141
IsThisRendererActing,
4242
attemptSynchronousHydration,
43+
attemptUserBlockingHydration,
4344
} from 'react-reconciler/inline.dom';
4445
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
4546
import {canUseDOM} from 'shared/ExecutionEnvironment';
@@ -75,7 +76,10 @@ import {
7576
} from './ReactDOMComponentTree';
7677
import {restoreControlledState} from './ReactDOMComponent';
7778
import {dispatchEvent} from '../events/ReactDOMEventListener';
78-
import {setAttemptSynchronousHydration} from '../events/ReactDOMEventReplaying';
79+
import {
80+
setAttemptSynchronousHydration,
81+
setAttemptUserBlockingHydration,
82+
} from '../events/ReactDOMEventReplaying';
7983
import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying';
8084
import {
8185
ELEMENT_NODE,
@@ -86,6 +90,7 @@ import {
8690
import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty';
8791

8892
setAttemptSynchronousHydration(attemptSynchronousHydration);
93+
setAttemptUserBlockingHydration(attemptUserBlockingHydration);
8994

9095
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
9196

packages/react-dom/src/events/ReactDOMEventReplaying.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export function setAttemptSynchronousHydration(fn: (fiber: Object) => void) {
3737
attemptSynchronousHydration = fn;
3838
}
3939

40+
let attemptUserBlockingHydration: (fiber: Object) => void;
41+
42+
export function setAttemptUserBlockingHydration(fn: (fiber: Object) => void) {
43+
attemptUserBlockingHydration = fn;
44+
}
45+
4046
// TODO: Upgrade this definition once we're on a newer version of Flow that
4147
// has this definition built-in.
4248
type PointerEvent = Event & {
@@ -436,6 +442,12 @@ function replayUnblockedEvents() {
436442
let nextDiscreteEvent = queuedDiscreteEvents[0];
437443
if (nextDiscreteEvent.blockedOn !== null) {
438444
// We're still blocked.
445+
// Increase the priority of this boundary to unblock
446+
// the next discrete event.
447+
let fiber = getInstanceFromNode(nextDiscreteEvent.blockedOn);
448+
if (fiber !== null) {
449+
attemptUserBlockingHydration(fiber);
450+
}
439451
break;
440452
}
441453
let nextBlockedOn = attemptToDispatchEvent(

packages/react-reconciler/src/ReactFiberReconciler.js

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {FundamentalComponent} from 'shared/ReactWorkTags';
2020
import type {ReactNodeList} from 'shared/ReactTypes';
2121
import type {ExpirationTime} from './ReactFiberExpirationTime';
2222
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
23-
import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent';
23+
import type {
24+
SuspenseHydrationCallbacks,
25+
SuspenseState,
26+
} from './ReactFiberSuspenseComponent';
2427

2528
import {
2629
findCurrentHostFiber,
@@ -75,7 +78,7 @@ import {
7578
current as ReactCurrentFiberCurrent,
7679
} from './ReactCurrentFiber';
7780
import {StrictMode} from './ReactTypeOfMode';
78-
import {Sync} from './ReactFiberExpirationTime';
81+
import {Sync, computeInteractiveExpiration} from './ReactFiberExpirationTime';
7982
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
8083
import {
8184
scheduleRefresh,
@@ -378,10 +381,46 @@ export function attemptSynchronousHydration(fiber: Fiber): void {
378381
break;
379382
case SuspenseComponent:
380383
flushSync(() => scheduleWork(fiber, Sync));
384+
// If we're still blocked after this, we need to increase
385+
// the priority of any promises resolving within this
386+
// boundary so that they next attempt also has higher pri.
387+
let retryExpTime = computeInteractiveExpiration(requestCurrentTime());
388+
markRetryTimeIfNotHydrated(fiber, retryExpTime);
381389
break;
382390
}
383391
}
384392

393+
function markRetryTimeImpl(fiber: Fiber, retryTime: ExpirationTime) {
394+
let suspenseState: null | SuspenseState = fiber.memoizedState;
395+
if (suspenseState !== null && suspenseState.dehydrated !== null) {
396+
if (suspenseState.retryTime < retryTime) {
397+
suspenseState.retryTime = retryTime;
398+
}
399+
}
400+
}
401+
402+
// Increases the priority of thennables when they resolve within this boundary.
403+
function markRetryTimeIfNotHydrated(fiber: Fiber, retryTime: ExpirationTime) {
404+
markRetryTimeImpl(fiber, retryTime);
405+
let alternate = fiber.alternate;
406+
if (alternate) {
407+
markRetryTimeImpl(alternate, retryTime);
408+
}
409+
}
410+
411+
export function attemptUserBlockingHydration(fiber: Fiber): void {
412+
if (fiber.tag !== SuspenseComponent) {
413+
// We ignore HostRoots here because we can't increase
414+
// their priority and they should not suspend on I/O,
415+
// since you have to wrap anything that might suspend in
416+
// Suspense.
417+
return;
418+
}
419+
let expTime = computeInteractiveExpiration(requestCurrentTime());
420+
scheduleWork(fiber, expTime);
421+
markRetryTimeIfNotHydrated(fiber, expTime);
422+
}
423+
385424
export {findHostInstance};
386425

387426
export {findHostInstanceWithWarning};

0 commit comments

Comments
 (0)