diff --git a/fixtures/unstable-async/suspense/package.json b/fixtures/unstable-async/suspense/package.json index 503b1e4d5a16f..e2364a2c63965 100644 --- a/fixtures/unstable-async/suspense/package.json +++ b/fixtures/unstable-async/suspense/package.json @@ -10,6 +10,7 @@ "dependencies": { "clipboard": "^1.7.1", "github-fork-ribbon-css": "^0.2.1", + "interaction-tracking": "../../../build/node_modules/interaction-tracking", "react": "../../../build/node_modules/react", "react-dom": "../../../build/node_modules/react-dom", "react-draggable": "^3.0.5", diff --git a/fixtures/unstable-async/suspense/src/components/App.js b/fixtures/unstable-async/suspense/src/components/App.js index a943f54965a03..88e0cd011ff99 100644 --- a/fixtures/unstable-async/suspense/src/components/App.js +++ b/fixtures/unstable-async/suspense/src/components/App.js @@ -1,3 +1,4 @@ +import {track, wrap} from 'interaction-tracking'; import React, {Placeholder, PureComponent} from 'react'; import {createResource} from 'simple-cache-provider'; import {cache} from '../cache'; @@ -27,21 +28,31 @@ export default class App extends PureComponent { } handleUserClick = id => { - this.setState({ - currentId: id, - }); - requestIdleCallback(() => { - this.setState({ - showDetail: true, - }); + track(`View ${id}`, performance.now(), () => { + track(`View ${id} (high-pri)`, performance.now(), () => + this.setState({ + currentId: id, + }) + ); + requestIdleCallback( + wrap(() => + track(`View ${id} (low-pri)`, performance.now(), () => + this.setState({ + showDetail: true, + }) + ) + ) + ); }); }; handleBackClick = () => - this.setState({ - currentId: null, - showDetail: false, - }); + track('View list', performance.now(), () => + this.setState({ + currentId: null, + showDetail: false, + }) + ); render() { const {currentId, showDetail} = this.state; diff --git a/fixtures/unstable-async/suspense/src/index.js b/fixtures/unstable-async/suspense/src/index.js index 975feb932616e..a2c047e810a1a 100644 --- a/fixtures/unstable-async/suspense/src/index.js +++ b/fixtures/unstable-async/suspense/src/index.js @@ -1,3 +1,4 @@ +import {track} from 'interaction-tracking'; import React, {Fragment, PureComponent} from 'react'; import {unstable_createRoot, render} from 'react-dom'; import {cache} from './cache'; @@ -64,11 +65,13 @@ class Debugger extends PureComponent { } handleReset = () => { - cache.invalidate(); - this.setState(state => ({ - requests: {}, - })); - handleReset(); + track('Clear cache', () => { + cache.invalidate(); + this.setState(state => ({ + requests: {}, + })); + handleReset(); + }); }; handleProgress = (url, progress, isPaused) => { diff --git a/fixtures/unstable-async/suspense/yarn.lock b/fixtures/unstable-async/suspense/yarn.lock index 4d1a5e75b0350..f80f812083ff8 100644 --- a/fixtures/unstable-async/suspense/yarn.lock +++ b/fixtures/unstable-async/suspense/yarn.lock @@ -3482,6 +3482,9 @@ inquirer@3.3.0, inquirer@^3.0.6: strip-ansi "^4.0.0" through "^2.3.6" +interaction-tracking@../../../build/node_modules/interaction-tracking: + version "0.0.1" + internal-ip@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-1.2.0.tgz#ae9fbf93b984878785d50a8de1b356956058cf5c" diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 3c287f05168a3..8044162664088 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -17,6 +17,7 @@ import type {UpdateQueue} from './ReactUpdateQueue'; import type {ContextDependency} from './ReactFiberNewContext'; import invariant from 'shared/invariant'; +import warningWithoutStack from 'shared/warningWithoutStack'; import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; import {NoEffect} from 'shared/ReactSideEffectTags'; import { @@ -531,7 +532,7 @@ export function createFiberFromProfiler( typeof pendingProps.id !== 'string' || typeof pendingProps.onRender !== 'function' ) { - invariant( + warningWithoutStack( false, 'Profiler must specify an "id" string and "onRender" function as props', ); diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 0cda7b6b22014..11317e99cf7b1 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -19,7 +19,11 @@ import type {FiberRoot} from './ReactFiberRoot'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {CapturedValue, CapturedError} from './ReactCapturedValue'; -import {enableProfilerTimer, enableSuspense} from 'shared/ReactFeatureFlags'; +import { + enableInteractionTracking, + enableProfilerTimer, + enableSuspense, +} from 'shared/ReactFeatureFlags'; import { ClassComponent, ClassComponentLazy, @@ -777,7 +781,11 @@ function commitDeletion(current: Fiber): void { detachFiber(current); } -function commitWork(current: Fiber | null, finishedWork: Fiber): void { +function commitWork( + root: FiberRoot, + current: Fiber | null, + finishedWork: Fiber, +): void { if (!supportsMutation) { commitContainer(finishedWork); return; @@ -836,14 +844,27 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { case Profiler: { if (enableProfilerTimer) { const onRender = finishedWork.memoizedProps.onRender; - onRender( - finishedWork.memoizedProps.id, - current === null ? 'mount' : 'update', - finishedWork.actualDuration, - finishedWork.treeBaseDuration, - finishedWork.actualStartTime, - getCommitTime(), - ); + + if (enableInteractionTracking) { + onRender( + finishedWork.memoizedProps.id, + current === null ? 'mount' : 'update', + finishedWork.actualDuration, + finishedWork.treeBaseDuration, + finishedWork.actualStartTime, + getCommitTime(), + root.memoizedInteractions, + ); + } else { + onRender( + finishedWork.memoizedProps.id, + current === null ? 'mount' : 'update', + finishedWork.actualDuration, + finishedWork.treeBaseDuration, + finishedWork.actualStartTime, + getCommitTime(), + ); + } } return; } diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 22ee98db32e68..2057ca6d59644 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -10,11 +10,13 @@ import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig'; +import type {Interaction} from 'interaction-tracking/src/InteractionTracking'; import {noTimeout} from './ReactFiberHostConfig'; - import {createHostRootFiber} from './ReactFiber'; import {NoWork} from './ReactFiberExpirationTime'; +import {enableInteractionTracking} from 'shared/ReactFeatureFlags'; +import {getThreadID} from 'interaction-tracking'; // TODO: This should be lifted into the renderer. export type Batch = { @@ -24,7 +26,9 @@ export type Batch = { _next: Batch | null, }; -export type FiberRoot = { +export type PendingInteractionMap = Map>; + +type BaseFiberRootProperties = {| // Any additional information from the host associated with this root. containerInfo: any, // Used only by persistent updates. @@ -73,6 +77,26 @@ export type FiberRoot = { firstBatch: Batch | null, // Linked-list of roots nextScheduledRoot: FiberRoot | null, +|}; + +// The following attributes are only used by interaction tracking builds. +// They enable interactions to be associated with their async work, +// And expose interaction metadata to the React DevTools Profiler plugin. +// Note that these attributes are only defined when the enableInteractionTracking flag is enabled. +type ProfilingOnlyFiberRootProperties = {| + interactionThreadID: number, + memoizedInteractions: Set, + pendingInteractionMap: PendingInteractionMap, +|}; + +// Exported FiberRoot type includes all properties, +// To avoid requiring potentially error-prone :any casts throughout the project. +// Profiling properties are only safe to access in profiling builds (when enableInteractionTracking is true). +// The types are defined separately within this file to ensure they stay in sync. +// (We don't have to use an inline :any cast when enableInteractionTracking is disabled.) +export type FiberRoot = { + ...BaseFiberRootProperties, + ...ProfilingOnlyFiberRootProperties, }; export function createFiberRoot( @@ -83,30 +107,69 @@ export function createFiberRoot( // Cyclic construction. This cheats the type system right now because // stateNode is any. const uninitializedFiber = createHostRootFiber(isAsync); - const root = { - current: uninitializedFiber, - containerInfo: containerInfo, - pendingChildren: null, - - earliestPendingTime: NoWork, - latestPendingTime: NoWork, - earliestSuspendedTime: NoWork, - latestSuspendedTime: NoWork, - latestPingedTime: NoWork, - - didError: false, - - pendingCommitExpirationTime: NoWork, - finishedWork: null, - timeoutHandle: noTimeout, - context: null, - pendingContext: null, - hydrate, - nextExpirationTimeToWorkOn: NoWork, - expirationTime: NoWork, - firstBatch: null, - nextScheduledRoot: null, - }; + + let root; + if (enableInteractionTracking) { + root = ({ + current: uninitializedFiber, + containerInfo: containerInfo, + pendingChildren: null, + + earliestPendingTime: NoWork, + latestPendingTime: NoWork, + earliestSuspendedTime: NoWork, + latestSuspendedTime: NoWork, + latestPingedTime: NoWork, + + didError: false, + + pendingCommitExpirationTime: NoWork, + finishedWork: null, + timeoutHandle: noTimeout, + context: null, + pendingContext: null, + hydrate, + nextExpirationTimeToWorkOn: NoWork, + expirationTime: NoWork, + firstBatch: null, + nextScheduledRoot: null, + + interactionThreadID: getThreadID(), + memoizedInteractions: new Set(), + pendingInteractionMap: new Map(), + }: FiberRoot); + } else { + root = ({ + current: uninitializedFiber, + containerInfo: containerInfo, + pendingChildren: null, + + earliestPendingTime: NoWork, + latestPendingTime: NoWork, + earliestSuspendedTime: NoWork, + latestSuspendedTime: NoWork, + latestPingedTime: NoWork, + + didError: false, + + pendingCommitExpirationTime: NoWork, + finishedWork: null, + timeoutHandle: noTimeout, + context: null, + pendingContext: null, + hydrate, + nextExpirationTimeToWorkOn: NoWork, + expirationTime: NoWork, + firstBatch: null, + nextScheduledRoot: null, + }: BaseFiberRootProperties); + } + uninitializedFiber.stateNode = root; - return root; + + // The reason for the way the Flow types are structured in this file, + // Is to avoid needing :any casts everywhere interaction-tracking fields are used. + // Unfortunately that requires an :any cast for non-interaction-tracking capable builds. + // $FlowFixMe Remove this :any cast and replace it with something better. + return ((root: any): FiberRoot); } diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 7444f514476fe..9f053d6cece60 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -8,9 +8,11 @@ */ import type {Fiber} from './ReactFiber'; -import type {FiberRoot, Batch} from './ReactFiberRoot'; +import type {Batch, FiberRoot} from './ReactFiberRoot'; import type {ExpirationTime} from './ReactFiberExpirationTime'; +import type {Interaction} from 'interaction-tracking/src/InteractionTracking'; +import {__interactionsRef, __subscriberRef} from 'interaction-tracking'; import { invokeGuardedCallback, hasCaughtError, @@ -42,6 +44,7 @@ import { HostPortal, } from 'shared/ReactWorkTags'; import { + enableInteractionTracking, enableProfilerTimer, enableUserTimingAPI, replayFailedUnitOfWorkWithInvokeGuardedCallback, @@ -242,6 +245,10 @@ let legacyErrorBoundariesThatAlreadyFailed: Set | null = null; // Used for performance tracking. let interruptedBy: Fiber | null = null; +// Do not decrement interaction counts in the event of suspense timeouts. +// This would lead to prematurely calling the interaction-complete hook. +let suspenseDidTimeout: boolean = false; + let stashedWorkInProgressProperties; let replayUnitOfWork; let isReplayingFailedUnitOfWork; @@ -363,7 +370,7 @@ function resetStack() { nextUnitOfWork = null; } -function commitAllHostEffects() { +function commitAllHostEffects(root: FiberRoot) { while (nextEffect !== null) { if (__DEV__) { ReactCurrentFiber.setCurrentFiber(nextEffect); @@ -408,12 +415,12 @@ function commitAllHostEffects() { // Update const current = nextEffect.alternate; - commitWork(current, nextEffect); + commitWork(root, current, nextEffect); break; } case Update: { const current = nextEffect.alternate; - commitWork(current, nextEffect); + commitWork(root, current, nextEffect); break; } case Deletion: { @@ -545,6 +552,34 @@ function commitRoot(root: FiberRoot, finishedWork: Fiber): void { : updateExpirationTimeBeforeCommit; markCommittedPriorityLevels(root, earliestRemainingTimeBeforeCommit); + let prevInteractions: Set = (null: any); + let committedInteractions: Array = enableInteractionTracking + ? [] + : (null: any); + if (enableInteractionTracking) { + // Restore any pending interactions at this point, + // So that cascading work triggered during the render phase will be accounted for. + prevInteractions = __interactionsRef.current; + __interactionsRef.current = root.memoizedInteractions; + + // We are potentially finished with the current batch of interactions. + // So we should clear them out of the pending interaction map. + // We do this at the start of commit in case cascading work is scheduled by commit phase lifecycles. + // In that event, interaction data may be added back into the pending map for a future commit. + // We also store the interactions we are about to commit so that we can notify subscribers after we're done. + // These are stored as an Array rather than a Set, + // Because the same interaction may be pending for multiple expiration times, + // In which case it's important that we decrement the count the right number of times after finishing. + root.pendingInteractionMap.forEach( + (scheduledInteractions, scheduledExpirationTime) => { + if (scheduledExpirationTime <= committedExpirationTime) { + committedInteractions.push(...Array.from(scheduledInteractions)); + root.pendingInteractionMap.delete(scheduledExpirationTime); + } + }, + ); + } + // Reset this to null before calling lifecycles ReactCurrentOwner.current = null; @@ -617,14 +652,14 @@ function commitRoot(root: FiberRoot, finishedWork: Fiber): void { let didError = false; let error; if (__DEV__) { - invokeGuardedCallback(null, commitAllHostEffects, null); + invokeGuardedCallback(null, commitAllHostEffects, null, root); if (hasCaughtError()) { didError = true; error = clearCaughtError(); } } else { try { - commitAllHostEffects(); + commitAllHostEffects(root); } catch (e) { didError = true; error = e; @@ -718,6 +753,53 @@ function commitRoot(root: FiberRoot, finishedWork: Fiber): void { legacyErrorBoundariesThatAlreadyFailed = null; } onCommit(root, earliestRemainingTimeAfterCommit); + + if (enableInteractionTracking) { + __interactionsRef.current = prevInteractions; + + let subscriber; + + try { + subscriber = __subscriberRef.current; + if (subscriber !== null && root.memoizedInteractions.size > 0) { + const threadID = computeThreadID( + committedExpirationTime, + root.interactionThreadID, + ); + subscriber.onWorkStopped(root.memoizedInteractions, threadID); + } + } catch (error) { + // It's not safe for commitRoot() to throw. + // Store the error for now and we'll re-throw in finishRendering(). + if (!hasUnhandledError) { + hasUnhandledError = true; + unhandledError = error; + } + } finally { + // Don't update interaction counts if we're frozen due to suspense. + // In this case, we can skip the completed-work check entirely. + if (!suspenseDidTimeout) { + // Now that we're done, check the completed batch of interactions. + // If no more work is outstanding for a given interaction, + // We need to notify the subscribers that it's finished. + committedInteractions.forEach(interaction => { + interaction.__count--; + if (subscriber !== null && interaction.__count === 0) { + try { + subscriber.onInteractionScheduledWorkCompleted(interaction); + } catch (error) { + // It's not safe for commitRoot() to throw. + // Store the error for now and we'll re-throw in finishRendering(). + if (!hasUnhandledError) { + hasUnhandledError = true; + unhandledError = error; + } + } + } + }); + } + } + } } function resetChildExpirationTime( @@ -1079,6 +1161,14 @@ function renderRoot( const expirationTime = root.nextExpirationTimeToWorkOn; + let prevInteractions: Set = (null: any); + if (enableInteractionTracking) { + // We're about to start new tracked work. + // Restore pending interactions so cascading work triggered during the render phase will be accounted for. + prevInteractions = __interactionsRef.current; + __interactionsRef.current = root.memoizedInteractions; + } + // Check if we're starting from a fresh stack, or if we're resuming from // previously yielded work. if ( @@ -1096,6 +1186,49 @@ function renderRoot( nextRenderExpirationTime, ); root.pendingCommitExpirationTime = NoWork; + + if (enableInteractionTracking) { + // Determine which interactions this batch of work currently includes, + // So that we can accurately attribute time spent working on it, + // And so that cascading work triggered during the render phase will be associated with it. + const interactions: Set = new Set(); + root.pendingInteractionMap.forEach( + (scheduledInteractions, scheduledExpirationTime) => { + if (scheduledExpirationTime <= expirationTime) { + scheduledInteractions.forEach(interaction => + interactions.add(interaction), + ); + } + }, + ); + + // Store the current set of interactions on the FiberRoot for a few reasons: + // We can re-use it in hot functions like renderRoot() without having to recalculate it. + // We will also use it in commitWork() to pass to any Profiler onRender() hooks. + // This also provides DevTools with a way to access it when the onCommitRoot() hook is called. + root.memoizedInteractions = interactions; + + if (interactions.size > 0) { + const subscriber = __subscriberRef.current; + if (subscriber !== null) { + const threadID = computeThreadID( + expirationTime, + root.interactionThreadID, + ); + try { + subscriber.onWorkStarted(interactions, threadID); + } catch (error) { + // Work thrown by a interaction-tracking subscriber should be rethrown, + // But only once it's safe (to avoid leaveing the scheduler in an invalid state). + // Store the error for now and we'll re-throw in finishRendering(). + if (!hasUnhandledError) { + hasUnhandledError = true; + unhandledError = error; + } + } + } + } + } } let didFatal = false; @@ -1159,6 +1292,11 @@ function renderRoot( break; } while (true); + if (enableInteractionTracking) { + // Tracked work is done for now; restore the previous interactions. + __interactionsRef.current = prevInteractions; + } + // We're done performing work. Time to clean up. isWorking = false; ReactCurrentOwner.currentDispatcher = null; @@ -1351,6 +1489,14 @@ function captureCommitPhaseError(fiber: Fiber, error: mixed) { return dispatch(fiber, error, Sync); } +function computeThreadID( + expirationTime: ExpirationTime, + interactionThreadID: number, +): number { + // Interaction threads are unique per root and expiration time. + return expirationTime * 1000 + interactionThreadID; +} + // Creates a unique async expiration time. function computeUniqueAsyncExpiration(): ExpirationTime { const currentTime = requestCurrentTime(); @@ -1455,7 +1601,18 @@ function retrySuspendedRoot( scheduleWorkToRoot(fiber, retryTime); const rootExpirationTime = root.expirationTime; if (rootExpirationTime !== NoWork) { - requestWork(root, rootExpirationTime); + if (enableInteractionTracking) { + // Restore previous interactions so that new work is associated with them. + let prevInteractions = __interactionsRef.current; + __interactionsRef.current = root.memoizedInteractions; + // Because suspense timeouts do not decrement the interaction count, + // Continued suspense work should also not increment the count. + storeInteractionsForExpirationTime(root, rootExpirationTime, false); + requestWork(root, rootExpirationTime); + __interactionsRef.current = prevInteractions; + } else { + requestWork(root, rootExpirationTime); + } } } } @@ -1510,6 +1667,49 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null { return null; } +function storeInteractionsForExpirationTime( + root: FiberRoot, + expirationTime: ExpirationTime, + updateInteractionCounts: boolean, +): void { + if (!enableInteractionTracking) { + return; + } + + const interactions = __interactionsRef.current; + if (interactions.size > 0) { + const pendingInteractions = root.pendingInteractionMap.get(expirationTime); + if (pendingInteractions != null) { + interactions.forEach(interaction => { + if (updateInteractionCounts && !pendingInteractions.has(interaction)) { + // Update the pending async work count for previously unscheduled interaction. + interaction.__count++; + } + + pendingInteractions.add(interaction); + }); + } else { + root.pendingInteractionMap.set(expirationTime, new Set(interactions)); + + // Update the pending async work count for the current interactions. + if (updateInteractionCounts) { + interactions.forEach(interaction => { + interaction.__count++; + }); + } + } + + const subscriber = __subscriberRef.current; + if (subscriber !== null) { + const threadID = computeThreadID( + expirationTime, + root.interactionThreadID, + ); + subscriber.onWorkScheduled(interactions, threadID); + } + } +} + function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) { recordScheduleUpdate(); @@ -1531,6 +1731,10 @@ function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) { return; } + if (enableInteractionTracking) { + storeInteractionsForExpirationTime(root, expirationTime, true); + } + if ( !isWorking && nextRenderExpirationTime !== NoWork && @@ -1637,7 +1841,10 @@ function recomputeCurrentRendererTime() { currentRendererTime = msToExpirationTime(currentTimeMs); } -function scheduleCallbackWithExpirationTime(expirationTime) { +function scheduleCallbackWithExpirationTime( + root: FiberRoot, + expirationTime: ExpirationTime, +) { if (callbackExpirationTime !== NoWork) { // A callback is already scheduled. Check its expiration time (timeout). if (expirationTime > callbackExpirationTime) { @@ -1714,7 +1921,16 @@ function onTimeout(root, finishedWork, suspendedExpirationTime) { // because we're at the top of a timer event. recomputeCurrentRendererTime(); currentSchedulerTime = currentRendererTime; - flushRoot(root, suspendedExpirationTime); + + if (enableInteractionTracking) { + // Don't update pending interaction counts for suspense timeouts, + // Because we know we still need to do more work in this case. + suspenseDidTimeout = true; + flushRoot(root, suspendedExpirationTime); + suspenseDidTimeout = false; + } else { + flushRoot(root, suspendedExpirationTime); + } } } @@ -1793,7 +2009,7 @@ function requestWork(root: FiberRoot, expirationTime: ExpirationTime) { if (expirationTime === Sync) { performSyncWork(); } else { - scheduleCallbackWithExpirationTime(expirationTime); + scheduleCallbackWithExpirationTime(root, expirationTime); } } @@ -1955,7 +2171,10 @@ function performWork(minExpirationTime: ExpirationTime, dl: Deadline | null) { } // If there's work left over, schedule a new callback. if (nextFlushedExpirationTime !== NoWork) { - scheduleCallbackWithExpirationTime(nextFlushedExpirationTime); + scheduleCallbackWithExpirationTime( + ((nextFlushedRoot: any): FiberRoot), + nextFlushedExpirationTime, + ); } // Clean-up. diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index b1149394c97ee..5ccc00f31a7f9 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -5,33 +5,72 @@ * LICENSE file in the root directory of this source tree. * * @emails react-core + * @jest-environment node */ 'use strict'; +let InteractionTracking; +let InteractionTrackingSubscriptions; let React; let ReactFeatureFlags; let ReactNoop; let ReactTestRenderer; +let advanceTimeBy; +let mockNow; +let AdvanceTime; function loadModules({ enableProfilerTimer = true, + enableSuspense = false, + enableInteractionTracking = true, replayFailedUnitOfWorkWithInvokeGuardedCallback = false, useNoopRenderer = false, } = {}) { + let currentTime = 0; + + mockNow = jest.fn().mockImplementation(() => currentTime); + + global.Date.now = mockNow; + ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffects = false; ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; ReactFeatureFlags.enableProfilerTimer = enableProfilerTimer; ReactFeatureFlags.enableGetDerivedStateFromCatch = true; + ReactFeatureFlags.enableInteractionTracking = enableInteractionTracking; + ReactFeatureFlags.enableSuspense = enableSuspense; ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = replayFailedUnitOfWorkWithInvokeGuardedCallback; + + InteractionTracking = require('interaction-tracking'); + InteractionTrackingSubscriptions = require('interaction-tracking/subscriptions'); React = require('react'); if (useNoopRenderer) { ReactNoop = require('react-noop-renderer'); } else { ReactTestRenderer = require('react-test-renderer'); + ReactTestRenderer.unstable_setNowImplementation(mockNow); } + + advanceTimeBy = amount => { + currentTime += amount; + }; + + AdvanceTime = class extends React.Component { + static defaultProps = { + byAmount: 10, + shouldComponentUpdate: true, + }; + shouldComponentUpdate(nextProps) { + return nextProps.shouldComponentUpdate; + } + render() { + // Simulate time passing when this component is rendered + advanceTimeBy(this.props.byAmount); + return this.props.children || null; + } + }; } const mockDevToolsForTest = () => { @@ -45,1009 +84,1016 @@ const mockDevToolsForTest = () => { describe('Profiler', () => { describe('works in profiling and non-profiling bundles', () => { - [true, false].forEach(flagEnabled => { - describe(`enableProfilerTimer ${ - flagEnabled ? 'enabled' : 'disabled' - }`, () => { - beforeEach(() => { - jest.resetModules(); - - loadModules({enableProfilerTimer: flagEnabled}); - }); + [true, false].forEach(enableInteractionTracking => { + [true, false].forEach(enableProfilerTimer => { + describe(`enableInteractionTracking:${ + enableInteractionTracking ? 'enabled' : 'disabled' + } enableProfilerTimer:${ + enableProfilerTimer ? 'enabled' : 'disabled' + }`, () => { + beforeEach(() => { + jest.resetModules(); - // This will throw in production too, - // But the test is only interested in verifying the DEV error message. - if (__DEV__ && flagEnabled) { - it('should warn if required params are missing', () => { - expect(() => { - ReactTestRenderer.create(); - }).toThrow( - 'Profiler must specify an "id" string and "onRender" function as props', - ); + loadModules({enableInteractionTracking, enableProfilerTimer}); + }); + + // This will throw in production too, + // But the test is only interested in verifying the DEV error message. + if (__DEV__ && enableProfilerTimer) { + it('should warn if required params are missing', () => { + expect(() => { + expect(() => { + ReactTestRenderer.create(); + }).toThrow('onRender is not a function'); + }).toWarnDev( + 'Profiler must specify an "id" string and "onRender" function as props', + {withoutStack: true}, + ); + }); + } + + it('should support an empty Profiler (with no children)', () => { + // As root + expect( + ReactTestRenderer.create( + , + ).toJSON(), + ).toMatchSnapshot(); + + // As non-root + expect( + ReactTestRenderer.create( +
+ +
, + ).toJSON(), + ).toMatchSnapshot(); }); - } - it('should support an empty Profiler (with no children)', () => { - // As root - expect( - ReactTestRenderer.create( - , - ).toJSON(), - ).toMatchSnapshot(); - - // As non-root - expect( - ReactTestRenderer.create( + it('should render children', () => { + const FunctionalComponent = ({label}) => {label}; + const renderer = ReactTestRenderer.create(
- + outside span + + inside span + +
, - ).toJSON(), - ).toMatchSnapshot(); - }); - - it('should render children', () => { - const FunctionalComponent = ({label}) => {label}; - const renderer = ReactTestRenderer.create( -
- outside span - - inside span - - -
, - ); - expect(renderer.toJSON()).toMatchSnapshot(); - }); + ); + expect(renderer.toJSON()).toMatchSnapshot(); + }); - it('should support nested Profilers', () => { - const FunctionalComponent = ({label}) =>
{label}
; - class ClassComponent extends React.Component { - render() { - return {this.props.label}; + it('should support nested Profilers', () => { + const FunctionalComponent = ({label}) =>
{label}
; + class ClassComponent extends React.Component { + render() { + return {this.props.label}; + } } - } - const renderer = ReactTestRenderer.create( - - - - - inner span - - , - ); - expect(renderer.toJSON()).toMatchSnapshot(); + const renderer = ReactTestRenderer.create( + + + + + inner span + + , + ); + expect(renderer.toJSON()).toMatchSnapshot(); + }); }); }); }); }); - describe('onRender callback', () => { - let AdvanceTime; - let advanceTimeBy; - let mockNow; - - const mockNowForTests = () => { - let currentTime = 0; - - mockNow = jest.fn().mockImplementation(() => currentTime); - - ReactTestRenderer.unstable_setNowImplementation(mockNow); - advanceTimeBy = amount => { - currentTime += amount; - }; - }; + [true, false].forEach(enableInteractionTracking => { + describe('onRender callback', () => { + beforeEach(() => { + jest.resetModules(); - beforeEach(() => { - jest.resetModules(); + loadModules({enableInteractionTracking}); + }); - loadModules(); - mockNowForTests(); + it('is not invoked until the commit phase', () => { + const callback = jest.fn(); - AdvanceTime = class extends React.Component { - static defaultProps = { - byAmount: 10, - shouldComponentUpdate: true, + const Yield = ({value}) => { + ReactTestRenderer.unstable_yield(value); + return null; }; - shouldComponentUpdate(nextProps) { - return nextProps.shouldComponentUpdate; - } - render() { - // Simulate time passing when this component is rendered - advanceTimeBy(this.props.byAmount); - return this.props.children || null; - } - }; - }); - it('is not invoked until the commit phase', () => { - const callback = jest.fn(); - - const Yield = ({value}) => { - ReactTestRenderer.unstable_yield(value); - return null; - }; + const renderer = ReactTestRenderer.create( + + + + , + { + unstable_isAsync: true, + }, + ); - const renderer = ReactTestRenderer.create( - - - - , - { - unstable_isAsync: true, - }, - ); + // Times are logged until a render is committed. + expect(renderer).toFlushThrough(['first']); + expect(callback).toHaveBeenCalledTimes(0); + expect(renderer).toFlushAll(['last']); + expect(callback).toHaveBeenCalledTimes(1); + }); - // Times are logged until a render is committed. - expect(renderer).toFlushThrough(['first']); - expect(callback).toHaveBeenCalledTimes(0); - expect(renderer).toFlushAll(['last']); - expect(callback).toHaveBeenCalledTimes(1); - }); + it('does not record times for components outside of Profiler tree', () => { + ReactTestRenderer.create( +
+ + + + + +
, + ); - it('does not record times for components outside of Profiler tree', () => { - ReactTestRenderer.create( -
- - - - - -
, - ); + // Should be called two times: + // 2. To compute the update expiration time + // 3. To record the commit time + // No additional calls from ProfilerTimer are expected. + expect(mockNow).toHaveBeenCalledTimes(2); + }); - // Should be called two times: - // 2. To compute the update expiration time - // 3. To record the commit time - // No additional calls from ProfilerTimer are expected. - expect(mockNow).toHaveBeenCalledTimes(2); - }); + it('logs render times for both mount and update', () => { + const callback = jest.fn(); - it('logs render times for both mount and update', () => { - const callback = jest.fn(); + advanceTimeBy(5); // 0 -> 5 - advanceTimeBy(5); // 0 -> 5 + const renderer = ReactTestRenderer.create( + + + , + ); - const renderer = ReactTestRenderer.create( - - - , - ); + expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledTimes(1); + let [call] = callback.mock.calls; - let [call] = callback.mock.calls; + expect(call).toHaveLength(enableInteractionTracking ? 7 : 6); + expect(call[0]).toBe('test'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(10); // actual time + expect(call[3]).toBe(10); // base time + expect(call[4]).toBe(5); // start time + expect(call[5]).toBe(15); // commit time + expect(call[6]).toEqual( + enableInteractionTracking ? new Set() : undefined, + ); // interaction events - expect(call).toHaveLength(6); - expect(call[0]).toBe('test'); - expect(call[1]).toBe('mount'); - expect(call[2]).toBe(10); // actual time - expect(call[3]).toBe(10); // base time - expect(call[4]).toBe(5); // start time - expect(call[5]).toBe(15); // commit time + callback.mockReset(); - callback.mockReset(); + advanceTimeBy(20); // 15 -> 35 - advanceTimeBy(20); // 15 -> 35 + renderer.update( + + + , + ); - renderer.update( - - - , - ); + expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledTimes(1); + [call] = callback.mock.calls; - [call] = callback.mock.calls; + expect(call).toHaveLength(enableInteractionTracking ? 7 : 6); + expect(call[0]).toBe('test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(10); // actual time + expect(call[3]).toBe(10); // base time + expect(call[4]).toBe(35); // start time + expect(call[5]).toBe(45); // commit time + expect(call[6]).toEqual( + enableInteractionTracking ? new Set() : undefined, + ); // interaction events - expect(call).toHaveLength(6); - expect(call[0]).toBe('test'); - expect(call[1]).toBe('update'); - expect(call[2]).toBe(10); // actual time - expect(call[3]).toBe(10); // base time - expect(call[4]).toBe(35); // start time - expect(call[5]).toBe(45); // commit time + callback.mockReset(); - callback.mockReset(); + advanceTimeBy(20); // 45 -> 65 - advanceTimeBy(20); // 45 -> 65 + renderer.update( + + + , + ); - renderer.update( - - - , - ); + expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledTimes(1); + [call] = callback.mock.calls; + + expect(call).toHaveLength(enableInteractionTracking ? 7 : 6); + expect(call[0]).toBe('test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(4); // actual time + expect(call[3]).toBe(4); // base time + expect(call[4]).toBe(65); // start time + expect(call[5]).toBe(69); // commit time + expect(call[6]).toEqual( + enableInteractionTracking ? new Set() : undefined, + ); // interaction events + }); - [call] = callback.mock.calls; + it('includes render times of nested Profilers in their parent times', () => { + const callback = jest.fn(); - expect(call).toHaveLength(6); - expect(call[0]).toBe('test'); - expect(call[1]).toBe('update'); - expect(call[2]).toBe(4); // actual time - expect(call[3]).toBe(4); // base time - expect(call[4]).toBe(65); // start time - expect(call[5]).toBe(69); // commit time - }); + advanceTimeBy(5); // 0 -> 5 - it('includes render times of nested Profilers in their parent times', () => { - const callback = jest.fn(); + ReactTestRenderer.create( + + + + + + + + + , + ); - advanceTimeBy(5); // 0 -> 5 + expect(callback).toHaveBeenCalledTimes(2); - ReactTestRenderer.create( - - - - - - - - - , - ); + // Callbacks bubble (reverse order). + const [childCall, parentCall] = callback.mock.calls; + expect(childCall[0]).toBe('child'); + expect(parentCall[0]).toBe('parent'); + + // Parent times should include child times + expect(childCall[2]).toBe(20); // actual time + expect(childCall[3]).toBe(20); // base time + expect(childCall[4]).toBe(15); // start time + expect(childCall[5]).toBe(35); // commit time + expect(parentCall[2]).toBe(30); // actual time + expect(parentCall[3]).toBe(30); // base time + expect(parentCall[4]).toBe(5); // start time + expect(parentCall[5]).toBe(35); // commit time + }); - expect(callback).toHaveBeenCalledTimes(2); - - // Callbacks bubble (reverse order). - const [childCall, parentCall] = callback.mock.calls; - expect(childCall[0]).toBe('child'); - expect(parentCall[0]).toBe('parent'); - - // Parent times should include child times - expect(childCall[2]).toBe(20); // actual time - expect(childCall[3]).toBe(20); // base time - expect(childCall[4]).toBe(15); // start time - expect(childCall[5]).toBe(35); // commit time - expect(parentCall[2]).toBe(30); // actual time - expect(parentCall[3]).toBe(30); // base time - expect(parentCall[4]).toBe(5); // start time - expect(parentCall[5]).toBe(35); // commit time - }); + it('tracks sibling Profilers separately', () => { + const callback = jest.fn(); - it('tracks sibling Profilers separately', () => { - const callback = jest.fn(); + advanceTimeBy(5); // 0 -> 5 - advanceTimeBy(5); // 0 -> 5 + ReactTestRenderer.create( + + + + + + + + , + ); - ReactTestRenderer.create( - - - - - - - - , - ); + expect(callback).toHaveBeenCalledTimes(2); - expect(callback).toHaveBeenCalledTimes(2); - - const [firstCall, secondCall] = callback.mock.calls; - expect(firstCall[0]).toBe('first'); - expect(secondCall[0]).toBe('second'); - - // Parent times should include child times - expect(firstCall[2]).toBe(20); // actual time - expect(firstCall[3]).toBe(20); // base time - expect(firstCall[4]).toBe(5); // start time - expect(firstCall[5]).toBe(30); // commit time - expect(secondCall[2]).toBe(5); // actual time - expect(secondCall[3]).toBe(5); // base time - expect(secondCall[4]).toBe(25); // start time - expect(secondCall[5]).toBe(30); // commit time - }); + const [firstCall, secondCall] = callback.mock.calls; + expect(firstCall[0]).toBe('first'); + expect(secondCall[0]).toBe('second'); + + // Parent times should include child times + expect(firstCall[2]).toBe(20); // actual time + expect(firstCall[3]).toBe(20); // base time + expect(firstCall[4]).toBe(5); // start time + expect(firstCall[5]).toBe(30); // commit time + expect(secondCall[2]).toBe(5); // actual time + expect(secondCall[3]).toBe(5); // base time + expect(secondCall[4]).toBe(25); // start time + expect(secondCall[5]).toBe(30); // commit time + }); - it('does not include time spent outside of profile root', () => { - const callback = jest.fn(); + it('does not include time spent outside of profile root', () => { + const callback = jest.fn(); - advanceTimeBy(5); // 0 -> 5 + advanceTimeBy(5); // 0 -> 5 - ReactTestRenderer.create( - - - - - - - , - ); + ReactTestRenderer.create( + + + + + + + , + ); - expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); - const [call] = callback.mock.calls; - expect(call[0]).toBe('test'); - expect(call[2]).toBe(5); // actual time - expect(call[3]).toBe(5); // base time - expect(call[4]).toBe(25); // start time - expect(call[5]).toBe(50); // commit time - }); + const [call] = callback.mock.calls; + expect(call[0]).toBe('test'); + expect(call[2]).toBe(5); // actual time + expect(call[3]).toBe(5); // base time + expect(call[4]).toBe(25); // start time + expect(call[5]).toBe(50); // commit time + }); - it('is not called when blocked by sCU false', () => { - const callback = jest.fn(); + it('is not called when blocked by sCU false', () => { + const callback = jest.fn(); - let instance; - class Updater extends React.Component { - state = {}; - render() { - instance = this; - return this.props.children; + let instance; + class Updater extends React.Component { + state = {}; + render() { + instance = this; + return this.props.children; + } } - } - class Pure extends React.PureComponent { - render() { - return this.props.children; + class Pure extends React.PureComponent { + render() { + return this.props.children; + } } - } - const renderer = ReactTestRenderer.create( - - - - - -
- - - - - , - ); + const renderer = ReactTestRenderer.create( + + + + + +
+ + + + + , + ); - // All profile callbacks are called for initial render - expect(callback).toHaveBeenCalledTimes(3); + // All profile callbacks are called for initial render + expect(callback).toHaveBeenCalledTimes(3); - callback.mockReset(); + callback.mockReset(); - renderer.unstable_flushSync(() => { - instance.setState({ - count: 1, + renderer.unstable_flushSync(() => { + instance.setState({ + count: 1, + }); }); - }); - // Only call profile updates for paths that have re-rendered - // Since "inner" is beneath a pure compoent, it isn't called - expect(callback).toHaveBeenCalledTimes(2); - expect(callback.mock.calls[0][0]).toBe('middle'); - expect(callback.mock.calls[1][0]).toBe('outer'); - }); + // Only call profile updates for paths that have re-rendered + // Since "inner" is beneath a pure compoent, it isn't called + expect(callback).toHaveBeenCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('middle'); + expect(callback.mock.calls[1][0]).toBe('outer'); + }); - it('decreases actual time but not base time when sCU prevents an update', () => { - const callback = jest.fn(); + it('decreases actual time but not base time when sCU prevents an update', () => { + const callback = jest.fn(); - advanceTimeBy(5); // 0 -> 5 + advanceTimeBy(5); // 0 -> 5 - const renderer = ReactTestRenderer.create( - - - - - , - ); + const renderer = ReactTestRenderer.create( + + + + + , + ); - expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); - advanceTimeBy(30); // 28 -> 58 + advanceTimeBy(30); // 28 -> 58 - renderer.update( - - - - - , - ); + renderer.update( + + + + + , + ); - expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledTimes(2); - const [mountCall, updateCall] = callback.mock.calls; + const [mountCall, updateCall] = callback.mock.calls; - expect(mountCall[1]).toBe('mount'); - expect(mountCall[2]).toBe(23); // actual time - expect(mountCall[3]).toBe(23); // base time - expect(mountCall[4]).toBe(5); // start time - expect(mountCall[5]).toBe(28); // commit time + expect(mountCall[1]).toBe('mount'); + expect(mountCall[2]).toBe(23); // actual time + expect(mountCall[3]).toBe(23); // base time + expect(mountCall[4]).toBe(5); // start time + expect(mountCall[5]).toBe(28); // commit time - expect(updateCall[1]).toBe('update'); - expect(updateCall[2]).toBe(4); // actual time - expect(updateCall[3]).toBe(17); // base time - expect(updateCall[4]).toBe(58); // start time - expect(updateCall[5]).toBe(62); // commit time - }); + expect(updateCall[1]).toBe('update'); + expect(updateCall[2]).toBe(4); // actual time + expect(updateCall[3]).toBe(17); // base time + expect(updateCall[4]).toBe(58); // start time + expect(updateCall[5]).toBe(62); // commit time + }); - it('includes time spent in render phase lifecycles', () => { - class WithLifecycles extends React.Component { - state = {}; - static getDerivedStateFromProps() { - advanceTimeBy(3); - return null; - } - shouldComponentUpdate() { - advanceTimeBy(7); - return true; - } - render() { - advanceTimeBy(5); - return null; + it('includes time spent in render phase lifecycles', () => { + class WithLifecycles extends React.Component { + state = {}; + static getDerivedStateFromProps() { + advanceTimeBy(3); + return null; + } + shouldComponentUpdate() { + advanceTimeBy(7); + return true; + } + render() { + advanceTimeBy(5); + return null; + } } - } - - const callback = jest.fn(); - advanceTimeBy(5); // 0 -> 5 - - const renderer = ReactTestRenderer.create( - - - , - ); - - advanceTimeBy(15); // 13 -> 28 - - renderer.update( - - - , - ); - - expect(callback).toHaveBeenCalledTimes(2); - - const [mountCall, updateCall] = callback.mock.calls; - - expect(mountCall[1]).toBe('mount'); - expect(mountCall[2]).toBe(8); // actual time - expect(mountCall[3]).toBe(8); // base time - expect(mountCall[4]).toBe(5); // start time - expect(mountCall[5]).toBe(13); // commit time - - expect(updateCall[1]).toBe('update'); - expect(updateCall[2]).toBe(15); // actual time - expect(updateCall[3]).toBe(15); // base time - expect(updateCall[4]).toBe(28); // start time - expect(updateCall[5]).toBe(43); // commit time - }); - - describe('with regard to interruptions', () => { - it('should accumulate actual time after a scheduling interruptions', () => { const callback = jest.fn(); - const Yield = ({renderTime}) => { - advanceTimeBy(renderTime); - ReactTestRenderer.unstable_yield('Yield:' + renderTime); - return null; - }; - advanceTimeBy(5); // 0 -> 5 - // Render partially, but run out of time before completing. const renderer = ReactTestRenderer.create( - - + , - {unstable_isAsync: true}, ); - expect(renderer).toFlushThrough(['Yield:2']); - expect(callback).toHaveBeenCalledTimes(0); - - // Resume render for remaining children. - expect(renderer).toFlushAll(['Yield:3']); - - // Verify that logged times include both durations above. - expect(callback).toHaveBeenCalledTimes(1); - const [call] = callback.mock.calls; - expect(call[2]).toBe(5); // actual time - expect(call[3]).toBe(5); // base time - expect(call[4]).toBe(5); // start time - expect(call[5]).toBe(10); // commit time - }); - - it('should not include time between frames', () => { - const callback = jest.fn(); - - const Yield = ({renderTime}) => { - advanceTimeBy(renderTime); - ReactTestRenderer.unstable_yield('Yield:' + renderTime); - return null; - }; - advanceTimeBy(5); // 0 -> 5 + advanceTimeBy(15); // 13 -> 28 - // Render partially, but don't finish. - // This partial render should take 5ms of simulated time. - const renderer = ReactTestRenderer.create( - - - - - - + renderer.update( + + , - {unstable_isAsync: true}, ); - expect(renderer).toFlushThrough(['Yield:5']); - expect(callback).toHaveBeenCalledTimes(0); - // Simulate time moving forward while frame is paused. - advanceTimeBy(50); // 10 -> 60 - - // Flush the remaninig work, - // Which should take an additional 10ms of simulated time. - expect(renderer).toFlushAll(['Yield:10', 'Yield:17']); expect(callback).toHaveBeenCalledTimes(2); - const [innerCall, outerCall] = callback.mock.calls; - - // Verify that the actual time includes all work times, - // But not the time that elapsed between frames. - expect(innerCall[0]).toBe('inner'); - expect(innerCall[2]).toBe(17); // actual time - expect(innerCall[3]).toBe(17); // base time - expect(innerCall[4]).toBe(70); // start time - expect(innerCall[5]).toBe(87); // commit time - expect(outerCall[0]).toBe('outer'); - expect(outerCall[2]).toBe(32); // actual time - expect(outerCall[3]).toBe(32); // base time - expect(outerCall[4]).toBe(5); // start time - expect(outerCall[5]).toBe(87); // commit time + const [mountCall, updateCall] = callback.mock.calls; + + expect(mountCall[1]).toBe('mount'); + expect(mountCall[2]).toBe(8); // actual time + expect(mountCall[3]).toBe(8); // base time + expect(mountCall[4]).toBe(5); // start time + expect(mountCall[5]).toBe(13); // commit time + + expect(updateCall[1]).toBe('update'); + expect(updateCall[2]).toBe(15); // actual time + expect(updateCall[3]).toBe(15); // base time + expect(updateCall[4]).toBe(28); // start time + expect(updateCall[5]).toBe(43); // commit time }); - it('should report the expected times when a high-priority update replaces an in-progress initial render', () => { - const callback = jest.fn(); + describe('with regard to interruptions', () => { + it('should accumulate actual time after a scheduling interruptions', () => { + const callback = jest.fn(); - const Yield = ({renderTime}) => { - advanceTimeBy(renderTime); - ReactTestRenderer.unstable_yield('Yield:' + renderTime); - return null; - }; + const Yield = ({renderTime}) => { + advanceTimeBy(renderTime); + ReactTestRenderer.unstable_yield('Yield:' + renderTime); + return null; + }; - advanceTimeBy(5); // 0 -> 5 + advanceTimeBy(5); // 0 -> 5 - // Render a partially update, but don't finish. - // This partial render should take 10ms of simulated time. - const renderer = ReactTestRenderer.create( - - - - , - {unstable_isAsync: true}, - ); - expect(renderer).toFlushThrough(['Yield:10']); - expect(callback).toHaveBeenCalledTimes(0); - - // Simulate time moving forward while frame is paused. - advanceTimeBy(100); // 15 -> 115 - - // Interrupt with higher priority work. - // The interrupted work simulates an additional 5ms of time. - renderer.unstable_flushSync(() => { - renderer.update( + // Render partially, but run out of time before completing. + const renderer = ReactTestRenderer.create( - + + , + {unstable_isAsync: true}, ); + expect(renderer).toFlushThrough(['Yield:2']); + expect(callback).toHaveBeenCalledTimes(0); + + // Resume render for remaining children. + expect(renderer).toFlushAll(['Yield:3']); + + // Verify that logged times include both durations above. + expect(callback).toHaveBeenCalledTimes(1); + const [call] = callback.mock.calls; + expect(call[2]).toBe(5); // actual time + expect(call[3]).toBe(5); // base time + expect(call[4]).toBe(5); // start time + expect(call[5]).toBe(10); // commit time }); - expect(ReactTestRenderer).toClearYields(['Yield:5']); - // The initial work was thrown away in this case, - // So the actual and base times should only include the final rendered tree times. - expect(callback).toHaveBeenCalledTimes(1); - let call = callback.mock.calls[0]; - expect(call[2]).toBe(5); // actual time - expect(call[3]).toBe(5); // base time - expect(call[4]).toBe(115); // start time - expect(call[5]).toBe(120); // commit time - - callback.mockReset(); - - // Verify no more unexpected callbacks from low priority work - expect(renderer).toFlushAll([]); - expect(callback).toHaveBeenCalledTimes(0); - }); + it('should not include time between frames', () => { + const callback = jest.fn(); - it('should report the expected times when a high-priority update replaces a low-priority update', () => { - const callback = jest.fn(); + const Yield = ({renderTime}) => { + advanceTimeBy(renderTime); + ReactTestRenderer.unstable_yield('Yield:' + renderTime); + return null; + }; - const Yield = ({renderTime}) => { - advanceTimeBy(renderTime); - ReactTestRenderer.unstable_yield('Yield:' + renderTime); - return null; - }; + advanceTimeBy(5); // 0 -> 5 - advanceTimeBy(5); // 0 -> 5 - - const renderer = ReactTestRenderer.create( - - - - , - {unstable_isAsync: true}, - ); - - // Render everything initially. - // This should take 21 seconds of actual and base time. - expect(renderer).toFlushAll(['Yield:6', 'Yield:15']); - expect(callback).toHaveBeenCalledTimes(1); - let call = callback.mock.calls[0]; - expect(call[2]).toBe(21); // actual time - expect(call[3]).toBe(21); // base time - expect(call[4]).toBe(5); // start time - expect(call[5]).toBe(26); // commit time - - callback.mockReset(); - - advanceTimeBy(30); // 26 -> 56 - - // Render a partially update, but don't finish. - // This partial render should take 3ms of simulated time. - renderer.update( - - - - - , - ); - expect(renderer).toFlushThrough(['Yield:3']); - expect(callback).toHaveBeenCalledTimes(0); + // Render partially, but don't finish. + // This partial render should take 5ms of simulated time. + const renderer = ReactTestRenderer.create( + + + + + + + , + {unstable_isAsync: true}, + ); + expect(renderer).toFlushThrough(['Yield:5']); + expect(callback).toHaveBeenCalledTimes(0); + + // Simulate time moving forward while frame is paused. + advanceTimeBy(50); // 10 -> 60 + + // Flush the remaninig work, + // Which should take an additional 10ms of simulated time. + expect(renderer).toFlushAll(['Yield:10', 'Yield:17']); + expect(callback).toHaveBeenCalledTimes(2); + + const [innerCall, outerCall] = callback.mock.calls; + + // Verify that the actual time includes all work times, + // But not the time that elapsed between frames. + expect(innerCall[0]).toBe('inner'); + expect(innerCall[2]).toBe(17); // actual time + expect(innerCall[3]).toBe(17); // base time + expect(innerCall[4]).toBe(70); // start time + expect(innerCall[5]).toBe(87); // commit time + expect(outerCall[0]).toBe('outer'); + expect(outerCall[2]).toBe(32); // actual time + expect(outerCall[3]).toBe(32); // base time + expect(outerCall[4]).toBe(5); // start time + expect(outerCall[5]).toBe(87); // commit time + }); - // Simulate time moving forward while frame is paused. - advanceTimeBy(100); // 59 -> 159 + it('should report the expected times when a high-pri update replaces a mount in-progress', () => { + const callback = jest.fn(); - // Render another 5ms of simulated time. - expect(renderer).toFlushThrough(['Yield:5']); - expect(callback).toHaveBeenCalledTimes(0); + const Yield = ({renderTime}) => { + advanceTimeBy(renderTime); + ReactTestRenderer.unstable_yield('Yield:' + renderTime); + return null; + }; - // Simulate time moving forward while frame is paused. - advanceTimeBy(100); // 164 -> 264 + advanceTimeBy(5); // 0 -> 5 - // Interrupt with higher priority work. - // The interrupted work simulates an additional 11ms of time. - renderer.unstable_flushSync(() => { - renderer.update( + // Render a partially update, but don't finish. + // This partial render should take 10ms of simulated time. + const renderer = ReactTestRenderer.create( - + + , + {unstable_isAsync: true}, ); - }); - expect(ReactTestRenderer).toClearYields(['Yield:11']); + expect(renderer).toFlushThrough(['Yield:10']); + expect(callback).toHaveBeenCalledTimes(0); - // The actual time should include only the most recent render, - // Because this lets us avoid a lot of commit phase reset complexity. - // The base time includes only the final rendered tree times. - expect(callback).toHaveBeenCalledTimes(1); - call = callback.mock.calls[0]; - expect(call[2]).toBe(11); // actual time - expect(call[3]).toBe(11); // base time - expect(call[4]).toBe(264); // start time - expect(call[5]).toBe(275); // commit time - - // Verify no more unexpected callbacks from low priority work - expect(renderer).toFlushAll([]); - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('should report the expected times when a high-priority update interrupts a low-priority update', () => { - const callback = jest.fn(); + // Simulate time moving forward while frame is paused. + advanceTimeBy(100); // 15 -> 115 - const Yield = ({renderTime}) => { - advanceTimeBy(renderTime); - ReactTestRenderer.unstable_yield('Yield:' + renderTime); - return null; - }; - - let first; - class FirstComponent extends React.Component { - state = {renderTime: 1}; - render() { - first = this; - advanceTimeBy(this.state.renderTime); - ReactTestRenderer.unstable_yield( - 'FirstComponent:' + this.state.renderTime, - ); - return ; - } - } - let second; - class SecondComponent extends React.Component { - state = {renderTime: 2}; - render() { - second = this; - advanceTimeBy(this.state.renderTime); - ReactTestRenderer.unstable_yield( - 'SecondComponent:' + this.state.renderTime, + // Interrupt with higher priority work. + // The interrupted work simulates an additional 5ms of time. + renderer.unstable_flushSync(() => { + renderer.update( + + + , ); - return ; - } - } - - advanceTimeBy(5); // 0 -> 5 - - const renderer = ReactTestRenderer.create( - - - - , - {unstable_isAsync: true}, - ); - - // Render everything initially. - // This simulates a total of 14ms of actual render time. - // The base render time is also 14ms for the initial render. - expect(renderer).toFlushAll([ - 'FirstComponent:1', - 'Yield:4', - 'SecondComponent:2', - 'Yield:7', - ]); - expect(callback).toHaveBeenCalledTimes(1); - let call = callback.mock.calls[0]; - expect(call[2]).toBe(14); // actual time - expect(call[3]).toBe(14); // base time - expect(call[4]).toBe(5); // start time - expect(call[5]).toBe(19); // commit time - - callback.mockClear(); - - advanceTimeBy(100); // 19 -> 119 - - // Render a partially update, but don't finish. - // This partial render will take 10ms of actual render time. - first.setState({renderTime: 10}); - expect(renderer).toFlushThrough(['FirstComponent:10']); - expect(callback).toHaveBeenCalledTimes(0); - - // Simulate time moving forward while frame is paused. - advanceTimeBy(100); // 129 -> 229 + }); + expect(ReactTestRenderer).toClearYields(['Yield:5']); + + // The initial work was thrown away in this case, + // So the actual and base times should only include the final rendered tree times. + expect(callback).toHaveBeenCalledTimes(1); + let call = callback.mock.calls[0]; + expect(call[2]).toBe(5); // actual time + expect(call[3]).toBe(5); // base time + expect(call[4]).toBe(115); // start time + expect(call[5]).toBe(120); // commit time + + callback.mockReset(); + + // Verify no more unexpected callbacks from low priority work + expect(renderer).toFlushAll([]); + expect(callback).toHaveBeenCalledTimes(0); + }); - // Interrupt with higher priority work. - // This simulates a total of 37ms of actual render time. - renderer.unstable_flushSync(() => second.setState({renderTime: 30})); - expect(ReactTestRenderer).toClearYields([ - 'SecondComponent:30', - 'Yield:7', - ]); + it('should report the expected times when a high-priority update replaces a low-priority update', () => { + const callback = jest.fn(); - // The actual time should include only the most recent render (37ms), - // Because this greatly simplifies the commit phase logic. - // The base time should include the more recent times for the SecondComponent subtree, - // As well as the original times for the FirstComponent subtree. - expect(callback).toHaveBeenCalledTimes(1); - call = callback.mock.calls[0]; - expect(call[2]).toBe(37); // actual time - expect(call[3]).toBe(42); // base time - expect(call[4]).toBe(229); // start time - expect(call[5]).toBe(266); // commit time + const Yield = ({renderTime}) => { + advanceTimeBy(renderTime); + ReactTestRenderer.unstable_yield('Yield:' + renderTime); + return null; + }; - callback.mockClear(); + advanceTimeBy(5); // 0 -> 5 - // Simulate time moving forward while frame is paused. - advanceTimeBy(100); // 266 -> 366 + const renderer = ReactTestRenderer.create( + + + + , + {unstable_isAsync: true}, + ); - // Resume the original low priority update, with rebased state. - // This simulates a total of 14ms of actual render time, - // And does not include the original (interrupted) 10ms. - // The tree contains 42ms of base render time at this point, - // Reflecting the most recent (longer) render durations. - // TODO: This actual time should decrease by 10ms once the scheduler supports resuming. - expect(renderer).toFlushAll(['FirstComponent:10', 'Yield:4']); - expect(callback).toHaveBeenCalledTimes(1); - call = callback.mock.calls[0]; - expect(call[2]).toBe(14); // actual time - expect(call[3]).toBe(51); // base time - expect(call[4]).toBe(366); // start time - expect(call[5]).toBe(380); // commit time - }); + // Render everything initially. + // This should take 21 seconds of actual and base time. + expect(renderer).toFlushAll(['Yield:6', 'Yield:15']); + expect(callback).toHaveBeenCalledTimes(1); + let call = callback.mock.calls[0]; + expect(call[2]).toBe(21); // actual time + expect(call[3]).toBe(21); // base time + expect(call[4]).toBe(5); // start time + expect(call[5]).toBe(26); // commit time - [true, false].forEach(flagEnabled => { - describe(`replayFailedUnitOfWorkWithInvokeGuardedCallback ${ - flagEnabled ? 'enabled' : 'disabled' - }`, () => { - beforeEach(() => { - jest.resetModules(); + callback.mockReset(); - loadModules({ - replayFailedUnitOfWorkWithInvokeGuardedCallback: flagEnabled, - }); - mockNowForTests(); - }); + advanceTimeBy(30); // 26 -> 56 - it('should accumulate actual time after an error handled by componentDidCatch()', () => { - const callback = jest.fn(); + // Render a partially update, but don't finish. + // This partial render should take 3ms of simulated time. + renderer.update( + + + + + , + ); + expect(renderer).toFlushThrough(['Yield:3']); + expect(callback).toHaveBeenCalledTimes(0); - const ThrowsError = () => { - advanceTimeBy(3); - throw Error('expected error'); - }; + // Simulate time moving forward while frame is paused. + advanceTimeBy(100); // 59 -> 159 - class ErrorBoundary extends React.Component { - state = {error: null}; - componentDidCatch(error) { - this.setState({error}); - } - render() { - advanceTimeBy(2); - return this.state.error === null ? ( - this.props.children - ) : ( - - ); - } - } + // Render another 5ms of simulated time. + expect(renderer).toFlushThrough(['Yield:5']); + expect(callback).toHaveBeenCalledTimes(0); - advanceTimeBy(5); // 0 -> 5 + // Simulate time moving forward while frame is paused. + advanceTimeBy(100); // 164 -> 264 - ReactTestRenderer.create( + // Interrupt with higher priority work. + // The interrupted work simulates an additional 11ms of time. + renderer.unstable_flushSync(() => { + renderer.update( - - - - + , ); - - expect(callback).toHaveBeenCalledTimes(2); - - // Callbacks bubble (reverse order). - let [mountCall, updateCall] = callback.mock.calls; - - // The initial mount only includes the ErrorBoundary (which takes 2) - // But it spends time rendering all of the failed subtree also. - expect(mountCall[1]).toBe('mount'); - // actual time includes: 2 (ErrorBoundary) + 9 (AdvanceTime) + 3 (ThrowsError) - // We don't count the time spent in replaying the failed unit of work (ThrowsError) - expect(mountCall[2]).toBe(14); - // base time includes: 2 (ErrorBoundary) - expect(mountCall[3]).toBe(2); - // start time - expect(mountCall[4]).toBe(5); - // commit time: 5 initially + 14 of work - // Add an additional 3 (ThrowsError) if we replaced the failed work - expect(mountCall[5]).toBe(__DEV__ && flagEnabled ? 22 : 19); - - // The update includes the ErrorBoundary and its fallback child - expect(updateCall[1]).toBe('update'); - // actual time includes: 2 (ErrorBoundary) + 20 (AdvanceTime) - expect(updateCall[2]).toBe(22); - // base time includes: 2 (ErrorBoundary) + 20 (AdvanceTime) - expect(updateCall[3]).toBe(22); - // start time - expect(updateCall[4]).toBe(__DEV__ && flagEnabled ? 22 : 19); - // commit time: 19 (startTime) + 2 (ErrorBoundary) + 20 (AdvanceTime) - // Add an additional 3 (ThrowsError) if we replaced the failed work - expect(updateCall[5]).toBe(__DEV__ && flagEnabled ? 44 : 41); }); + expect(ReactTestRenderer).toClearYields(['Yield:11']); + + // The actual time should include only the most recent render, + // Because this lets us avoid a lot of commit phase reset complexity. + // The base time includes only the final rendered tree times. + expect(callback).toHaveBeenCalledTimes(1); + call = callback.mock.calls[0]; + expect(call[2]).toBe(11); // actual time + expect(call[3]).toBe(11); // base time + expect(call[4]).toBe(264); // start time + expect(call[5]).toBe(275); // commit time + + // Verify no more unexpected callbacks from low priority work + expect(renderer).toFlushAll([]); + expect(callback).toHaveBeenCalledTimes(1); + }); - it('should accumulate actual time after an error handled by getDerivedStateFromCatch()', () => { - const callback = jest.fn(); + it('should report the expected times when a high-priority update interrupts a low-priority update', () => { + const callback = jest.fn(); - const ThrowsError = () => { - advanceTimeBy(10); - throw Error('expected error'); - }; + const Yield = ({renderTime}) => { + advanceTimeBy(renderTime); + ReactTestRenderer.unstable_yield('Yield:' + renderTime); + return null; + }; - class ErrorBoundary extends React.Component { - state = {error: null}; - static getDerivedStateFromCatch(error) { - return {error}; - } - render() { - advanceTimeBy(2); - return this.state.error === null ? ( - this.props.children - ) : ( - - ); - } + let first; + class FirstComponent extends React.Component { + state = {renderTime: 1}; + render() { + first = this; + advanceTimeBy(this.state.renderTime); + ReactTestRenderer.unstable_yield( + 'FirstComponent:' + this.state.renderTime, + ); + return ; + } + } + let second; + class SecondComponent extends React.Component { + state = {renderTime: 2}; + render() { + second = this; + advanceTimeBy(this.state.renderTime); + ReactTestRenderer.unstable_yield( + 'SecondComponent:' + this.state.renderTime, + ); + return ; } + } - advanceTimeBy(5); // 0 -> 5 + advanceTimeBy(5); // 0 -> 5 - ReactTestRenderer.create( - - - - - - , - ); + const renderer = ReactTestRenderer.create( + + + + , + {unstable_isAsync: true}, + ); - expect(callback).toHaveBeenCalledTimes(1); - - // Callbacks bubble (reverse order). - let [mountCall] = callback.mock.calls; - - // The initial mount includes the ErrorBoundary's error state, - // But i also spends actual time rendering UI that fails and isn't included. - expect(mountCall[1]).toBe('mount'); - // actual time includes: 2 (ErrorBoundary) + 5 (AdvanceTime) + 10 (ThrowsError) - // Then the re-render: 2 (ErrorBoundary) + 20 (AdvanceTime) - // We don't count the time spent in replaying the failed unit of work (ThrowsError) - expect(mountCall[2]).toBe(39); - // base time includes: 2 (ErrorBoundary) + 20 (AdvanceTime) - expect(mountCall[3]).toBe(22); - // start time - expect(mountCall[4]).toBe(5); - // commit time - expect(mountCall[5]).toBe(__DEV__ && flagEnabled ? 54 : 44); - }); + // Render everything initially. + // This simulates a total of 14ms of actual render time. + // The base render time is also 14ms for the initial render. + expect(renderer).toFlushAll([ + 'FirstComponent:1', + 'Yield:4', + 'SecondComponent:2', + 'Yield:7', + ]); + expect(callback).toHaveBeenCalledTimes(1); + let call = callback.mock.calls[0]; + expect(call[2]).toBe(14); // actual time + expect(call[3]).toBe(14); // base time + expect(call[4]).toBe(5); // start time + expect(call[5]).toBe(19); // commit time + + callback.mockClear(); + + advanceTimeBy(100); // 19 -> 119 + + // Render a partially update, but don't finish. + // This partial render will take 10ms of actual render time. + first.setState({renderTime: 10}); + expect(renderer).toFlushThrough(['FirstComponent:10']); + expect(callback).toHaveBeenCalledTimes(0); + + // Simulate time moving forward while frame is paused. + advanceTimeBy(100); // 129 -> 229 + + // Interrupt with higher priority work. + // This simulates a total of 37ms of actual render time. + renderer.unstable_flushSync(() => second.setState({renderTime: 30})); + expect(ReactTestRenderer).toClearYields([ + 'SecondComponent:30', + 'Yield:7', + ]); + + // The actual time should include only the most recent render (37ms), + // Because this greatly simplifies the commit phase logic. + // The base time should include the more recent times for the SecondComponent subtree, + // As well as the original times for the FirstComponent subtree. + expect(callback).toHaveBeenCalledTimes(1); + call = callback.mock.calls[0]; + expect(call[2]).toBe(37); // actual time + expect(call[3]).toBe(42); // base time + expect(call[4]).toBe(229); // start time + expect(call[5]).toBe(266); // commit time + + callback.mockClear(); + + // Simulate time moving forward while frame is paused. + advanceTimeBy(100); // 266 -> 366 + + // Resume the original low priority update, with rebased state. + // This simulates a total of 14ms of actual render time, + // And does not include the original (interrupted) 10ms. + // The tree contains 42ms of base render time at this point, + // Reflecting the most recent (longer) render durations. + // TODO: This actual time should decrease by 10ms once the scheduler supports resuming. + expect(renderer).toFlushAll(['FirstComponent:10', 'Yield:4']); + expect(callback).toHaveBeenCalledTimes(1); + call = callback.mock.calls[0]; + expect(call[2]).toBe(14); // actual time + expect(call[3]).toBe(51); // base time + expect(call[4]).toBe(366); // start time + expect(call[5]).toBe(380); // commit time + }); - it('should reset the fiber stack correct after a "complete" phase error', () => { - jest.resetModules(); + [true, false].forEach( + replayFailedUnitOfWorkWithInvokeGuardedCallback => { + describe(`replayFailedUnitOfWorkWithInvokeGuardedCallback ${ + replayFailedUnitOfWorkWithInvokeGuardedCallback + ? 'enabled' + : 'disabled' + }`, () => { + beforeEach(() => { + jest.resetModules(); + + loadModules({ + replayFailedUnitOfWorkWithInvokeGuardedCallback, + }); + }); + + it('should accumulate actual time after an error handled by componentDidCatch()', () => { + const callback = jest.fn(); + + const ThrowsError = () => { + advanceTimeBy(3); + throw Error('expected error'); + }; + + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + advanceTimeBy(2); + return this.state.error === null ? ( + this.props.children + ) : ( + + ); + } + } + + advanceTimeBy(5); // 0 -> 5 + + ReactTestRenderer.create( + + + + + + , + ); - loadModules({ - useNoopRenderer: true, - replayFailedUnitOfWorkWithInvokeGuardedCallback: flagEnabled, - }); + expect(callback).toHaveBeenCalledTimes(2); + + // Callbacks bubble (reverse order). + let [mountCall, updateCall] = callback.mock.calls; + + // The initial mount only includes the ErrorBoundary (which takes 2) + // But it spends time rendering all of the failed subtree also. + expect(mountCall[1]).toBe('mount'); + // actual time includes: 2 (ErrorBoundary) + 9 (AdvanceTime) + 3 (ThrowsError) + // We don't count the time spent in replaying the failed unit of work (ThrowsError) + expect(mountCall[2]).toBe(14); + // base time includes: 2 (ErrorBoundary) + // Since the tree is empty for the initial commit + expect(mountCall[3]).toBe(2); + // start time + expect(mountCall[4]).toBe(5); + // commit time: 5 initially + 14 of work + // Add an additional 3 (ThrowsError) if we replayed the failed work + expect(mountCall[5]).toBe( + __DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback + ? 22 + : 19, + ); - // Simulate a renderer error during the "complete" phase. - // This mimics behavior like React Native's View/Text nesting validation. - ReactNoop.render( - - hi - , - ); - expect(ReactNoop.flush).toThrow('Error in host config.'); - - // A similar case we've seen caused by an invariant in ReactDOM. - // It didn't reproduce without a host component inside. - ReactNoop.render( - - - hi - - , - ); - expect(ReactNoop.flush).toThrow('Error in host config.'); + // The update includes the ErrorBoundary and its fallback child + expect(updateCall[1]).toBe('update'); + // actual time includes: 2 (ErrorBoundary) + 20 (AdvanceTime) + expect(updateCall[2]).toBe(22); + // base time includes: 2 (ErrorBoundary) + 20 (AdvanceTime) + expect(updateCall[3]).toBe(22); + // start time + expect(updateCall[4]).toBe( + __DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback + ? 22 + : 19, + ); + // commit time: 19 (startTime) + 2 (ErrorBoundary) + 20 (AdvanceTime) + // Add an additional 3 (ThrowsError) if we replayed the failed work + expect(updateCall[5]).toBe( + __DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback + ? 44 + : 41, + ); + }); + + it('should accumulate actual time after an error handled by getDerivedStateFromCatch()', () => { + const callback = jest.fn(); + + const ThrowsError = () => { + advanceTimeBy(10); + throw Error('expected error'); + }; + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromCatch(error) { + return {error}; + } + render() { + advanceTimeBy(2); + return this.state.error === null ? ( + this.props.children + ) : ( + + ); + } + } + + advanceTimeBy(5); // 0 -> 5 + + ReactTestRenderer.create( + + + + + + , + ); - // So long as the profiler timer's fiber stack is reset correctly, - // Subsequent renders should not error. - ReactNoop.render( - - hi - , - ); - ReactNoop.flush(); - }); - }); + expect(callback).toHaveBeenCalledTimes(1); + + // Callbacks bubble (reverse order). + let [mountCall] = callback.mock.calls; + + // The initial mount includes the ErrorBoundary's error state, + // But it also spends actual time rendering UI that fails and isn't included. + expect(mountCall[1]).toBe('mount'); + // actual time includes: 2 (ErrorBoundary) + 5 (AdvanceTime) + 10 (ThrowsError) + // Then the re-render: 2 (ErrorBoundary) + 20 (AdvanceTime) + // We don't count the time spent in replaying the failed unit of work (ThrowsError) + expect(mountCall[2]).toBe(39); + // base time includes: 2 (ErrorBoundary) + 20 (AdvanceTime) + expect(mountCall[3]).toBe(22); + // start time + expect(mountCall[4]).toBe(5); + // commit time + expect(mountCall[5]).toBe( + __DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback + ? 54 + : 44, + ); + }); + + it('should reset the fiber stack correct after a "complete" phase error', () => { + jest.resetModules(); + + loadModules({ + useNoopRenderer: true, + replayFailedUnitOfWorkWithInvokeGuardedCallback, + }); + + // Simulate a renderer error during the "complete" phase. + // This mimics behavior like React Native's View/Text nesting validation. + ReactNoop.render( + + hi + , + ); + expect(ReactNoop.flush).toThrow('Error in host config.'); + + // A similar case we've seen caused by an invariant in ReactDOM. + // It didn't reproduce without a host component inside. + ReactNoop.render( + + + hi + + , + ); + expect(ReactNoop.flush).toThrow('Error in host config.'); + + // So long as the profiler timer's fiber stack is reset correctly, + // Subsequent renders should not error. + ReactNoop.render( + + hi + , + ); + ReactNoop.flush(); + }); + }); + }, + ); }); - }); - it('reflects the most recently rendered id value', () => { - const callback = jest.fn(); + it('reflects the most recently rendered id value', () => { + const callback = jest.fn(); - advanceTimeBy(5); // 0 -> 5 + advanceTimeBy(5); // 0 -> 5 - const renderer = ReactTestRenderer.create( - - - , - ); + const renderer = ReactTestRenderer.create( + + + , + ); - expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); - advanceTimeBy(20); // 7 -> 27 + advanceTimeBy(20); // 7 -> 27 - renderer.update( - - - , - ); + renderer.update( + + + , + ); - expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledTimes(2); - const [mountCall, updateCall] = callback.mock.calls; + const [mountCall, updateCall] = callback.mock.calls; - expect(mountCall[0]).toBe('one'); - expect(mountCall[1]).toBe('mount'); - expect(mountCall[2]).toBe(2); // actual time - expect(mountCall[3]).toBe(2); // base time - expect(mountCall[4]).toBe(5); // start time + expect(mountCall[0]).toBe('one'); + expect(mountCall[1]).toBe('mount'); + expect(mountCall[2]).toBe(2); // actual time + expect(mountCall[3]).toBe(2); // base time + expect(mountCall[4]).toBe(5); // start time - expect(updateCall[0]).toBe('two'); - expect(updateCall[1]).toBe('update'); - expect(updateCall[2]).toBe(1); // actual time - expect(updateCall[3]).toBe(1); // base time - expect(updateCall[4]).toBe(27); // start time + expect(updateCall[0]).toBe('two'); + expect(updateCall[1]).toBe('update'); + expect(updateCall[2]).toBe(1); // actual time + expect(updateCall[3]).toBe(1); // base time + expect(updateCall[4]).toBe(27); // start time + }); }); }); @@ -1110,4 +1156,1045 @@ describe('Profiler', () => { expect(ReactNoop.getRoot('two').current.actualDuration).toBe(14); }); + + describe('interaction tracking', () => { + let onInteractionScheduledWorkCompleted; + let onInteractionTracked; + let onWorkCanceled; + let onWorkScheduled; + let onWorkStarted; + let onWorkStopped; + let throwInOnInteractionScheduledWorkCompleted; + let throwInOnWorkScheduled; + let throwInOnWorkStarted; + let throwInOnWorkStopped; + + const getWorkForReactThreads = mockFn => + mockFn.mock.calls.filter(([interactions, threadID]) => threadID > 0); + + beforeEach(() => { + jest.resetModules(); + + loadModules({ + enableInteractionTracking: true, + }); + + throwInOnInteractionScheduledWorkCompleted = false; + throwInOnWorkScheduled = false; + throwInOnWorkStarted = false; + throwInOnWorkStopped = false; + + onInteractionScheduledWorkCompleted = jest.fn(() => { + if (throwInOnInteractionScheduledWorkCompleted) { + throw Error('Expected error onInteractionScheduledWorkCompleted'); + } + }); + onInteractionTracked = jest.fn(); + onWorkCanceled = jest.fn(); + onWorkScheduled = jest.fn(() => { + if (throwInOnWorkScheduled) { + throw Error('Expected error onWorkScheduled'); + } + }); + onWorkStarted = jest.fn(() => { + if (throwInOnWorkStarted) { + throw Error('Expected error onWorkStarted'); + } + }); + onWorkStopped = jest.fn(() => { + if (throwInOnWorkStopped) { + throw Error('Expected error onWorkStopped'); + } + }); + + // Verify interaction subscriber methods are called as expected. + InteractionTrackingSubscriptions.subscribe({ + onInteractionScheduledWorkCompleted, + onInteractionTracked, + onWorkCanceled, + onWorkScheduled, + onWorkStarted, + onWorkStopped, + }); + }); + + describe('error handling', () => { + it('should cover errors thrown in onWorkScheduled', () => { + function Component({children}) { + ReactTestRenderer.unstable_yield('Component:' + children); + return children; + } + + let renderer; + + // Errors that happen inside of a subscriber should throw, + throwInOnWorkScheduled = true; + expect(() => { + InteractionTracking.track('event', mockNow(), () => { + renderer = ReactTestRenderer.create(fail, { + unstable_isAsync: true, + }); + }); + }).toThrow('Expected error onWorkScheduled'); + throwInOnWorkScheduled = false; + expect(onWorkScheduled).toHaveBeenCalled(); + + // But should not leave React in a broken state for subsequent renders. + renderer = ReactTestRenderer.create(succeed, { + unstable_isAsync: true, + }); + expect(renderer).toFlushAll(['Component:succeed']); + const tree = renderer.toTree(); + expect(tree.type).toBe(Component); + expect(tree.props.children).toBe('succeed'); + }); + + it('should cover errors thrown in onWorkStarted', () => { + function Component({children}) { + ReactTestRenderer.unstable_yield('Component:' + children); + return children; + } + + let renderer; + InteractionTracking.track('event', mockNow(), () => { + renderer = ReactTestRenderer.create(text, { + unstable_isAsync: true, + }); + }); + onWorkStarted.mockClear(); + + // Errors that happen inside of a subscriber should throw, + throwInOnWorkStarted = true; + expect(() => { + expect(renderer).toFlushAll(['Component:text']); + }).toThrow('Expected error onWorkStarted'); + throwInOnWorkStarted = false; + expect(onWorkStarted).toHaveBeenCalled(); + + // But the React work should have still been processed. + expect(renderer).toFlushAll([]); + const tree = renderer.toTree(); + expect(tree.type).toBe(Component); + expect(tree.props.children).toBe('text'); + }); + + it('should cover errors thrown in onWorkStopped', () => { + function Component({children}) { + ReactTestRenderer.unstable_yield('Component:' + children); + return children; + } + + let renderer; + InteractionTracking.track('event', mockNow(), () => { + renderer = ReactTestRenderer.create(text, { + unstable_isAsync: true, + }); + }); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + // Errors that happen in an on-stopped callback, + throwInOnWorkStopped = true; + expect(() => { + renderer.unstable_flushAll(['Component:text']); + }).toThrow('Expected error onWorkStopped'); + throwInOnWorkStopped = false; + expect(onWorkStopped).toHaveBeenCalledTimes(2); + + // Should still commit the update, + const tree = renderer.toTree(); + expect(tree.type).toBe(Component); + expect(tree.props.children).toBe('text'); + + // And still call onInteractionScheduledWorkCompleted if the interaction is finished. + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + }); + + it('should cover errors thrown in onInteractionScheduledWorkCompleted', () => { + function Component({children}) { + ReactTestRenderer.unstable_yield('Component:' + children); + return children; + } + + const eventOne = { + id: 0, + name: 'event one', + timestamp: mockNow(), + }; + const eventTwo = { + id: 1, + name: 'event two', + timestamp: mockNow(), + }; + + let renderer; + InteractionTracking.track(eventOne.name, mockNow(), () => { + InteractionTracking.track(eventTwo.name, mockNow(), () => { + renderer = ReactTestRenderer.create(text, { + unstable_isAsync: true, + }); + }); + }); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + throwInOnInteractionScheduledWorkCompleted = true; + expect(() => { + renderer.unstable_flushAll(['Component:text']); + }).toThrow('Expected error onInteractionScheduledWorkCompleted'); + + // Even though an error is thrown for one completed interaction, + // The completed callback should be called for all completed interactions. + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2); + }); + }); + + it('should associate tracked events with their subsequent commits', () => { + let instance = null; + + const Yield = ({duration = 10, value}) => { + advanceTimeBy(duration); + ReactTestRenderer.unstable_yield(value); + return null; + }; + + class Example extends React.Component { + state = { + count: 0, + }; + render() { + instance = this; + return ( + + + {this.state.count} + + + ); + } + } + + advanceTimeBy(1); + + const interactionCreation = { + id: 0, + name: 'creation event', + timestamp: mockNow(), + }; + + const onRender = jest.fn(); + let renderer; + InteractionTracking.track(interactionCreation.name, mockNow(), () => { + renderer = ReactTestRenderer.create( + + + , + { + unstable_isAsync: true, + }, + ); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interactionCreation, + ); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + // The interaction-tracking package will notify of work started for the default thread, + // But React shouldn't notify until it's been flushed. + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(0); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + // Work may have been scheduled multiple times. + // We only care that the subscriber was notified at least once. + // As for the thread ID- the actual value isn't important, only that there was one. + expect(onWorkScheduled).toHaveBeenCalled(); + expect(onWorkScheduled.mock.calls[0][0]).toMatchInteractions([ + interactionCreation, + ]); + expect(onWorkScheduled.mock.calls[0][1] > 0).toBe(true); + + // Mount + renderer.unstable_flushAll(['first', 'last']); + expect(onRender).toHaveBeenCalledTimes(1); + let call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test-profiler'); + expect(call[5]).toEqual(mockNow()); + if (ReactFeatureFlags.enableInteractionTracking) { + expect(call[6]).toMatchInteractions([interactionCreation]); + } + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interactionCreation); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStarted)[0][0]).toMatchInteractions([ + interactionCreation, + ]); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStopped)[0][0]).toMatchInteractions([ + interactionCreation, + ]); + + onRender.mockClear(); + onWorkScheduled.mockClear(); + onWorkStarted.mockClear(); + onWorkStopped.mockClear(); + + advanceTimeBy(3); + + let didRunCallback = false; + + const interactionOne = { + id: 1, + name: 'initial event', + timestamp: mockNow(), + }; + InteractionTracking.track(interactionOne.name, mockNow(), () => { + instance.setState({count: 1}); + + // Update state again to verify our tracked interaction isn't registered twice + instance.setState({count: 2}); + + // The interaction-tracking package will notify of work started for the default thread, + // But React shouldn't notify until it's been flushed. + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(0); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + // Work may have been scheduled multiple times. + // We only care that the subscriber was notified at least once. + // As for the thread ID- the actual value isn't important, only that there was one. + expect(onWorkScheduled).toHaveBeenCalled(); + expect(onWorkScheduled.mock.calls[0][0]).toMatchInteractions([ + interactionOne, + ]); + expect(onWorkScheduled.mock.calls[0][1] > 0).toBe(true); + + expect(renderer).toFlushThrough(['first']); + expect(onRender).not.toHaveBeenCalled(); + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interactionOne, + ); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStarted)[0][0]).toMatchInteractions( + [interactionOne], + ); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + renderer.unstable_flushAll(['last']); + expect(onRender).toHaveBeenCalledTimes(1); + + call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test-profiler'); + expect(call[5]).toEqual(mockNow()); + if (ReactFeatureFlags.enableInteractionTracking) { + expect(call[6]).toMatchInteractions([interactionOne]); + } + + didRunCallback = true; + + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStarted)[0][0]).toMatchInteractions( + [interactionOne], + ); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStopped)[0][0]).toMatchInteractions( + [interactionOne], + ); + }); + + expect(didRunCallback).toBe(true); + + onRender.mockClear(); + onWorkScheduled.mockClear(); + onWorkStarted.mockClear(); + onWorkStopped.mockClear(); + + advanceTimeBy(17); + + // Verify that updating state again does not re-log our interaction. + instance.setState({count: 3}); + renderer.unstable_flushAll(['first', 'last']); + + expect(onRender).toHaveBeenCalledTimes(1); + call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test-profiler'); + expect(call[5]).toEqual(mockNow()); + if (ReactFeatureFlags.enableInteractionTracking) { + expect(call[6]).toMatchInteractions([]); + } + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interactionOne); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(0); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + onRender.mockClear(); + + advanceTimeBy(3); + + // Verify that root updates are also associated with tracked events. + const interactionTwo = { + id: 2, + name: 'root update event', + timestamp: mockNow(), + }; + InteractionTracking.track(interactionTwo.name, mockNow(), () => { + renderer.update( + + + , + ); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(3); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interactionTwo, + ); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2); + + // The interaction-tracking package will notify of work started for the default thread, + // But React shouldn't notify until it's been flushed. + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(0); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + // Work may have been scheduled multiple times. + // We only care that the subscriber was notified at least once. + // As for the thread ID- the actual value isn't important, only that there was one. + expect(onWorkScheduled).toHaveBeenCalled(); + expect(onWorkScheduled.mock.calls[0][0]).toMatchInteractions([ + interactionTwo, + ]); + expect(onWorkScheduled.mock.calls[0][1] > 0).toBe(true); + + renderer.unstable_flushAll(['first', 'last']); + + expect(onRender).toHaveBeenCalledTimes(1); + call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test-profiler'); + expect(call[5]).toEqual(mockNow()); + if (ReactFeatureFlags.enableInteractionTracking) { + expect(call[6]).toMatchInteractions([interactionTwo]); + } + + expect(onInteractionTracked).toHaveBeenCalledTimes(3); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(3); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interactionTwo); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStarted)[0][0]).toMatchInteractions([ + interactionTwo, + ]); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStopped)[0][0]).toMatchInteractions([ + interactionTwo, + ]); + }); + + it('should report the expected times when a high-priority update interrupts a low-priority update', () => { + const onRender = jest.fn(); + + let first; + class FirstComponent extends React.Component { + state = {count: 0}; + render() { + first = this; + ReactTestRenderer.unstable_yield('FirstComponent'); + return null; + } + } + let second; + class SecondComponent extends React.Component { + state = {count: 0}; + render() { + second = this; + ReactTestRenderer.unstable_yield('SecondComponent'); + return null; + } + } + + advanceTimeBy(5); + + const renderer = ReactTestRenderer.create( + + + + , + {unstable_isAsync: true}, + ); + + // Initial mount. + renderer.unstable_flushAll(['FirstComponent', 'SecondComponent']); + + expect(onInteractionTracked).not.toHaveBeenCalled(); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + onRender.mockClear(); + + advanceTimeBy(100); + + const interactionLowPri = { + id: 0, + name: 'lowPri', + timestamp: mockNow(), + }; + + InteractionTracking.track(interactionLowPri.name, mockNow(), () => { + // Render a partially update, but don't finish. + first.setState({count: 1}); + + expect(onWorkScheduled).toHaveBeenCalled(); + expect(onWorkScheduled.mock.calls[0][0]).toMatchInteractions([ + interactionLowPri, + ]); + + expect(renderer).toFlushThrough(['FirstComponent']); + expect(onRender).not.toHaveBeenCalled(); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interactionLowPri, + ); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStarted)[0][0]).toMatchInteractions( + [interactionLowPri], + ); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + advanceTimeBy(100); + + const interactionHighPri = { + id: 1, + name: 'highPri', + timestamp: mockNow(), + }; + + // Interrupt with higher priority work. + // This simulates a total of 37ms of actual render time. + renderer.unstable_flushSync(() => { + InteractionTracking.track(interactionHighPri.name, mockNow(), () => { + second.setState({count: 1}); + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interactionHighPri, + ); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + }); + }); + expect(ReactTestRenderer).toClearYields(['SecondComponent']); + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interactionHighPri); + + // Verify the high priority update was associated with the high priority event. + expect(onRender).toHaveBeenCalledTimes(1); + let call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test'); + expect(call[5]).toEqual(mockNow()); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking + ? [interactionLowPri, interactionHighPri] + : [], + ); + + onRender.mockClear(); + + advanceTimeBy(100); + + // Resume the original low priority update, with rebased state. + // Verify the low priority update was retained. + renderer.unstable_flushAll(['FirstComponent']); + expect(onRender).toHaveBeenCalledTimes(1); + call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test'); + expect(call[5]).toEqual(mockNow()); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking + ? [interactionLowPri] + : [], + ); + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + + // Work might be started multiple times before being completed. + // This is okay; it's part of the interaction-tracking subscriber contract. + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(3); + expect(getWorkForReactThreads(onWorkStarted)[1][0]).toMatchInteractions( + [interactionLowPri, interactionHighPri], + ); + expect(getWorkForReactThreads(onWorkStarted)[2][0]).toMatchInteractions( + [interactionLowPri], + ); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(2); + expect(getWorkForReactThreads(onWorkStopped)[0][0]).toMatchInteractions( + [interactionLowPri, interactionHighPri], + ); + expect(getWorkForReactThreads(onWorkStopped)[1][0]).toMatchInteractions( + [interactionLowPri], + ); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interactionLowPri); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(3); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(2); + }); + + it('should track work spawned by a commit phase lifecycle and setState callback', () => { + let instance; + class Example extends React.Component { + state = { + count: 0, + }; + componentDidMount() { + advanceTimeBy(10); // Advance timer to keep commits separate + this.setState({count: 1}); // Intentional cascading update + } + componentDidUpdate(prevProps, prevState) { + if (this.state.count === 2 && prevState.count === 1) { + advanceTimeBy(10); // Advance timer to keep commits separate + this.setState({count: 3}); // Intentional cascading update + } + } + render() { + instance = this; + ReactTestRenderer.unstable_yield('Example:' + this.state.count); + return null; + } + } + + const interactionOne = { + id: 0, + name: 'componentDidMount test', + timestamp: mockNow(), + }; + + // Initial mount. + const onRender = jest.fn(); + let firstCommitTime = mockNow(); + let renderer; + InteractionTracking.track(interactionOne.name, mockNow(), () => { + renderer = ReactTestRenderer.create( + + + , + {unstable_isAsync: true}, + ); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interactionOne, + ); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(0); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + renderer.unstable_flushAll(['Example:0', 'Example:1']); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interactionOne); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(2); + expect(getWorkForReactThreads(onWorkStarted)[0][0]).toMatchInteractions([ + interactionOne, + ]); + expect(getWorkForReactThreads(onWorkStarted)[1][0]).toMatchInteractions([ + interactionOne, + ]); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(2); + expect(getWorkForReactThreads(onWorkStopped)[0][0]).toMatchInteractions([ + interactionOne, + ]); + expect(getWorkForReactThreads(onWorkStopped)[1][0]).toMatchInteractions([ + interactionOne, + ]); + + expect(onRender).toHaveBeenCalledTimes(2); + let call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test'); + expect(call[5]).toEqual(firstCommitTime); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interactionOne] : [], + ); + call = onRender.mock.calls[1]; + expect(call[0]).toEqual('test'); + expect(call[5]).toEqual(mockNow()); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interactionOne] : [], + ); + + onRender.mockClear(); + + const interactionTwo = { + id: 1, + name: 'componentDidUpdate test', + timestamp: mockNow(), + }; + + // Cause an tracked, async update + InteractionTracking.track(interactionTwo.name, mockNow(), () => { + instance.setState({count: 2}); + }); + expect(onRender).not.toHaveBeenCalled(); + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interactionTwo, + ); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(2); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(2); + + advanceTimeBy(5); + + // Flush async work (outside of tracked scope) + // This will cause an intentional cascading update from did-update + firstCommitTime = mockNow(); + renderer.unstable_flushAll(['Example:2', 'Example:3']); + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interactionTwo); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(4); + expect(getWorkForReactThreads(onWorkStarted)[2][0]).toMatchInteractions([ + interactionTwo, + ]); + expect(getWorkForReactThreads(onWorkStarted)[3][0]).toMatchInteractions([ + interactionTwo, + ]); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(4); + expect(getWorkForReactThreads(onWorkStopped)[2][0]).toMatchInteractions([ + interactionTwo, + ]); + expect(getWorkForReactThreads(onWorkStopped)[3][0]).toMatchInteractions([ + interactionTwo, + ]); + + // Verify the cascading commit is associated with the origin event + expect(onRender).toHaveBeenCalledTimes(2); + call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test'); + expect(call[5]).toEqual(firstCommitTime); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interactionTwo] : [], + ); + call = onRender.mock.calls[1]; + expect(call[0]).toEqual('test'); + expect(call[5]).toEqual(mockNow()); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interactionTwo] : [], + ); + + onRender.mockClear(); + + const interactionThree = { + id: 2, + name: 'setState callback test', + timestamp: mockNow(), + }; + + // Cause a cascading update from the setState callback + function callback() { + instance.setState({count: 6}); + } + InteractionTracking.track(interactionThree.name, mockNow(), () => { + instance.setState({count: 5}, callback); + }); + expect(onRender).not.toHaveBeenCalled(); + + expect(onInteractionTracked).toHaveBeenCalledTimes(3); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interactionThree, + ); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(4); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(4); + + // Flush async work (outside of tracked scope) + // This will cause an intentional cascading update from the setState callback + firstCommitTime = mockNow(); + renderer.unstable_flushAll(['Example:5', 'Example:6']); + + expect(onInteractionTracked).toHaveBeenCalledTimes(3); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(3); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interactionThree); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(6); + expect(getWorkForReactThreads(onWorkStarted)[4][0]).toMatchInteractions([ + interactionThree, + ]); + expect(getWorkForReactThreads(onWorkStarted)[5][0]).toMatchInteractions([ + interactionThree, + ]); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(6); + expect(getWorkForReactThreads(onWorkStopped)[4][0]).toMatchInteractions([ + interactionThree, + ]); + expect(getWorkForReactThreads(onWorkStopped)[5][0]).toMatchInteractions([ + interactionThree, + ]); + + // Verify the cascading commit is associated with the origin event + expect(onRender).toHaveBeenCalledTimes(2); + call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test'); + expect(call[5]).toEqual(firstCommitTime); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interactionThree] : [], + ); + call = onRender.mock.calls[1]; + expect(call[0]).toEqual('test'); + expect(call[5]).toEqual(mockNow()); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interactionThree] : [], + ); + }); + + it('should track interactions associated with a parent component state update', () => { + const onRender = jest.fn(); + let parentInstance = null; + + class Child extends React.Component { + render() { + ReactTestRenderer.unstable_yield('Child:', this.props.count); + return null; + } + } + + class Parent extends React.Component { + state = { + count: 0, + }; + render() { + parentInstance = this; + return ( + + + + ); + } + } + + advanceTimeBy(1); + + const renderer = ReactTestRenderer.create(, { + unstable_isAsync: true, + }); + renderer.unstable_flushAll(['Child:0']); + onRender.mockClear(); + + const interaction = { + id: 0, + name: 'parent interaction', + timestamp: mockNow(), + }; + + InteractionTracking.track(interaction.name, mockNow(), () => { + parentInstance.setState({count: 1}); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interaction, + ); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(0); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + expect(onRender).not.toHaveBeenCalled(); + renderer.unstable_flushAll(['Child:1']); + expect(onRender).toHaveBeenCalledTimes(1); + let call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test-profiler'); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interaction] : [], + ); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interaction); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStarted)[0][0]).toMatchInteractions([ + interaction, + ]); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(1); + expect(getWorkForReactThreads(onWorkStopped)[0][0]).toMatchInteractions([ + interaction, + ]); + }); + + it('tracks both the temporary placeholder and the finished render for an interaction', async () => { + jest.resetModules(); + + loadModules({ + useNoopRenderer: true, + enableSuspense: true, + enableInteractionTracking: true, + }); + + // Re-register since we've reloaded modules + InteractionTrackingSubscriptions.subscribe({ + onInteractionScheduledWorkCompleted, + onInteractionTracked, + onWorkCanceled, + onWorkScheduled, + onWorkStarted, + onWorkStopped, + }); + + function awaitableAdvanceTimers(ms) { + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + return new Promise(resolve => { + setImmediate(resolve); + }); + } + + const SimpleCacheProvider = require('simple-cache-provider'); + let cache; + function invalidateCache() { + cache = SimpleCacheProvider.createCache(invalidateCache); + } + invalidateCache(); + const TextResource = SimpleCacheProvider.createResource( + ([text, ms = 0]) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + ReactNoop.yield(`Promise resolved [${text}]`); + resolve(text); + }, ms); + }); + }, + ([text, ms]) => text, + ); + + function Text(props) { + ReactNoop.yield(props.text); + return ; + } + + function span(prop) { + return {type: 'span', children: [], prop}; + } + + function AsyncText(props) { + const text = props.text; + try { + TextResource.read(cache, [props.text, props.ms]); + ReactNoop.yield(text); + return ; + } catch (promise) { + if (typeof promise.then === 'function') { + ReactNoop.yield(`Suspend! [${text}]`); + } else { + ReactNoop.yield(`Error! [${text}]`); + } + throw promise; + } + } + + const interaction = { + id: 0, + name: 'initial render', + timestamp: mockNow(), + }; + + const onRender = jest.fn(); + InteractionTracking.track(interaction.name, mockNow(), () => { + ReactNoop.render( + + }> + + + + , + ); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + interaction, + ); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + expect(getWorkForReactThreads(onWorkStarted)).toHaveLength(0); + expect(getWorkForReactThreads(onWorkStopped)).toHaveLength(0); + + expect(ReactNoop.flush()).toEqual([ + 'Suspend! [Async]', + 'Loading...', + 'Sync', + ]); + // The update hasn't expired yet, so we commit nothing. + expect(ReactNoop.getChildren()).toEqual([]); + expect(onRender).not.toHaveBeenCalled(); + + // Advance both React's virtual time and Jest's timers by enough to expire + // the update, but not by enough to flush the suspending promise. + ReactNoop.expire(10000); + await awaitableAdvanceTimers(10000); + // No additional rendering work is required, since we already prepared + // the placeholder. + expect(ReactNoop.flushExpired()).toEqual([]); + // Should have committed the placeholder. + expect(ReactNoop.getChildren()).toEqual([ + span('Loading...'), + span('Sync'), + ]); + expect(onRender).toHaveBeenCalledTimes(1); + + let call = onRender.mock.calls[0]; + expect(call[0]).toEqual('test-profiler'); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interaction] : [], + ); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + // Once the promise resolves, we render the suspended view + await awaitableAdvanceTimers(10000); + expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + expect(onRender).toHaveBeenCalledTimes(2); + + call = onRender.mock.calls[1]; + expect(call[0]).toEqual('test-profiler'); + expect(call[6]).toMatchInteractions( + ReactFeatureFlags.enableInteractionTracking ? [interaction] : [], + ); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(interaction); + }); + }); }); diff --git a/packages/react/src/__tests__/ReactProfilerDevToolsIntegration-test.internal.js b/packages/react/src/__tests__/ReactProfilerDevToolsIntegration-test.internal.js index bff7ad131cecb..201c9a1803c7f 100644 --- a/packages/react/src/__tests__/ReactProfilerDevToolsIntegration-test.internal.js +++ b/packages/react/src/__tests__/ReactProfilerDevToolsIntegration-test.internal.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @emails react-core + * @jest-environment node */ 'use strict'; @@ -13,22 +14,12 @@ describe('ReactProfiler DevTools integration', () => { let React; let ReactFeatureFlags; let ReactTestRenderer; + let InteractionTracking; let AdvanceTime; let advanceTimeBy; let hook; let mockNow; - const mockNowForTests = () => { - let currentTime = 0; - - mockNow = jest.fn().mockImplementation(() => currentTime); - - ReactTestRenderer.unstable_setNowImplementation(mockNow); - advanceTimeBy = amount => { - currentTime += amount; - }; - }; - beforeEach(() => { global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook = { inject: () => {}, @@ -39,12 +30,23 @@ describe('ReactProfiler DevTools integration', () => { jest.resetModules(); + let currentTime = 0; + + mockNow = jest.fn().mockImplementation(() => currentTime); + + global.Date.now = mockNow; + ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableProfilerTimer = true; + ReactFeatureFlags.enableInteractionTracking = true; React = require('react'); ReactTestRenderer = require('react-test-renderer'); + InteractionTracking = require('interaction-tracking'); - mockNowForTests(); + ReactTestRenderer.unstable_setNowImplementation(mockNow); + advanceTimeBy = amount => { + currentTime += amount; + }; AdvanceTime = class extends React.Component { static defaultProps = { @@ -85,7 +87,15 @@ describe('ReactProfiler DevTools integration', () => { // The time spent in App (above the Profiler) won't be included in the durations, // But needs to be accounted for in the offset times. expect(onRender).toHaveBeenCalledTimes(1); - expect(onRender).toHaveBeenCalledWith('Profiler', 'mount', 10, 10, 2, 12); + expect(onRender).toHaveBeenCalledWith( + 'Profiler', + 'mount', + 10, + 10, + 2, + 12, + new Set(), + ); onRender.mockClear(); // Measure unobservable timing required by the DevTools profiler. @@ -102,7 +112,15 @@ describe('ReactProfiler DevTools integration', () => { // The time spent in App (above the Profiler) won't be included in the durations, // But needs to be accounted for in the offset times. expect(onRender).toHaveBeenCalledTimes(1); - expect(onRender).toHaveBeenCalledWith('Profiler', 'update', 6, 13, 14, 20); + expect(onRender).toHaveBeenCalledWith( + 'Profiler', + 'update', + 6, + 13, + 14, + 20, + new Set(), + ); // Measure unobservable timing required by the DevTools profiler. // At this point, the base time should include both: @@ -149,4 +167,25 @@ describe('ReactProfiler DevTools integration', () => { rendered.root.findByType('div')._currentFiber().treeBaseDuration, ).toBe(7); }); + + it('should store tracked interactions on the HostNode so DevTools can access them', () => { + // Render without an interaction + const rendered = ReactTestRenderer.create(
); + + const root = rendered.root._currentFiber().return; + expect(root.stateNode.memoizedInteractions).toContainNoInteractions(); + + advanceTimeBy(10); + + const eventTime = mockNow(); + + // Render with an interaction + InteractionTracking.track('some event', eventTime, () => { + rendered.update(
); + }); + + expect(root.stateNode.memoizedInteractions).toMatchInteractions([ + {name: 'some event', timestamp: eventTime}, + ]); + }); }); diff --git a/packages/react/src/__tests__/__snapshots__/ReactProfiler-test.internal.js.snap b/packages/react/src/__tests__/__snapshots__/ReactProfiler-test.internal.js.snap index 0022e5abd342f..06e00d35930c4 100644 --- a/packages/react/src/__tests__/__snapshots__/ReactProfiler-test.internal.js.snap +++ b/packages/react/src/__tests__/__snapshots__/ReactProfiler-test.internal.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Profiler works in profiling and non-profiling bundles enableProfilerTimer disabled should render children 1`] = ` +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:disabled enableProfilerTimer:disabled should render children 1`] = `
outside span @@ -14,11 +14,11 @@ exports[`Profiler works in profiling and non-profiling bundles enableProfilerTim
`; -exports[`Profiler works in profiling and non-profiling bundles enableProfilerTimer disabled should support an empty Profiler (with no children) 1`] = `null`; +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:disabled enableProfilerTimer:disabled should support an empty Profiler (with no children) 1`] = `null`; -exports[`Profiler works in profiling and non-profiling bundles enableProfilerTimer disabled should support an empty Profiler (with no children) 2`] = `
`; +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:disabled enableProfilerTimer:disabled should support an empty Profiler (with no children) 2`] = `
`; -exports[`Profiler works in profiling and non-profiling bundles enableProfilerTimer disabled should support nested Profilers 1`] = ` +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:disabled enableProfilerTimer:disabled should support nested Profilers 1`] = ` Array [
outer functional component @@ -32,7 +32,7 @@ Array [ ] `; -exports[`Profiler works in profiling and non-profiling bundles enableProfilerTimer enabled should render children 1`] = ` +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:disabled enableProfilerTimer:enabled should render children 1`] = `
outside span @@ -46,11 +46,75 @@ exports[`Profiler works in profiling and non-profiling bundles enableProfilerTim
`; -exports[`Profiler works in profiling and non-profiling bundles enableProfilerTimer enabled should support an empty Profiler (with no children) 1`] = `null`; +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:disabled enableProfilerTimer:enabled should support an empty Profiler (with no children) 1`] = `null`; -exports[`Profiler works in profiling and non-profiling bundles enableProfilerTimer enabled should support an empty Profiler (with no children) 2`] = `
`; +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:disabled enableProfilerTimer:enabled should support an empty Profiler (with no children) 2`] = `
`; -exports[`Profiler works in profiling and non-profiling bundles enableProfilerTimer enabled should support nested Profilers 1`] = ` +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:disabled enableProfilerTimer:enabled should support nested Profilers 1`] = ` +Array [ +
+ outer functional component +
, + + inner class component + , + + inner span + , +] +`; + +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:enabled enableProfilerTimer:disabled should render children 1`] = ` +
+ + outside span + + + inside span + + + functional component + +
+`; + +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:enabled enableProfilerTimer:disabled should support an empty Profiler (with no children) 1`] = `null`; + +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:enabled enableProfilerTimer:disabled should support an empty Profiler (with no children) 2`] = `
`; + +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:enabled enableProfilerTimer:disabled should support nested Profilers 1`] = ` +Array [ +
+ outer functional component +
, + + inner class component + , + + inner span + , +] +`; + +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:enabled enableProfilerTimer:enabled should render children 1`] = ` +
+ + outside span + + + inside span + + + functional component + +
+`; + +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:enabled enableProfilerTimer:enabled should support an empty Profiler (with no children) 1`] = `null`; + +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:enabled enableProfilerTimer:enabled should support an empty Profiler (with no children) 2`] = `
`; + +exports[`Profiler works in profiling and non-profiling bundles enableInteractionTracking:enabled enableProfilerTimer:enabled should support nested Profilers 1`] = ` Array [
outer functional component diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 3c92b53e559c4..7478a9c8746c6 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -40,7 +40,7 @@ export const warnAboutLegacyContextAPI = false; export const enableProfilerTimer = __PROFILE__; // Track which interactions trigger each commit. -export const enableInteractionTracking = false; +export const enableInteractionTracking = __PROFILE__; // Only used in www builds. export function addUserTimingListener() { diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 12b8c4bccfc6c..a656c4be24396 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -85,7 +85,7 @@ const bundles = [ moduleType: RENDERER, entry: 'react-dom', global: 'ReactDOM', - externals: ['react'], + externals: ['interaction-tracking', 'react'], }, //******* Test Utils *******/ @@ -129,7 +129,7 @@ const bundles = [ moduleType: NON_FIBER_RENDERER, entry: 'react-dom/server.browser', global: 'ReactDOMServer', - externals: ['react'], + externals: ['interaction-tracking', 'react'], }, { @@ -137,7 +137,7 @@ const bundles = [ bundleTypes: [NODE_DEV, NODE_PROD], moduleType: NON_FIBER_RENDERER, entry: 'react-dom/server.node', - externals: ['react', 'stream'], + externals: ['interaction-tracking', 'react', 'stream'], }, /******* React ART *******/ @@ -179,6 +179,7 @@ const bundles = [ 'deepDiffer', 'deepFreezeAndThrowOnMutationInDev', 'flattenStyle', + 'interaction-tracking', 'ReactNativeViewConfigRegistry', ], }, @@ -199,6 +200,7 @@ const bundles = [ 'deepDiffer', 'deepFreezeAndThrowOnMutationInDev', 'flattenStyle', + 'interaction-tracking', 'ReactNativeViewConfigRegistry', ], }, @@ -221,6 +223,7 @@ const bundles = [ 'deepDiffer', 'deepFreezeAndThrowOnMutationInDev', 'flattenStyle', + 'interaction-tracking', 'ReactNativeViewConfigRegistry', ], }, @@ -242,6 +245,7 @@ const bundles = [ 'deepDiffer', 'deepFreezeAndThrowOnMutationInDev', 'flattenStyle', + 'interaction-tracking', 'ReactNativeViewConfigRegistry', ], }, @@ -253,7 +257,7 @@ const bundles = [ moduleType: RENDERER, entry: 'react-test-renderer', global: 'ReactTestRenderer', - externals: ['react'], + externals: ['interaction-tracking', 'react'], }, { @@ -318,7 +322,7 @@ const bundles = [ moduleType: RECONCILER, entry: 'react-reconciler', global: 'ReactReconciler', - externals: ['react'], + externals: ['interaction-tracking', 'react'], }, /******* React Persistent Reconciler *******/ @@ -328,7 +332,7 @@ const bundles = [ moduleType: RECONCILER, entry: 'react-reconciler/persistent', global: 'ReactPersistentReconciler', - externals: ['react'], + externals: ['interaction-tracking', 'react'], }, /******* Reflection *******/ diff --git a/scripts/rollup/modules.js b/scripts/rollup/modules.js index b3a778224d385..1c7d27886438a 100644 --- a/scripts/rollup/modules.js +++ b/scripts/rollup/modules.js @@ -14,6 +14,7 @@ const HAS_NO_SIDE_EFFECTS_ON_IMPORT = false; const importSideEffects = Object.freeze({ 'prop-types/checkPropTypes': HAS_NO_SIDE_EFFECTS_ON_IMPORT, deepFreezeAndThrowOnMutationInDev: HAS_NO_SIDE_EFFECTS_ON_IMPORT, + 'interaction-tracking': HAS_NO_SIDE_EFFECTS_ON_IMPORT, }); // Bundles exporting globals that other modules rely on.