diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index 600389dc32..7ef14cab64 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -54,6 +54,7 @@ import {
debugRenderPhaseSideEffects,
debugRenderPhaseSideEffectsForStrictMode,
enableProfilerTimer,
+ enableSchedulerTracing,
enableSuspenseServerRenderer,
enableEventAPI,
} from 'shared/ReactFeatureFlags';
@@ -164,7 +165,11 @@ import {
createWorkInProgress,
isSimpleFunctionComponent,
} from './ReactFiber';
-import {requestCurrentTime, retryTimedOutBoundary} from './ReactFiberWorkLoop';
+import {
+ markDidDeprioritizeIdleSubtree,
+ requestCurrentTime,
+ retryTimedOutBoundary,
+} from './ReactFiberWorkLoop';
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -988,6 +993,9 @@ function updateHostComponent(current, workInProgress, renderExpirationTime) {
renderExpirationTime !== Never &&
shouldDeprioritizeSubtree(type, nextProps)
) {
+ if (enableSchedulerTracing) {
+ markDidDeprioritizeIdleSubtree();
+ }
// Schedule this fiber to re-render at offscreen priority. Then bailout.
workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
return null;
@@ -2265,6 +2273,9 @@ function beginWork(
renderExpirationTime !== Never &&
shouldDeprioritizeSubtree(workInProgress.type, newProps)
) {
+ if (enableSchedulerTracing) {
+ markDidDeprioritizeIdleSubtree();
+ }
// Schedule this fiber to re-render at offscreen priority. Then bailout.
workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
return null;
diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js
index 0b8ad6e63a..d8fe61acf4 100644
--- a/packages/react-reconciler/src/ReactFiberCompleteWork.js
+++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js
@@ -97,10 +97,12 @@ import {
popHydrationState,
} from './ReactFiberHydrationContext';
import {
+ enableSchedulerTracing,
enableSuspenseServerRenderer,
enableEventAPI,
} from 'shared/ReactFeatureFlags';
import {
+ markDidDeprioritizeIdleSubtree,
renderDidSuspend,
renderDidSuspendDelayIfPossible,
} from './ReactFiberWorkLoop';
@@ -815,6 +817,9 @@ function completeWork(
'A dehydrated suspense component was completed without a hydrated node. ' +
'This is probably a bug in React.',
);
+ if (enableSchedulerTracing) {
+ markDidDeprioritizeIdleSubtree();
+ }
skipPastDehydratedSuspenseInstance(workInProgress);
} else if ((workInProgress.effectTag & DidCapture) === NoEffect) {
// This boundary did not suspend so it's now hydrated.
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index dd76b3aed9..aa865be438 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -246,6 +246,10 @@ let nestedPassiveUpdateCount: number = 0;
let interruptedBy: Fiber | null = null;
+// Marks the need to reschedule pending interactions at Never priority during the commit phase.
+// This enables them to be traced accross hidden boundaries or suspended SSR hydration.
+let didDeprioritizeIdleSubtree: boolean = false;
+
// Expiration times are computed by adding to the current time (the start
// time). However, if two updates are scheduled within the same event, we
// should treat their start times as simultaneous, even if the actual clock
@@ -378,7 +382,7 @@ export function scheduleUpdateOnFiber(
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
// Register pending interactions on the root to avoid losing traced interaction data.
- schedulePendingInteraction(root, expirationTime);
+ schedulePendingInteractions(root, expirationTime);
// This is a legacy edge case. The initial mount of a ReactDOM.render-ed
// root inside of batchedUpdates should be synchronous, but layout updates
@@ -541,9 +545,8 @@ function scheduleCallbackForRoot(
}
}
- // Add the current set of interactions to the pending set associated with
- // this root.
- schedulePendingInteraction(root, expirationTime);
+ // Associate the current interactions with this new root+priority.
+ schedulePendingInteractions(root, expirationTime);
}
function runRootCallback(root, callback, isSync) {
@@ -781,6 +784,10 @@ function prepareFreshStack(root, expirationTime) {
workInProgressRootCanSuspendUsingConfig = null;
workInProgressRootHasPendingPing = false;
+ if (enableSchedulerTracing) {
+ didDeprioritizeIdleSubtree = false;
+ }
+
if (__DEV__) {
ReactStrictModeWarnings.discardPendingWarnings();
componentsWithSuspendedDiscreteUpdates = null;
@@ -822,7 +829,7 @@ function renderRoot(
// and prepare a fresh one. Otherwise we'll continue where we left off.
if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
prepareFreshStack(root, expirationTime);
- startWorkOnPendingInteraction(root, expirationTime);
+ startWorkOnPendingInteractions(root, expirationTime);
} else if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
// We could've received an update at a lower priority while we yielded.
// We're suspended in a delayed state. Once we complete this render we're
@@ -1680,19 +1687,14 @@ function commitRootImpl(root) {
stopCommitTimer();
+ const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
+
if (rootDoesHavePassiveEffects) {
// This commit has passive effects. Stash a reference to them. But don't
// schedule a callback until after flushing layout work.
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsExpirationTime = expirationTime;
- } else {
- if (enableSchedulerTracing) {
- // If there are no passive effects, then we can complete the pending
- // interactions. Otherwise, we'll wait until after the passive effects
- // are flushed.
- finishPendingInteractions(root, expirationTime);
- }
}
// Check if there's remaining work on this root
@@ -1703,6 +1705,14 @@ function commitRootImpl(root) {
currentTime,
remainingExpirationTime,
);
+
+ if (enableSchedulerTracing) {
+ if (didDeprioritizeIdleSubtree) {
+ didDeprioritizeIdleSubtree = false;
+ scheduleInteractions(root, Never, root.memoizedInteractions);
+ }
+ }
+
scheduleCallbackForRoot(root, priorityLevel, remainingExpirationTime);
} else {
// If there's no remaining work, we can clear the set of already failed
@@ -1710,6 +1720,16 @@ function commitRootImpl(root) {
legacyErrorBoundariesThatAlreadyFailed = null;
}
+ if (enableSchedulerTracing) {
+ if (!rootDidHavePassiveEffects) {
+ // If there are no passive effects, then we can complete the pending interactions.
+ // Otherwise, we'll wait until after the passive effects are flushed.
+ // Wait to do this until after remaining work has been scheduled,
+ // so that we don't prematurely signal complete for interactions when there's e.g. hidden work.
+ finishPendingInteractions(root, expirationTime);
+ }
+ }
+
onCommitRoot(finishedWork.stateNode, expirationTime);
if (remainingExpirationTime === Sync) {
@@ -2512,14 +2532,18 @@ function computeThreadID(root, expirationTime) {
return expirationTime * 1000 + root.interactionThreadID;
}
-function schedulePendingInteraction(root, expirationTime) {
- // This is called when work is scheduled on a root. It sets up a pending
- // interaction, which is completed once the work commits.
+export function markDidDeprioritizeIdleSubtree() {
+ if (!enableSchedulerTracing) {
+ return;
+ }
+ didDeprioritizeIdleSubtree = true;
+}
+
+function scheduleInteractions(root, expirationTime, interactions) {
if (!enableSchedulerTracing) {
return;
}
- const interactions = __interactionsRef.current;
if (interactions.size > 0) {
const pendingInteractionMap = root.pendingInteractionMap;
const pendingInteractions = pendingInteractionMap.get(expirationTime);
@@ -2549,7 +2573,18 @@ function schedulePendingInteraction(root, expirationTime) {
}
}
-function startWorkOnPendingInteraction(root, expirationTime) {
+function schedulePendingInteractions(root, expirationTime) {
+ // This is called when work is scheduled on a root.
+ // It associates the current interactions with the newly-scheduled expiration.
+ // They will be restored when that expiration is later committed.
+ if (!enableSchedulerTracing) {
+ return;
+ }
+
+ scheduleInteractions(root, expirationTime, __interactionsRef.current);
+}
+
+function startWorkOnPendingInteractions(root, expirationTime) {
// This is called when new work is started on a root.
if (!enableSchedulerTracing) {
return;
diff --git a/packages/react/src/__tests__/ReactDOMTracing-test.internal.js b/packages/react/src/__tests__/ReactDOMTracing-test.internal.js
new file mode 100644
index 0000000000..10442f72ae
--- /dev/null
+++ b/packages/react/src/__tests__/ReactDOMTracing-test.internal.js
@@ -0,0 +1,633 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+'use strict';
+
+let React;
+let ReactDOM;
+let ReactDOMServer;
+let ReactFeatureFlags;
+let Scheduler;
+let SchedulerTracing;
+let TestUtils;
+let onInteractionScheduledWorkCompleted;
+let onInteractionTraced;
+let onWorkCanceled;
+let onWorkScheduled;
+let onWorkStarted;
+let onWorkStopped;
+
+function loadModules() {
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
+ ReactFeatureFlags.debugRenderPhaseSideEffects = false;
+ ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
+ ReactFeatureFlags.enableSuspenseServerRenderer = true;
+ ReactFeatureFlags.enableProfilerTimer = true;
+ ReactFeatureFlags.enableSchedulerTracing = true;
+ ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
+
+ React = require('react');
+ ReactDOM = require('react-dom');
+ ReactDOMServer = require('react-dom/server');
+ Scheduler = require('scheduler');
+ SchedulerTracing = require('scheduler/tracing');
+ TestUtils = require('react-dom/test-utils');
+
+ onInteractionScheduledWorkCompleted = jest.fn();
+ onInteractionTraced = jest.fn();
+ onWorkCanceled = jest.fn();
+ onWorkScheduled = jest.fn();
+ onWorkStarted = jest.fn();
+ onWorkStopped = jest.fn();
+
+ // Verify interaction subscriber methods are called as expected.
+ SchedulerTracing.unstable_subscribe({
+ onInteractionScheduledWorkCompleted,
+ onInteractionTraced,
+ onWorkCanceled,
+ onWorkScheduled,
+ onWorkStarted,
+ onWorkStopped,
+ });
+}
+
+describe('ReactDOMTracing', () => {
+ beforeEach(() => {
+ jest.resetModules();
+
+ loadModules();
+ });
+
+ describe('interaction tracing', () => {
+ describe('hidden', () => {
+ it('traces interaction through hidden subtree', () => {
+ const Child = () => {
+ const [didMount, setDidMount] = React.useState(false);
+ Scheduler.yieldValue('Child');
+ React.useEffect(
+ () => {
+ if (didMount) {
+ Scheduler.yieldValue('Child:update');
+ } else {
+ Scheduler.yieldValue('Child:mount');
+ setDidMount(true);
+ }
+ },
+ [didMount],
+ );
+ return
;
+ };
+
+ const App = () => {
+ Scheduler.yieldValue('App');
+ React.useEffect(() => {
+ Scheduler.yieldValue('App:mount');
+ }, []);
+ return (
+
+
+
+ );
+ };
+
+ let interaction;
+
+ const onRender = jest.fn();
+
+ const container = document.createElement('div');
+ const root = ReactDOM.unstable_createRoot(container);
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
+
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
+ interaction,
+ );
+
+ expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+ expect(onRender).toHaveBeenCalledTimes(1);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+
+ expect(Scheduler).toFlushAndYieldThrough(['Child', 'Child:mount']);
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+ expect(onRender).toHaveBeenCalledTimes(2);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+
+ expect(Scheduler).toFlushAndYield(['Child', 'Child:update']);
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
+ expect(
+ onInteractionScheduledWorkCompleted,
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
+ expect(onRender).toHaveBeenCalledTimes(3);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+ });
+
+ it('traces interaction through hidden subtree when there is other pending traced work', () => {
+ const Child = () => {
+ Scheduler.yieldValue('Child');
+ return ;
+ };
+
+ let wrapped = null;
+
+ const App = () => {
+ Scheduler.yieldValue('App');
+ React.useEffect(() => {
+ wrapped = SchedulerTracing.unstable_wrap(() => {});
+ Scheduler.yieldValue('App:mount');
+ }, []);
+ return (
+
+
+
+ );
+ };
+
+ let interaction;
+
+ const onRender = jest.fn();
+
+ const container = document.createElement('div');
+ const root = ReactDOM.unstable_createRoot(container);
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
+
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
+ interaction,
+ );
+
+ expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+ expect(onRender).toHaveBeenCalledTimes(1);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+
+ expect(wrapped).not.toBeNull();
+
+ expect(Scheduler).toFlushAndYield(['Child']);
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+ expect(onRender).toHaveBeenCalledTimes(2);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+
+ wrapped();
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
+ expect(
+ onInteractionScheduledWorkCompleted,
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
+ });
+
+ it('traces interaction through hidden subtree that schedules more idle/never work', () => {
+ const Child = () => {
+ const [didMount, setDidMount] = React.useState(false);
+ Scheduler.yieldValue('Child');
+ React.useLayoutEffect(
+ () => {
+ if (didMount) {
+ Scheduler.yieldValue('Child:update');
+ } else {
+ Scheduler.yieldValue('Child:mount');
+ Scheduler.unstable_runWithPriority(
+ Scheduler.unstable_IdlePriority,
+ () => setDidMount(true),
+ );
+ }
+ },
+ [didMount],
+ );
+ return ;
+ };
+
+ const App = () => {
+ Scheduler.yieldValue('App');
+ React.useEffect(() => {
+ Scheduler.yieldValue('App:mount');
+ }, []);
+ return (
+
+
+
+ );
+ };
+
+ let interaction;
+
+ const onRender = jest.fn();
+
+ const container = document.createElement('div');
+ const root = ReactDOM.unstable_createRoot(container);
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
+
+ root.render(
+
+
+ ,
+ );
+ });
+
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
+ interaction,
+ );
+
+ expect(Scheduler).toFlushAndYieldThrough(['App', 'App:mount']);
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+ expect(onRender).toHaveBeenCalledTimes(1);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+
+ expect(Scheduler).toFlushAndYieldThrough(['Child', 'Child:mount']);
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+ expect(onRender).toHaveBeenCalledTimes(2);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+
+ expect(Scheduler).toFlushAndYield(['Child', 'Child:update']);
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
+ expect(
+ onInteractionScheduledWorkCompleted,
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
+ expect(onRender).toHaveBeenCalledTimes(3);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+ });
+
+ it('does not continue interactions across pre-existing idle work', () => {
+ const Child = () => {
+ Scheduler.yieldValue('Child');
+ return ;
+ };
+
+ let update = null;
+
+ const WithHiddenWork = () => {
+ Scheduler.yieldValue('WithHiddenWork');
+ return (
+
+
+
+ );
+ };
+
+ const Updater = () => {
+ Scheduler.yieldValue('Updater');
+ React.useEffect(() => {
+ Scheduler.yieldValue('Updater:effect');
+ });
+
+ const setCount = React.useState(0)[1];
+ update = () => {
+ setCount(current => current + 1);
+ };
+
+ return ;
+ };
+
+ const App = () => {
+ Scheduler.yieldValue('App');
+ React.useEffect(() => {
+ Scheduler.yieldValue('App:effect');
+ });
+
+ return (
+
+
+
+
+ );
+ };
+
+ const onRender = jest.fn();
+ const container = document.createElement('div');
+ const root = ReactDOM.unstable_createRoot(container);
+
+ // Schedule some idle work without any interactions.
+ TestUtils.act(() => {
+ root.render(
+
+
+ ,
+ );
+ expect(Scheduler).toFlushAndYieldThrough([
+ 'App',
+ 'WithHiddenWork',
+ 'Updater',
+ 'Updater:effect',
+ 'App:effect',
+ ]);
+ expect(update).not.toBeNull();
+
+ // Trace a higher-priority update.
+ let interaction = null;
+ SchedulerTracing.unstable_trace('update', 0, () => {
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
+ update();
+ });
+ expect(interaction).not.toBeNull();
+ expect(onRender).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
+ interaction,
+ );
+
+ // Ensure the traced interaction completes without being attributed to the pre-existing idle work.
+ expect(Scheduler).toFlushAndYieldThrough([
+ 'Updater',
+ 'Updater:effect',
+ ]);
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
+ expect(
+ onInteractionScheduledWorkCompleted,
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
+ expect(onRender).toHaveBeenCalledTimes(2);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+
+ // Complete low-priority work and ensure no lingering interaction.
+ expect(Scheduler).toFlushAndYield(['Child']);
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
+ expect(onRender).toHaveBeenCalledTimes(3);
+ expect(onRender).toHaveLastRenderedWithInteractions(new Set([]));
+ });
+ });
+
+ it('should properly trace interactions when there is work of interleaved priorities', () => {
+ const Child = () => {
+ Scheduler.yieldValue('Child');
+ return ;
+ };
+
+ let scheduleUpdate = null;
+ let scheduleUpdateWithHidden = null;
+
+ const MaybeHiddenWork = () => {
+ const [flag, setFlag] = React.useState(false);
+ scheduleUpdateWithHidden = () => setFlag(true);
+ Scheduler.yieldValue('MaybeHiddenWork');
+ React.useEffect(() => {
+ Scheduler.yieldValue('MaybeHiddenWork:effect');
+ });
+ return flag ? (
+
+
+
+ ) : null;
+ };
+
+ const Updater = () => {
+ Scheduler.yieldValue('Updater');
+ React.useEffect(() => {
+ Scheduler.yieldValue('Updater:effect');
+ });
+
+ const setCount = React.useState(0)[1];
+ scheduleUpdate = () => setCount(current => current + 1);
+
+ return ;
+ };
+
+ const App = () => {
+ Scheduler.yieldValue('App');
+ React.useEffect(() => {
+ Scheduler.yieldValue('App:effect');
+ });
+
+ return (
+
+
+
+
+ );
+ };
+
+ const onRender = jest.fn();
+ const container = document.createElement('div');
+ const root = ReactDOM.unstable_createRoot(container);
+
+ TestUtils.act(() => {
+ root.render(
+
+
+ ,
+ );
+ expect(Scheduler).toFlushAndYield([
+ 'App',
+ 'MaybeHiddenWork',
+ 'Updater',
+ 'MaybeHiddenWork:effect',
+ 'Updater:effect',
+ 'App:effect',
+ ]);
+ expect(scheduleUpdate).not.toBeNull();
+ expect(scheduleUpdateWithHidden).not.toBeNull();
+ expect(onRender).toHaveBeenCalledTimes(1);
+
+ // schedule traced high-pri update and a (non-traced) low-pri update.
+ let interaction = null;
+ SchedulerTracing.unstable_trace('update', 0, () => {
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
+ Scheduler.unstable_runWithPriority(
+ Scheduler.unstable_UserBlockingPriority,
+ () => scheduleUpdateWithHidden(),
+ );
+ });
+ scheduleUpdate();
+ expect(interaction).not.toBeNull();
+ expect(onRender).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
+ interaction,
+ );
+
+ // high-pri update should leave behind idle work and should not complete the interaction
+ expect(Scheduler).toFlushAndYieldThrough([
+ 'MaybeHiddenWork',
+ 'MaybeHiddenWork:effect',
+ ]);
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+ expect(onRender).toHaveBeenCalledTimes(2);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+
+ // low-pri update should not have the interaction
+ expect(Scheduler).toFlushAndYieldThrough([
+ 'Updater',
+ 'Updater:effect',
+ ]);
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+ expect(onRender).toHaveBeenCalledTimes(3);
+ expect(onRender).toHaveLastRenderedWithInteractions(new Set([]));
+
+ // idle work should complete the interaction
+ expect(Scheduler).toFlushAndYield(['Child']);
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
+ expect(
+ onInteractionScheduledWorkCompleted,
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
+ expect(onRender).toHaveBeenCalledTimes(4);
+ expect(onRender).toHaveLastRenderedWithInteractions(
+ new Set([interaction]),
+ );
+ });
+ });
+ });
+
+ describe('hydration', () => {
+ it('traces interaction across hydration', async done => {
+ let ref = React.createRef();
+
+ function Child() {
+ return 'Hello';
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+ );
+ }
+
+ // Render the final HTML.
+ const finalHTML = ReactDOMServer.renderToString();
+
+ const container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ let interaction;
+
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+
+ // Hydrate it.
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
+
+ root.render();
+ });
+ Scheduler.flushAll();
+ jest.runAllTimers();
+
+ expect(ref.current).not.toBe(null);
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
+ interaction,
+ );
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
+ expect(
+ onInteractionScheduledWorkCompleted,
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
+
+ done();
+ });
+
+ it('traces interaction across suspended hydration', async done => {
+ let suspend = false;
+ let resolve;
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
+ let ref = React.createRef();
+
+ function Child() {
+ if (suspend) {
+ throw promise;
+ } else {
+ return 'Hello';
+ }
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ // Render the final HTML.
+ // Don't suspend on the server.
+ const finalHTML = ReactDOMServer.renderToString();
+
+ const container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ let interaction;
+
+ const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
+
+ // Start hydrating but simulate blocking for suspense data.
+ suspend = true;
+ SchedulerTracing.unstable_trace('initialization', 0, () => {
+ interaction = Array.from(SchedulerTracing.unstable_getCurrent())[0];
+
+ root.render();
+ });
+ Scheduler.flushAll();
+ jest.runAllTimers();
+
+ expect(ref.current).toBe(null);
+ expect(onInteractionTraced).toHaveBeenCalledTimes(1);
+ expect(onInteractionTraced).toHaveBeenLastNotifiedOfInteraction(
+ interaction,
+ );
+ expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
+
+ // Resolving the promise should continue hydration
+ suspend = false;
+ resolve();
+ await promise;
+ Scheduler.flushAll();
+ jest.runAllTimers();
+
+ expect(ref.current).not.toBe(null);
+ expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1);
+ expect(
+ onInteractionScheduledWorkCompleted,
+ ).toHaveBeenLastNotifiedOfInteraction(interaction);
+
+ done();
+ });
+ });
+ });
+});
diff --git a/scripts/jest/matchers/interactionTracing.js b/scripts/jest/matchers/interactionTracingMatchers.js
similarity index 100%
rename from scripts/jest/matchers/interactionTracing.js
rename to scripts/jest/matchers/interactionTracingMatchers.js
diff --git a/scripts/jest/matchers/profilerMatchers.js b/scripts/jest/matchers/profilerMatchers.js
new file mode 100644
index 0000000000..a39169de7b
--- /dev/null
+++ b/scripts/jest/matchers/profilerMatchers.js
@@ -0,0 +1,74 @@
+'use strict';
+
+const jestDiff = require('jest-diff');
+
+function toHaveLastRenderedWithNoInteractions(onRenderMockFn) {
+ const calls = onRenderMockFn.mock.calls;
+ if (calls.length === 0) {
+ return {
+ message: () => 'Mock onRender function was not called',
+ pass: false,
+ };
+ }
+}
+
+function toHaveLastRenderedWithInteractions(
+ onRenderMockFn,
+ expectedInteractions
+) {
+ const calls = onRenderMockFn.mock.calls;
+ if (calls.length === 0) {
+ return {
+ message: () => 'Mock onRender function was not called',
+ pass: false,
+ };
+ }
+
+ const lastCall = calls[calls.length - 1];
+ const actualInteractions = lastCall[6];
+
+ return toMatchInteractions(actualInteractions, expectedInteractions);
+}
+
+function toMatchInteraction(actual, expected) {
+ let attribute;
+ for (attribute in expected) {
+ if (actual[attribute] !== expected[attribute]) {
+ return {
+ message: () => jestDiff(expected, actual),
+ pass: false,
+ };
+ }
+ }
+
+ return {pass: true};
+}
+
+function toMatchInteractions(actualSetOrArray, expectedSetOrArray) {
+ const actualArray = Array.from(actualSetOrArray);
+ const expectedArray = Array.from(expectedSetOrArray);
+
+ if (actualArray.length !== expectedArray.length) {
+ return {
+ message: () =>
+ `Expected ${expectedArray.length} interactions but there were ${
+ actualArray.length
+ }`,
+ pass: false,
+ };
+ }
+
+ for (let i = 0; i < actualArray.length; i++) {
+ const result = toMatchInteraction(actualArray[i], expectedArray[i]);
+ if (result.pass === false) {
+ return result;
+ }
+ }
+
+ return {pass: true};
+}
+
+module.exports = {
+ toHaveLastRenderedWithInteractions,
+ toHaveLastRenderedWithNoInteractions,
+};
diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js
index 838e0fd6f6..92ef339f67 100644
--- a/scripts/jest/setupTests.js
+++ b/scripts/jest/setupTests.js
@@ -44,7 +44,8 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
}
expect.extend({
- ...require('./matchers/interactionTracing'),
+ ...require('./matchers/interactionTracingMatchers'),
+ ...require('./matchers/profilerMatchers'),
...require('./matchers/toWarnDev'),
...require('./matchers/reactTestMatchers'),
});
diff --git a/scripts/jest/spec-equivalence-reporter/setupTests.js b/scripts/jest/spec-equivalence-reporter/setupTests.js
index 4fb46660d9..997abd76fb 100644
--- a/scripts/jest/spec-equivalence-reporter/setupTests.js
+++ b/scripts/jest/spec-equivalence-reporter/setupTests.js
@@ -46,7 +46,8 @@ global.spyOnProd = function(...args) {
};
expect.extend({
- ...require('../matchers/interactionTracing'),
+ ...require('../matchers/interactionTracingMatchers'),
+ ...require('../matchers/profilerMatchers'),
...require('../matchers/toWarnDev'),
...require('../matchers/reactTestMatchers'),
});