Interaction tracing works across hidden and SSR hydration boundaries (#15872)
* Interaction tracing works across hidden and SSR hydration boundaries
Brian Vaughn committed
Jun 14, 2019 at 18:08 UTC
801feed95c9fa3e26d1f3e534ac3a88b127652ca
8 files changed
+780
-20
packages/react-reconciler/src/ReactFiberBeginWork.js
+12
-1
@@ -54,6 +54,7 @@ import {
54
debugRenderPhaseSideEffects,
55
debugRenderPhaseSideEffectsForStrictMode,
56
enableProfilerTimer,
57
+ enableSchedulerTracing,
58
enableSuspenseServerRenderer,
59
enableEventAPI,
60
} from 'shared/ReactFeatureFlags';
@@ -164,7 +165,11 @@ import {
165
createWorkInProgress,
166
isSimpleFunctionComponent,
167
} from './ReactFiber';
167
-import {requestCurrentTime, retryTimedOutBoundary} from './ReactFiberWorkLoop';
168
+import {
169
+ markDidDeprioritizeIdleSubtree,
170
+ requestCurrentTime,
171
+ retryTimedOutBoundary,
172
+} from './ReactFiberWorkLoop';
173
174
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
175
@@ -988,6 +993,9 @@ function updateHostComponent(current, workInProgress, renderExpirationTime) {
993
renderExpirationTime !== Never &&
994
shouldDeprioritizeSubtree(type, nextProps)
995
) {
996
+ if (enableSchedulerTracing) {
997
+ markDidDeprioritizeIdleSubtree();
998
+ }
999
// Schedule this fiber to re-render at offscreen priority. Then bailout.
1000
workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
1001
return null;
@@ -2265,6 +2273,9 @@ function beginWork(
2273
renderExpirationTime !== Never &&
2274
shouldDeprioritizeSubtree(workInProgress.type, newProps)
2275
) {
2276
+ if (enableSchedulerTracing) {
2277
+ markDidDeprioritizeIdleSubtree();
2278
+ }
2279
// Schedule this fiber to re-render at offscreen priority. Then bailout.
2280
workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
2281
return null;
packages/react-reconciler/src/ReactFiberCompleteWork.js
+5
@@ -97,10 +97,12 @@ import {
97
popHydrationState,
98
} from './ReactFiberHydrationContext';
99
import {
100
+ enableSchedulerTracing,
101
enableSuspenseServerRenderer,
102
enableEventAPI,
103
} from 'shared/ReactFeatureFlags';
104
import {
105
+ markDidDeprioritizeIdleSubtree,
106
renderDidSuspend,
107
renderDidSuspendDelayIfPossible,
108
} from './ReactFiberWorkLoop';
@@ -815,6 +817,9 @@ function completeWork(
817
'A dehydrated suspense component was completed without a hydrated node. ' +
818
'This is probably a bug in React.',
819
);
820
+ if (enableSchedulerTracing) {
821
+ markDidDeprioritizeIdleSubtree();
822
+ }
823
skipPastDehydratedSuspenseInstance(workInProgress);
824
} else if ((workInProgress.effectTag & DidCapture) === NoEffect) {
825
// This boundary did not suspend so it's now hydrated.
packages/react-reconciler/src/ReactFiberWorkLoop.js
+52
-17
@@ -246,6 +246,10 @@ let nestedPassiveUpdateCount: number = 0;
246
247
let interruptedBy: Fiber | null = null;
248
249
+// Marks the need to reschedule pending interactions at Never priority during the commit phase.
250
+// This enables them to be traced accross hidden boundaries or suspended SSR hydration.
251
+let didDeprioritizeIdleSubtree: boolean = false;
252
+
253
// Expiration times are computed by adding to the current time (the start
254
// time). However, if two updates are scheduled within the same event, we
255
// should treat their start times as simultaneous, even if the actual clock
@@ -378,7 +382,7 @@ export function scheduleUpdateOnFiber(
382
(executionContext & (RenderContext | CommitContext)) === NoContext
383
) {
384
// Register pending interactions on the root to avoid losing traced interaction data.
381
- schedulePendingInteraction(root, expirationTime);
385
+ schedulePendingInteractions(root, expirationTime);
386
387
// This is a legacy edge case. The initial mount of a ReactDOM.render-ed
388
// root inside of batchedUpdates should be synchronous, but layout updates
@@ -541,9 +545,8 @@ function scheduleCallbackForRoot(
545
}
546
}
547
544
- // Add the current set of interactions to the pending set associated with
545
- // this root.
546
- schedulePendingInteraction(root, expirationTime);
548
+ // Associate the current interactions with this new root+priority.
549
+ schedulePendingInteractions(root, expirationTime);
550
}
551
552
function runRootCallback(root, callback, isSync) {
@@ -781,6 +784,10 @@ function prepareFreshStack(root, expirationTime) {
784
workInProgressRootCanSuspendUsingConfig = null;
785
workInProgressRootHasPendingPing = false;
786
787
+ if (enableSchedulerTracing) {
788
+ didDeprioritizeIdleSubtree = false;
789
+ }
790
+
791
if (__DEV__) {
792
ReactStrictModeWarnings.discardPendingWarnings();
793
componentsWithSuspendedDiscreteUpdates = null;
@@ -822,7 +829,7 @@ function renderRoot(
829
// and prepare a fresh one. Otherwise we'll continue where we left off.
830
if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
831
prepareFreshStack(root, expirationTime);
825
- startWorkOnPendingInteraction(root, expirationTime);
832
+ startWorkOnPendingInteractions(root, expirationTime);
833
} else if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
834
// We could've received an update at a lower priority while we yielded.
835
// We're suspended in a delayed state. Once we complete this render we're
@@ -1680,19 +1687,14 @@ function commitRootImpl(root) {
1687
1688
stopCommitTimer();
1689
1690
+ const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
1691
+
1692
if (rootDoesHavePassiveEffects) {
1693
// This commit has passive effects. Stash a reference to them. But don't
1694
// schedule a callback until after flushing layout work.
1695
rootDoesHavePassiveEffects = false;
1696
rootWithPendingPassiveEffects = root;
1697
pendingPassiveEffectsExpirationTime = expirationTime;
1689
- } else {
1690
- if (enableSchedulerTracing) {
1691
- // If there are no passive effects, then we can complete the pending
1692
- // interactions. Otherwise, we'll wait until after the passive effects
1693
- // are flushed.
1694
- finishPendingInteractions(root, expirationTime);
1695
- }
1698
}
1699
1700
// Check if there's remaining work on this root
@@ -1703,6 +1705,14 @@ function commitRootImpl(root) {
1705
currentTime,
1706
remainingExpirationTime,
1707
);
1708
+
1709
+ if (enableSchedulerTracing) {
1710
+ if (didDeprioritizeIdleSubtree) {
1711
+ didDeprioritizeIdleSubtree = false;
1712
+ scheduleInteractions(root, Never, root.memoizedInteractions);
1713
+ }
1714
+ }
1715
+
1716
scheduleCallbackForRoot(root, priorityLevel, remainingExpirationTime);
1717
} else {
1718
// If there's no remaining work, we can clear the set of already failed
@@ -1710,6 +1720,16 @@ function commitRootImpl(root) {
1720
legacyErrorBoundariesThatAlreadyFailed = null;
1721
}
1722
1723
+ if (enableSchedulerTracing) {
1724
+ if (!rootDidHavePassiveEffects) {
1725
+ // If there are no passive effects, then we can complete the pending interactions.
1726
+ // Otherwise, we'll wait until after the passive effects are flushed.
1727
+ // Wait to do this until after remaining work has been scheduled,
1728
+ // so that we don't prematurely signal complete for interactions when there's e.g. hidden work.
1729
+ finishPendingInteractions(root, expirationTime);
1730
+ }
1731
+ }
1732
+
1733
onCommitRoot(finishedWork.stateNode, expirationTime);
1734
1735
if (remainingExpirationTime === Sync) {
@@ -2512,14 +2532,18 @@ function computeThreadID(root, expirationTime) {
2532
return expirationTime * 1000 + root.interactionThreadID;
2533
}
2534
2515
-function schedulePendingInteraction(root, expirationTime) {
2516
- // This is called when work is scheduled on a root. It sets up a pending
2517
- // interaction, which is completed once the work commits.
2535
+export function markDidDeprioritizeIdleSubtree() {
2536
+ if (!enableSchedulerTracing) {
2537
+ return;
2538
+ }
2539
+ didDeprioritizeIdleSubtree = true;
2540
+}
2541
+
2542
+function scheduleInteractions(root, expirationTime, interactions) {
2543
if (!enableSchedulerTracing) {
2544
return;
2545
}
2546
2522
- const interactions = __interactionsRef.current;
2547
if (interactions.size > 0) {
2548
const pendingInteractionMap = root.pendingInteractionMap;
2549
const pendingInteractions = pendingInteractionMap.get(expirationTime);
@@ -2549,7 +2573,18 @@ function schedulePendingInteraction(root, expirationTime) {
2573
}
2574
}
2575
2552
-function startWorkOnPendingInteraction(root, expirationTime) {
2576
+function schedulePendingInteractions(root, expirationTime) {
2577
+ // This is called when work is scheduled on a root.
2578
+ // It associates the current interactions with the newly-scheduled expiration.
2579
+ // They will be restored when that expiration is later committed.
2580
+ if (!enableSchedulerTracing) {
2581
+ return;
2582
+ }
2583
+
2584
+ scheduleInteractions(root, expirationTime, __interactionsRef.current);
2585
+}
2586
+
2587
+function startWorkOnPendingInteractions(root, expirationTime) {
2588
// This is called when new work is started on a root.
2589
if (!enableSchedulerTracing) {
2590
return;
packages/react/src/__tests__/ReactDOMTracing-test.internal.js
new
+633
@@ -0,0 +1,633 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @emails react-core
8
+ */
9
+
10
+'use strict';
11
+
12
+let React;
13
+let ReactDOM;
14
+let ReactDOMServer;
15
+let ReactFeatureFlags;
16
+let Scheduler;
17
+let SchedulerTracing;
18
+let TestUtils;
19
+let onInteractionScheduledWorkCompleted;
20
+let onInteractionTraced;
21
+let onWorkCanceled;
22
+let onWorkScheduled;
23
+let onWorkStarted;
24
+let onWorkStopped;
25
+
26
+function loadModules() {
27
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
28
+ ReactFeatureFlags.debugRenderPhaseSideEffects = false;
29
+ ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
30
+ ReactFeatureFlags.enableSuspenseServerRenderer = true;
31
+ ReactFeatureFlags.enableProfilerTimer = true;
32
+ ReactFeatureFlags.enableSchedulerTracing = true;
33
+ ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
34
+
35
+ React = require('react');
36
+ ReactDOM = require('react-dom');
37
+ ReactDOMServer = require('react-dom/server');
38
+ Scheduler = require('scheduler');
39
+ SchedulerTracing = require('scheduler/tracing');
40
+ TestUtils = require('react-dom/test-utils');
41
+
42
+ onInteractionScheduledWorkCompleted = jest.fn();
43
+ onInteractionTraced = jest.fn();
44
+ onWorkCanceled = jest.fn();
45
+ onWorkScheduled = jest.fn();
46
+ onWorkStarted = jest.fn();
47
+ onWorkStopped = jest.fn();
48
+
49
+ // Verify interaction subscriber methods are called as expected.
50
+ SchedulerTracing.unstable_subscribe({
51
+ onInteractionScheduledWorkCompleted,
52
+ onInteractionTraced,
53
+ onWorkCanceled,
54
+ onWorkScheduled,
55
+ onWorkStarted,
56
+ onWorkStopped,
57
+ });
58
+}
59
+
60
+describe('ReactDOMTracing', () => {
61
+ beforeEach(() => {
62
+ jest.resetModules();
63
+
64
+ loadModules();
65
+ });
66
+
67
+ describe('interaction tracing', () => {
68
+ describe('hidden', () => {
69
+ it('traces interaction through hidden subtree', () => {
70
+ const Child = () => {
71
+ const [didMount, setDidMount] = React.useState(false);
72
+ Scheduler.yieldValue('Child');
73
+ React.useEffect(
74
+ () => {
75
+ if (didMount) {
76
+ Scheduler.yieldValue('Child:update');
77
+ } else {
78
+ Scheduler.yieldValue('Child:mount');
79
+ setDidMount(true);
80
+ }
81
+ },
82
+ [didMount],
83
+ );
84
+ return <div />;
85
+ };
86
+
87
+ const App = () => {
88
+ Scheduler.yieldValue('App');
89
+ React.useEffect(() => {
90
+ Scheduler.yieldValue('App:mount');
91
+ }, []);
92
+ return (
93
+ <div hidden={true}>
94
+ <Child />
95
+ </div>
96
+ );
97
+ };
98
+
99
+ let interaction;
100
+
101
+ const onRender = jest.fn();
102
+
103
+ const container = document.createElement('div');
104
+ const root = ReactDOM.unstable_createRoot(container);
105
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
106
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
107
+
108
+ root.render(
109
+ <React.Profiler id="test" onRender={onRender}>
110
+ <App />
111
+ </React.Profiler>,
112
+ );
113
+ });
114
+
115
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
116
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
117
+ interaction,
118
+ );
119
+
120
+ expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
121
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
122
+ expect(onRender).toHaveBeenCalledTimes(1);
123
+ expect(onRender).toHaveLastRenderedWithInteractions(
124
+ new Set([interaction]),
125
+ );
126
+
127
+ expect(Scheduler).toFlushAndYieldThrough(['Child', 'Child:mount']);
128
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
129
+ expect(onRender).toHaveBeenCalledTimes(2);
130
+ expect(onRender).toHaveLastRenderedWithInteractions(
131
+ new Set([interaction]),
132
+ );
133
+
134
+ expect(Scheduler).toFlushAndYield(['Child', 'Child:update']);
135
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
136
+ expect(
137
+ onInteractionScheduledWorkCompleted,
138
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
139
+ expect(onRender).toHaveBeenCalledTimes(3);
140
+ expect(onRender).toHaveLastRenderedWithInteractions(
141
+ new Set([interaction]),
142
+ );
143
+ });
144
+
145
+ it('traces interaction through hidden subtree when there is other pending traced work', () => {
146
+ const Child = () => {
147
+ Scheduler.yieldValue('Child');
148
+ return <div />;
149
+ };
150
+
151
+ let wrapped = null;
152
+
153
+ const App = () => {
154
+ Scheduler.yieldValue('App');
155
+ React.useEffect(() => {
156
+ wrapped = SchedulerTracing.unstable_wrap(() => {});
157
+ Scheduler.yieldValue('App:mount');
158
+ }, []);
159
+ return (
160
+ <div hidden={true}>
161
+ <Child />
162
+ </div>
163
+ );
164
+ };
165
+
166
+ let interaction;
167
+
168
+ const onRender = jest.fn();
169
+
170
+ const container = document.createElement('div');
171
+ const root = ReactDOM.unstable_createRoot(container);
172
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
173
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
174
+
175
+ root.render(
176
+ <React.Profiler id="test" onRender={onRender}>
177
+ <App />
178
+ </React.Profiler>,
179
+ );
180
+ });
181
+
182
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
183
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
184
+ interaction,
185
+ );
186
+
187
+ expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
188
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
189
+ expect(onRender).toHaveBeenCalledTimes(1);
190
+ expect(onRender).toHaveLastRenderedWithInteractions(
191
+ new Set([interaction]),
192
+ );
193
+
194
+ expect(wrapped).not.toBeNull();
195
+
196
+ expect(Scheduler).toFlushAndYield(['Child']);
197
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
198
+ expect(onRender).toHaveBeenCalledTimes(2);
199
+ expect(onRender).toHaveLastRenderedWithInteractions(
200
+ new Set([interaction]),
201
+ );
202
+
203
+ wrapped();
204
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
205
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
206
+ expect(
207
+ onInteractionScheduledWorkCompleted,
208
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
209
+ });
210
+
211
+ it('traces interaction through hidden subtree that schedules more idle/never work', () => {
212
+ const Child = () => {
213
+ const [didMount, setDidMount] = React.useState(false);
214
+ Scheduler.yieldValue('Child');
215
+ React.useLayoutEffect(
216
+ () => {
217
+ if (didMount) {
218
+ Scheduler.yieldValue('Child:update');
219
+ } else {
220
+ Scheduler.yieldValue('Child:mount');
221
+ Scheduler.unstable_runWithPriority(
222
+ Scheduler.unstable_IdlePriority,
223
+ () => setDidMount(true),
224
+ );
225
+ }
226
+ },
227
+ [didMount],
228
+ );
229
+ return <div />;
230
+ };
231
+
232
+ const App = () => {
233
+ Scheduler.yieldValue('App');
234
+ React.useEffect(() => {
235
+ Scheduler.yieldValue('App:mount');
236
+ }, []);
237
+ return (
238
+ <div hidden={true}>
239
+ <Child />
240
+ </div>
241
+ );
242
+ };
243
+
244
+ let interaction;
245
+
246
+ const onRender = jest.fn();
247
+
248
+ const container = document.createElement('div');
249
+ const root = ReactDOM.unstable_createRoot(container);
250
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
251
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
252
+
253
+ root.render(
254
+ <React.Profiler id="test" onRender={onRender}>
255
+ <App />
256
+ </React.Profiler>,
257
+ );
258
+ });
259
+
260
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
261
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
262
+ interaction,
263
+ );
264
+
265
+ expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
266
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
267
+ expect(onRender).toHaveBeenCalledTimes(1);
268
+ expect(onRender).toHaveLastRenderedWithInteractions(
269
+ new Set([interaction]),
270
+ );
271
+
272
+ expect(Scheduler).toFlushAndYieldThrough(['Child', 'Child:mount']);
273
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
274
+ expect(onRender).toHaveBeenCalledTimes(2);
275
+ expect(onRender).toHaveLastRenderedWithInteractions(
276
+ new Set([interaction]),
277
+ );
278
+
279
+ expect(Scheduler).toFlushAndYield(['Child', 'Child:update']);
280
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
281
+ expect(
282
+ onInteractionScheduledWorkCompleted,
283
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
284
+ expect(onRender).toHaveBeenCalledTimes(3);
285
+ expect(onRender).toHaveLastRenderedWithInteractions(
286
+ new Set([interaction]),
287
+ );
288
+ });
289
+
290
+ it('does not continue interactions across pre-existing idle work', () => {
291
+ const Child = () => {
292
+ Scheduler.yieldValue('Child');
293
+ return <div />;
294
+ };
295
+
296
+ let update = null;
297
+
298
+ const WithHiddenWork = () => {
299
+ Scheduler.yieldValue('WithHiddenWork');
300
+ return (
301
+ <div hidden={true}>
302
+ <Child />
303
+ </div>
304
+ );
305
+ };
306
+
307
+ const Updater = () => {
308
+ Scheduler.yieldValue('Updater');
309
+ React.useEffect(() => {
310
+ Scheduler.yieldValue('Updater:effect');
311
+ });
312
+
313
+ const setCount = React.useState(0)[1];
314
+ update = () => {
315
+ setCount(current => current + 1);
316
+ };
317
+
318
+ return <div />;
319
+ };
320
+
321
+ const App = () => {
322
+ Scheduler.yieldValue('App');
323
+ React.useEffect(() => {
324
+ Scheduler.yieldValue('App:effect');
325
+ });
326
+
327
+ return (
328
+ <React.Fragment>
329
+ <WithHiddenWork />
330
+ <Updater />
331
+ </React.Fragment>
332
+ );
333
+ };
334
+
335
+ const onRender = jest.fn();
336
+ const container = document.createElement('div');
337
+ const root = ReactDOM.unstable_createRoot(container);
338
+
339
+ // Schedule some idle work without any interactions.
340
+ TestUtils.act(() => {
341
+ root.render(
342
+ <React.Profiler id="test" onRender={onRender}>
343
+ <App />
344
+ </React.Profiler>,
345
+ );
346
+ expect(Scheduler).toFlushAndYieldThrough([
347
+ 'App',
348
+ 'WithHiddenWork',
349
+ 'Updater',
350
+ 'Updater:effect',
351
+ 'App:effect',
352
+ ]);
353
+ expect(update).not.toBeNull();
354
+
355
+ // Trace a higher-priority update.
356
+ let interaction = null;
357
+ SchedulerTracing.unstable_trace('update', 0, () => {
358
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
359
+ update();
360
+ });
361
+ expect(interaction).not.toBeNull();
362
+ expect(onRender).toHaveBeenCalledTimes(1);
363
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
364
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
365
+ interaction,
366
+ );
367
+
368
+ // Ensure the traced interaction completes without being attributed to the pre-existing idle work.
369
+ expect(Scheduler).toFlushAndYieldThrough([
370
+ 'Updater',
371
+ 'Updater:effect',
372
+ ]);
373
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
374
+ expect(
375
+ onInteractionScheduledWorkCompleted,
376
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
377
+ expect(onRender).toHaveBeenCalledTimes(2);
378
+ expect(onRender).toHaveLastRenderedWithInteractions(
379
+ new Set([interaction]),
380
+ );
381
+
382
+ // Complete low-priority work and ensure no lingering interaction.
383
+ expect(Scheduler).toFlushAndYield(['Child']);
384
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
385
+ expect(onRender).toHaveBeenCalledTimes(3);
386
+ expect(onRender).toHaveLastRenderedWithInteractions(new Set([]));
387
+ });
388
+ });
389
+
390
+ it('should properly trace interactions when there is work of interleaved priorities', () => {
391
+ const Child = () => {
392
+ Scheduler.yieldValue('Child');
393
+ return <div />;
394
+ };
395
+
396
+ let scheduleUpdate = null;
397
+ let scheduleUpdateWithHidden = null;
398
+
399
+ const MaybeHiddenWork = () => {
400
+ const [flag, setFlag] = React.useState(false);
401
+ scheduleUpdateWithHidden = () => setFlag(true);
402
+ Scheduler.yieldValue('MaybeHiddenWork');
403
+ React.useEffect(() => {
404
+ Scheduler.yieldValue('MaybeHiddenWork:effect');
405
+ });
406
+ return flag ? (
407
+ <div hidden={true}>
408
+ <Child />
409
+ </div>
410
+ ) : null;
411
+ };
412
+
413
+ const Updater = () => {
414
+ Scheduler.yieldValue('Updater');
415
+ React.useEffect(() => {
416
+ Scheduler.yieldValue('Updater:effect');
417
+ });
418
+
419
+ const setCount = React.useState(0)[1];
420
+ scheduleUpdate = () => setCount(current => current + 1);
421
+
422
+ return <div />;
423
+ };
424
+
425
+ const App = () => {
426
+ Scheduler.yieldValue('App');
427
+ React.useEffect(() => {
428
+ Scheduler.yieldValue('App:effect');
429
+ });
430
+
431
+ return (
432
+ <React.Fragment>
433
+ <MaybeHiddenWork />
434
+ <Updater />
435
+ </React.Fragment>
436
+ );
437
+ };
438
+
439
+ const onRender = jest.fn();
440
+ const container = document.createElement('div');
441
+ const root = ReactDOM.unstable_createRoot(container);
442
+
443
+ TestUtils.act(() => {
444
+ root.render(
445
+ <React.Profiler id="test" onRender={onRender}>
446
+ <App />
447
+ </React.Profiler>,
448
+ );
449
+ expect(Scheduler).toFlushAndYield([
450
+ 'App',
451
+ 'MaybeHiddenWork',
452
+ 'Updater',
453
+ 'MaybeHiddenWork:effect',
454
+ 'Updater:effect',
455
+ 'App:effect',
456
+ ]);
457
+ expect(scheduleUpdate).not.toBeNull();
458
+ expect(scheduleUpdateWithHidden).not.toBeNull();
459
+ expect(onRender).toHaveBeenCalledTimes(1);
460
+
461
+ // schedule traced high-pri update and a (non-traced) low-pri update.
462
+ let interaction = null;
463
+ SchedulerTracing.unstable_trace('update', 0, () => {
464
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
465
+ Scheduler.unstable_runWithPriority(
466
+ Scheduler.unstable_UserBlockingPriority,
467
+ () => scheduleUpdateWithHidden(),
468
+ );
469
+ });
470
+ scheduleUpdate();
471
+ expect(interaction).not.toBeNull();
472
+ expect(onRender).toHaveBeenCalledTimes(1);
473
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
474
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
475
+ interaction,
476
+ );
477
+
478
+ // high-pri update should leave behind idle work and should not complete the interaction
479
+ expect(Scheduler).toFlushAndYieldThrough([
480
+ 'MaybeHiddenWork',
481
+ 'MaybeHiddenWork:effect',
482
+ ]);
483
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
484
+ expect(onRender).toHaveBeenCalledTimes(2);
485
+ expect(onRender).toHaveLastRenderedWithInteractions(
486
+ new Set([interaction]),
487
+ );
488
+
489
+ // low-pri update should not have the interaction
490
+ expect(Scheduler).toFlushAndYieldThrough([
491
+ 'Updater',
492
+ 'Updater:effect',
493
+ ]);
494
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
495
+ expect(onRender).toHaveBeenCalledTimes(3);
496
+ expect(onRender).toHaveLastRenderedWithInteractions(new Set([]));
497
+
498
+ // idle work should complete the interaction
499
+ expect(Scheduler).toFlushAndYield(['Child']);
500
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
501
+ expect(
502
+ onInteractionScheduledWorkCompleted,
503
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
504
+ expect(onRender).toHaveBeenCalledTimes(4);
505
+ expect(onRender).toHaveLastRenderedWithInteractions(
506
+ new Set([interaction]),
507
+ );
508
+ });
509
+ });
510
+ });
511
+
512
+ describe('hydration', () => {
513
+ it('traces interaction across hydration', async done => {
514
+ let ref = React.createRef();
515
+
516
+ function Child() {
517
+ return 'Hello';
518
+ }
519
+
520
+ function App() {
521
+ return (
522
+ <div>
523
+ <span ref={ref}>
524
+ <Child />
525
+ </span>
526
+ </div>
527
+ );
528
+ }
529
+
530
+ // Render the final HTML.
531
+ const finalHTML = ReactDOMServer.renderToString(<App />);
532
+
533
+ const container = document.createElement('div');
534
+ container.innerHTML = finalHTML;
535
+
536
+ let interaction;
537
+
538
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
539
+
540
+ // Hydrate it.
541
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
542
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
543
+
544
+ root.render(<App />);
545
+ });
546
+ Scheduler.flushAll();
547
+ jest.runAllTimers();
548
+
549
+ expect(ref.current).not.toBe(null);
550
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
551
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
552
+ interaction,
553
+ );
554
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
555
+ expect(
556
+ onInteractionScheduledWorkCompleted,
557
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
558
+
559
+ done();
560
+ });
561
+
562
+ it('traces interaction across suspended hydration', async done => {
563
+ let suspend = false;
564
+ let resolve;
565
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
566
+ let ref = React.createRef();
567
+
568
+ function Child() {
569
+ if (suspend) {
570
+ throw promise;
571
+ } else {
572
+ return 'Hello';
573
+ }
574
+ }
575
+
576
+ function App() {
577
+ return (
578
+ <div>
579
+ <React.Suspense fallback="Loading...">
580
+ <span ref={ref}>
581
+ <Child />
582
+ </span>
583
+ </React.Suspense>
584
+ </div>
585
+ );
586
+ }
587
+
588
+ // Render the final HTML.
589
+ // Don't suspend on the server.
590
+ const finalHTML = ReactDOMServer.renderToString(<App />);
591
+
592
+ const container = document.createElement('div');
593
+ container.innerHTML = finalHTML;
594
+
595
+ let interaction;
596
+
597
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
598
+
599
+ // Start hydrating but simulate blocking for suspense data.
600
+ suspend = true;
601
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
602
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
603
+
604
+ root.render(<App />);
605
+ });
606
+ Scheduler.flushAll();
607
+ jest.runAllTimers();
608
+
609
+ expect(ref.current).toBe(null);
610
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
611
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
612
+ interaction,
613
+ );
614
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
615
+
616
+ // Resolving the promise should continue hydration
617
+ suspend = false;
618
+ resolve();
619
+ await promise;
620
+ Scheduler.flushAll();
621
+ jest.runAllTimers();
622
+
623
+ expect(ref.current).not.toBe(null);
624
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
625
+ expect(
626
+ onInteractionScheduledWorkCompleted,
627
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
628
+
629
+ done();
630
+ });
631
+ });
632
+ });
633
+});
scripts/jest/matchers/interactionTracingMatchers.js
renamed
scripts/jest/matchers/profilerMatchers.js
new
+74
@@ -0,0 +1,74 @@
1
+'use strict';
2
+
3
+const jestDiff = require('jest-diff');
4
+
5
+function toHaveLastRenderedWithNoInteractions(onRenderMockFn) {
6
+ const calls = onRenderMockFn.mock.calls;
7
+ if (calls.length === 0) {
8
+ return {
9
+ message: () => 'Mock onRender function was not called',
10
+ pass: false,
11
+ };
12
+ }
13
+}
14
+
15
+function toHaveLastRenderedWithInteractions(
16
+ onRenderMockFn,
17
+ expectedInteractions
18
+) {
19
+ const calls = onRenderMockFn.mock.calls;
20
+ if (calls.length === 0) {
21
+ return {
22
+ message: () => 'Mock onRender function was not called',
23
+ pass: false,
24
+ };
25
+ }
26
+
27
+ const lastCall = calls[calls.length - 1];
28
+ const actualInteractions = lastCall[6];
29
+
30
+ return toMatchInteractions(actualInteractions, expectedInteractions);
31
+}
32
+
33
+function toMatchInteraction(actual, expected) {
34
+ let attribute;
35
+ for (attribute in expected) {
36
+ if (actual[attribute] !== expected[attribute]) {
37
+ return {
38
+ message: () => jestDiff(expected, actual),
39
+ pass: false,
40
+ };
41
+ }
42
+ }
43
+
44
+ return {pass: true};
45
+}
46
+
47
+function toMatchInteractions(actualSetOrArray, expectedSetOrArray) {
48
+ const actualArray = Array.from(actualSetOrArray);
49
+ const expectedArray = Array.from(expectedSetOrArray);
50
+
51
+ if (actualArray.length !== expectedArray.length) {
52
+ return {
53
+ message: () =>
54
+ `Expected ${expectedArray.length} interactions but there were ${
55
+ actualArray.length
56
+ }`,
57
+ pass: false,
58
+ };
59
+ }
60
+
61
+ for (let i = 0; i < actualArray.length; i++) {
62
+ const result = toMatchInteraction(actualArray[i], expectedArray[i]);
63
+ if (result.pass === false) {
64
+ return result;
65
+ }
66
+ }
67
+
68
+ return {pass: true};
69
+}
70
+
71
+module.exports = {
72
+ toHaveLastRenderedWithInteractions,
73
+ toHaveLastRenderedWithNoInteractions,
74
+};
scripts/jest/setupTests.js
+2
-1
@@ -44,7 +44,8 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
44
}
45
46
expect.extend({
47
- ...require('./matchers/interactionTracing'),
47
+ ...require('./matchers/interactionTracingMatchers'),
48
+ ...require('./matchers/profilerMatchers'),
49
...require('./matchers/toWarnDev'),
50
...require('./matchers/reactTestMatchers'),
51
});
scripts/jest/spec-equivalence-reporter/setupTests.js
+2
-1
@@ -46,7 +46,8 @@ global.spyOnProd = function(...args) {
46
};
47
48
expect.extend({
49
- ...require('../matchers/interactionTracing'),
49
+ ...require('../matchers/interactionTracingMatchers'),
50
+ ...require('../matchers/profilerMatchers'),
51
...require('../matchers/toWarnDev'),
52
...require('../matchers/reactTestMatchers'),
53
});