[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
Sebastian Markbåge committed
Sep 27, 2019 at 17:27 UTC
fe31cc710ec0a768bbeb9f382ec3055a09c97149
4 files changed
+235
-3
packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
+176
@@ -114,4 +114,180 @@ describe('ReactDOMServerSelectiveHydration', () => {
114
115
document.body.removeChild(container);
116
});
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
+ });
293
});
packages/react-dom/src/client/ReactDOM.js
+6
-1
@@ -40,6 +40,7 @@ import {
40
flushPassiveEffects,
41
IsThisRendererActing,
42
attemptSynchronousHydration,
43
+ attemptUserBlockingHydration,
44
} from 'react-reconciler/inline.dom';
45
import {createPortal as createPortalImpl} from 'shared/ReactPortal';
46
import {canUseDOM} from 'shared/ExecutionEnvironment';
@@ -75,7 +76,10 @@ import {
76
} from './ReactDOMComponentTree';
77
import {restoreControlledState} from './ReactDOMComponent';
78
import {dispatchEvent} from '../events/ReactDOMEventListener';
78
-import {setAttemptSynchronousHydration} from '../events/ReactDOMEventReplaying';
79
+import {
80
+ setAttemptSynchronousHydration,
81
+ setAttemptUserBlockingHydration,
82
+} from '../events/ReactDOMEventReplaying';
83
import {eagerlyTrapReplayableEvents} from '../events/ReactDOMEventReplaying';
84
import {
85
ELEMENT_NODE,
@@ -86,6 +90,7 @@ import {
90
import {ROOT_ATTRIBUTE_NAME} from '../shared/DOMProperty';
91
92
setAttemptSynchronousHydration(attemptSynchronousHydration);
93
+setAttemptUserBlockingHydration(attemptUserBlockingHydration);
94
95
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
96
packages/react-dom/src/events/ReactDOMEventReplaying.js
+12
@@ -37,6 +37,12 @@ export function setAttemptSynchronousHydration(fn: (fiber: Object) => void) {
37
attemptSynchronousHydration = fn;
38
}
39
40
+let attemptUserBlockingHydration: (fiber: Object) => void;
41
+
42
+export function setAttemptUserBlockingHydration(fn: (fiber: Object) => void) {
43
+ attemptUserBlockingHydration = fn;
44
+}
45
+
46
// TODO: Upgrade this definition once we're on a newer version of Flow that
47
// has this definition built-in.
48
type PointerEvent = Event & {
@@ -436,6 +442,12 @@ function replayUnblockedEvents() {
442
let nextDiscreteEvent = queuedDiscreteEvents[0];
443
if (nextDiscreteEvent.blockedOn !== null) {
444
// 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
+ }
451
break;
452
}
453
let nextBlockedOn = attemptToDispatchEvent(
packages/react-reconciler/src/ReactFiberReconciler.js
+41
-2
@@ -20,7 +20,10 @@ import {FundamentalComponent} from 'shared/ReactWorkTags';
20
import type {ReactNodeList} from 'shared/ReactTypes';
21
import type {ExpirationTime} from './ReactFiberExpirationTime';
22
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
23
-import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent';
23
+import type {
24
+ SuspenseHydrationCallbacks,
25
+ SuspenseState,
26
+} from './ReactFiberSuspenseComponent';
27
28
import {
29
findCurrentHostFiber,
@@ -75,7 +78,7 @@ import {
78
current as ReactCurrentFiberCurrent,
79
} from './ReactCurrentFiber';
80
import {StrictMode} from './ReactTypeOfMode';
78
-import {Sync} from './ReactFiberExpirationTime';
81
+import {Sync, computeInteractiveExpiration} from './ReactFiberExpirationTime';
82
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
83
import {
84
scheduleRefresh,
@@ -378,10 +381,46 @@ export function attemptSynchronousHydration(fiber: Fiber): void {
381
break;
382
case SuspenseComponent:
383
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);
389
break;
390
}
391
}
392
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
+
424
export {findHostInstance};
425
426
export {findHostInstanceWithWarning};