@samitouri / QOS-React-2 / commits / 801feed95c

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 });