diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 792cd1def4db..370c559b5701 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -815,13 +815,8 @@ function createFiberFromProfiler( key: null | string, ): Fiber { if (__DEV__) { - if ( - typeof pendingProps.id !== 'string' || - typeof pendingProps.onRender !== 'function' - ) { - console.error( - 'Profiler must specify an "id" string and "onRender" function as props', - ); + if (typeof pendingProps.id !== 'string') { + console.error('Profiler must specify an "id" as a prop'); } } @@ -831,6 +826,13 @@ function createFiberFromProfiler( fiber.type = REACT_PROFILER_TYPE; fiber.expirationTime = expirationTime; + if (enableProfilerTimer) { + fiber.stateNode = { + effectDuration: 0, + passiveEffectDuration: 0, + }; + } + return fiber; } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 9db5886a6c2b..3738219bfc57 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -577,6 +577,12 @@ function updateProfiler( ) { if (enableProfilerTimer) { workInProgress.effectTag |= Update; + + // Reset effect durations for the next eventual effect phase. + // These are reset during render to allow the DevTools commit hook a chance to read them, + const stateNode = workInProgress.stateNode; + stateNode.effectDuration = 0; + stateNode.passiveEffectDuration = 0; } const nextProps = workInProgress.pendingProps; const nextChildren = nextProps.children; @@ -2974,6 +2980,12 @@ function beginWork( if (hasChildWork) { workInProgress.effectTag |= Update; } + + // Reset effect durations for the next eventual effect phase. + // These are reset during render to allow the DevTools commit hook a chance to read them, + const stateNode = workInProgress.stateNode; + stateNode.effectDuration = 0; + stateNode.passiveEffectDuration = 0; } break; case SuspenseComponent: { diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index d22f2d296a73..6dd0e9bfe430 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -75,7 +75,14 @@ import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf'; import {getStackByFiberInDevAndProd} from './ReactCurrentFiber'; import {logCapturedError} from './ReactFiberErrorLogger'; import {resolveDefaultProps} from './ReactFiberLazyComponent'; -import {getCommitTime} from './ReactProfilerTimer'; +import { + getCommitTime, + recordLayoutEffectDuration, + recordPassiveEffectDuration, + startLayoutEffectTimer, + startPassiveEffectTimer, +} from './ReactProfilerTimer'; +import {ProfileMode} from './ReactTypeOfMode'; import {commitUpdateQueue} from './ReactUpdateQueue'; import { getPublicInstance, @@ -401,11 +408,18 @@ export function commitPassiveHookEffects(finishedWork: Fiber): void { case ForwardRef: case SimpleMemoComponent: case Chunk: { - // TODO (#17945) We should call all passive destroy functions (for all fibers) - // before calling any create functions. The current approach only serializes - // these for a single fiber. - commitHookEffectList(HookPassive, NoHookEffect, finishedWork); - commitHookEffectList(NoHookEffect, HookPassive, finishedWork); + if (enableProfilerTimer && finishedWork.mode & ProfileMode) { + try { + startPassiveEffectTimer(); + commitHookEffectList(HookPassive, NoHookEffect, finishedWork); + commitHookEffectList(NoHookEffect, HookPassive, finishedWork); + } finally { + recordPassiveEffectDuration(finishedWork); + } + } else { + commitHookEffectList(HookPassive, NoHookEffect, finishedWork); + commitHookEffectList(NoHookEffect, HookPassive, finishedWork); + } break; } default: @@ -421,7 +435,16 @@ export function commitPassiveHookUnmountEffects(finishedWork: Fiber): void { case ForwardRef: case SimpleMemoComponent: case Chunk: { - commitHookEffectList(HookPassive, NoHookEffect, finishedWork); + if (enableProfilerTimer && finishedWork.mode & ProfileMode) { + try { + startPassiveEffectTimer(); + commitHookEffectList(HookPassive, NoHookEffect, finishedWork); + } finally { + recordPassiveEffectDuration(finishedWork); + } + } else { + commitHookEffectList(HookPassive, NoHookEffect, finishedWork); + } break; } default: @@ -437,7 +460,16 @@ export function commitPassiveHookMountEffects(finishedWork: Fiber): void { case ForwardRef: case SimpleMemoComponent: case Chunk: { - commitHookEffectList(NoHookEffect, HookPassive, finishedWork); + if (enableProfilerTimer && finishedWork.mode & ProfileMode) { + try { + startPassiveEffectTimer(); + commitHookEffectList(NoHookEffect, HookPassive, finishedWork); + } finally { + recordPassiveEffectDuration(finishedWork); + } + } else { + commitHookEffectList(NoHookEffect, HookPassive, finishedWork); + } break; } default: @@ -446,6 +478,61 @@ export function commitPassiveHookMountEffects(finishedWork: Fiber): void { } } +export function commitPassiveEffectDurations( + finishedRoot: FiberRoot, + finishedWork: Fiber, +): void { + if (enableProfilerTimer) { + // Only Profilers with work in their subtree will have an Update effect scheduled. + if ((finishedWork.effectTag & Update) !== NoEffect) { + switch (finishedWork.tag) { + case Profiler: { + const {passiveEffectDuration} = finishedWork.stateNode; + const {id, onPostCommit} = finishedWork.memoizedProps; + + // This value will still reflect the previous commit phase. + // It does not get reset until the start of the next commit phase. + const commitTime = getCommitTime(); + + if (typeof onPostCommit === 'function') { + if (enableSchedulerTracing) { + onPostCommit( + id, + finishedWork.alternate === null ? 'mount' : 'update', + passiveEffectDuration, + commitTime, + finishedRoot.memoizedInteractions, + ); + } else { + onPostCommit( + id, + finishedWork.alternate === null ? 'mount' : 'update', + passiveEffectDuration, + commitTime, + ); + } + } + + // Bubble times to the next nearest ancestor Profiler. + // After we process that Profiler, we'll bubble further up. + let parentFiber = finishedWork.return; + while (parentFiber !== null) { + if (parentFiber.tag === Profiler) { + const parentStateNode = parentFiber.stateNode; + parentStateNode.passiveEffectDuration += passiveEffectDuration; + break; + } + parentFiber = parentFiber.return; + } + break; + } + default: + break; + } + } + } +} + function commitLifeCycles( finishedRoot: FiberRoot, current: Fiber | null, @@ -461,7 +548,16 @@ function commitLifeCycles( // This is done to prevent sibling component effects from interfering with each other, // e.g. a destroy function in one component should never override a ref set // by a create function in another component during the same commit. - commitHookEffectList(NoHookEffect, HookLayout, finishedWork); + if (enableProfilerTimer && finishedWork.mode & ProfileMode) { + try { + startLayoutEffectTimer(); + commitHookEffectList(NoHookEffect, HookLayout, finishedWork); + } finally { + recordLayoutEffectDuration(finishedWork); + } + } else { + commitHookEffectList(NoHookEffect, HookLayout, finishedWork); + } return; } case ClassComponent: { @@ -499,7 +595,16 @@ function commitLifeCycles( } } } - instance.componentDidMount(); + if (enableProfilerTimer && finishedWork.mode & ProfileMode) { + try { + startLayoutEffectTimer(); + instance.componentDidMount(); + } finally { + recordLayoutEffectDuration(finishedWork); + } + } else { + instance.componentDidMount(); + } stopPhaseTimer(); } else { const prevProps = @@ -538,11 +643,24 @@ function commitLifeCycles( } } } - instance.componentDidUpdate( - prevProps, - prevState, - instance.__reactInternalSnapshotBeforeUpdate, - ); + if (enableProfilerTimer && finishedWork.mode & ProfileMode) { + try { + startLayoutEffectTimer(); + instance.componentDidUpdate( + prevProps, + prevState, + instance.__reactInternalSnapshotBeforeUpdate, + ); + } finally { + recordLayoutEffectDuration(finishedWork); + } + } else { + instance.componentDidUpdate( + prevProps, + prevState, + instance.__reactInternalSnapshotBeforeUpdate, + ); + } stopPhaseTimer(); } } @@ -635,7 +753,10 @@ function commitLifeCycles( } case Profiler: { if (enableProfilerTimer) { - const onRender = finishedWork.memoizedProps.onRender; + const {onCommit, onRender} = finishedWork.memoizedProps; + const {effectDuration} = finishedWork.stateNode; + + const commitTime = getCommitTime(); if (typeof onRender === 'function') { if (enableSchedulerTracing) { @@ -645,7 +766,7 @@ function commitLifeCycles( finishedWork.actualDuration, finishedWork.treeBaseDuration, finishedWork.actualStartTime, - getCommitTime(), + commitTime, finishedRoot.memoizedInteractions, ); } else { @@ -655,10 +776,41 @@ function commitLifeCycles( finishedWork.actualDuration, finishedWork.treeBaseDuration, finishedWork.actualStartTime, - getCommitTime(), + commitTime, + ); + } + } + + if (typeof onCommit === 'function') { + if (enableSchedulerTracing) { + onCommit( + finishedWork.memoizedProps.id, + current === null ? 'mount' : 'update', + effectDuration, + commitTime, + finishedRoot.memoizedInteractions, + ); + } else { + onCommit( + finishedWork.memoizedProps.id, + current === null ? 'mount' : 'update', + effectDuration, + commitTime, ); } } + + // Propagate layout effect durations to the next nearest Profiler ancestor. + // Do not reset these values until the next render so DevTools has a chance to read them first. + let parentFiber = finishedWork.return; + while (parentFiber !== null) { + if (parentFiber.tag === Profiler) { + const parentStateNode = parentFiber.stateNode; + parentStateNode.effectDuration += effectDuration; + break; + } + parentFiber = parentFiber.return; + } } return; } @@ -806,7 +958,13 @@ function commitUnmount( if ((tag & HookPassive) !== NoHookEffect) { enqueuePendingPassiveEffectDestroyFn(destroy); } else { - safelyCallDestroy(current, destroy); + if (enableProfilerTimer && current.mode & ProfileMode) { + startLayoutEffectTimer(); + safelyCallDestroy(current, destroy); + recordLayoutEffectDuration(current); + } else { + safelyCallDestroy(current, destroy); + } } } effect = effect.next; @@ -847,7 +1005,13 @@ function commitUnmount( safelyDetachRef(current); const instance = current.stateNode; if (typeof instance.componentWillUnmount === 'function') { - safelyCallComponentWillUnmount(current, instance); + if (enableProfilerTimer && current.mode & ProfileMode) { + startLayoutEffectTimer(); + safelyCallComponentWillUnmount(current, instance); + recordLayoutEffectDuration(current); + } else { + safelyCallComponentWillUnmount(current, instance); + } } return; } @@ -1347,7 +1511,16 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { // This prevents sibling component effects from interfering with each other, // e.g. a destroy function in one component should never override a ref set // by a create function in another component during the same commit. - commitHookEffectList(HookLayout, NoHookEffect, finishedWork); + if (enableProfilerTimer && finishedWork.mode & ProfileMode) { + try { + startLayoutEffectTimer(); + commitHookEffectList(HookLayout, NoHookEffect, finishedWork); + } finally { + recordLayoutEffectDuration(finishedWork); + } + } else { + commitHookEffectList(HookLayout, NoHookEffect, finishedWork); + } return; } case Profiler: { @@ -1390,7 +1563,16 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { // This prevents sibling component effects from interfering with each other, // e.g. a destroy function in one component should never override a ref set // by a create function in another component during the same commit. - commitHookEffectList(HookLayout, NoHookEffect, finishedWork); + if (enableProfilerTimer && finishedWork.mode & ProfileMode) { + try { + startLayoutEffectTimer(); + commitHookEffectList(HookLayout, NoHookEffect, finishedWork); + } finally { + recordLayoutEffectDuration(finishedWork); + } + } else { + commitHookEffectList(HookLayout, NoHookEffect, finishedWork); + } return; } case ClassComponent: { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index dd25a2cee256..d84b590af46e 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -141,6 +141,7 @@ import { commitDetachRef, commitAttachRef, commitResetTextContent, + commitPassiveEffectDurations, } from './ReactFiberCommitWork'; import {enqueueUpdate} from './ReactUpdateQueue'; import {resetContextDependencies} from './ReactFiberNewContext'; @@ -149,6 +150,8 @@ import {createCapturedValue} from './ReactCapturedValue'; import { recordCommitTime, + recordPassiveEffectDuration, + startPassiveEffectTimer, startProfilerTimer, stopProfilerTimerIfRunningAndRecordDelta, } from './ReactProfilerTimer'; @@ -2199,6 +2202,7 @@ function flushPassiveEffectsImpl() { if (rootWithPendingPassiveEffects === null) { return false; } + const root = rootWithPendingPassiveEffects; const expirationTime = pendingPassiveEffectsExpirationTime; rootWithPendingPassiveEffects = null; @@ -2220,6 +2224,13 @@ function flushPassiveEffectsImpl() { i < pendingUnmountedPassiveEffectDestroyFunctions.length; i++ ) { + // TODO (bvaughn) If we are in a profiling build, within a Profiled subtree, + // we should measure the duration of passive destroy functions. + // However by the time we are flushing passive effects for an unmount, + // the effect's Fiber is no longer in the tree- + // so we don't know if there was a Profiler above us. + // Let's revisit this if we refactor to preserve the unmounted Fiber tree. + const destroy = pendingUnmountedPassiveEffectDestroyFunctions[i]; invokeGuardedCallback(null, destroy, null); } @@ -2242,12 +2253,23 @@ function flushPassiveEffectsImpl() { while (effect !== null) { if (__DEV__) { setCurrentDebugFiberInDEV(effect); - invokeGuardedCallback( - null, - commitPassiveHookUnmountEffects, - null, - effect, - ); + if (enableProfilerTimer && effect.mode & ProfileMode) { + startPassiveEffectTimer(); + invokeGuardedCallback( + null, + commitPassiveHookUnmountEffects, + null, + effect, + ); + recordPassiveEffectDuration(effect); + } else { + invokeGuardedCallback( + null, + commitPassiveHookUnmountEffects, + null, + effect, + ); + } if (hasCaughtError()) { effectWithErrorDuringUnmount = effect; invariant(effect !== null, 'Should be working on an effect.'); @@ -2257,7 +2279,16 @@ function flushPassiveEffectsImpl() { resetCurrentDebugFiberInDEV(); } else { try { - commitPassiveHookUnmountEffects(effect); + if (enableProfilerTimer && effect.mode & ProfileMode) { + try { + startPassiveEffectTimer(); + commitPassiveHookUnmountEffects(effect); + } finally { + recordPassiveEffectDuration(effect); + } + } else { + commitPassiveHookUnmountEffects(effect); + } } catch (error) { effectWithErrorDuringUnmount = effect; invariant(effect !== null, 'Should be working on an effect.'); @@ -2280,12 +2311,23 @@ function flushPassiveEffectsImpl() { if (effectWithErrorDuringUnmount !== effect) { if (__DEV__) { setCurrentDebugFiberInDEV(effect); - invokeGuardedCallback( - null, - commitPassiveHookMountEffects, - null, - effect, - ); + if (enableProfilerTimer && effect.mode & ProfileMode) { + startPassiveEffectTimer(); + invokeGuardedCallback( + null, + commitPassiveHookMountEffects, + null, + effect, + ); + recordPassiveEffectDuration(effect); + } else { + invokeGuardedCallback( + null, + commitPassiveHookMountEffects, + null, + effect, + ); + } if (hasCaughtError()) { invariant(effect !== null, 'Should be working on an effect.'); const error = clearCaughtError(); @@ -2294,13 +2336,27 @@ function flushPassiveEffectsImpl() { resetCurrentDebugFiberInDEV(); } else { try { - commitPassiveHookMountEffects(effect); + if (enableProfilerTimer && effect.mode & ProfileMode) { + try { + startPassiveEffectTimer(); + commitPassiveHookMountEffects(effect); + } finally { + recordPassiveEffectDuration(effect); + } + } else { + commitPassiveHookMountEffects(effect); + } } catch (error) { invariant(effect !== null, 'Should be working on an effect.'); captureCommitPhaseError(effect, error); } } } + + if (enableProfilerTimer) { + commitPassiveEffectDurations(root, effect); + } + const nextNextEffect = effect.nextEffect; // Remove nextEffect pointer to assist GC effect.nextEffect = null; @@ -2314,7 +2370,13 @@ function flushPassiveEffectsImpl() { while (effect !== null) { if (__DEV__) { setCurrentDebugFiberInDEV(effect); - invokeGuardedCallback(null, commitPassiveHookEffects, null, effect); + if (enableProfilerTimer && effect.mode & ProfileMode) { + startPassiveEffectTimer(); + invokeGuardedCallback(null, commitPassiveHookEffects, null, effect); + recordPassiveEffectDuration(effect); + } else { + invokeGuardedCallback(null, commitPassiveHookEffects, null, effect); + } if (hasCaughtError()) { invariant(effect !== null, 'Should be working on an effect.'); const error = clearCaughtError(); @@ -2323,12 +2385,26 @@ function flushPassiveEffectsImpl() { resetCurrentDebugFiberInDEV(); } else { try { - commitPassiveHookEffects(effect); + if (enableProfilerTimer && effect.mode & ProfileMode) { + try { + startPassiveEffectTimer(); + commitPassiveHookEffects(effect); + } finally { + recordPassiveEffectDuration(effect); + } + } else { + commitPassiveHookEffects(effect); + } } catch (error) { invariant(effect !== null, 'Should be working on an effect.'); captureCommitPhaseError(effect, error); } } + + if (enableProfilerTimer) { + commitPassiveEffectDurations(root, effect); + } + const nextNextEffect = effect.nextEffect; // Remove nextEffect pointer to assist GC effect.nextEffect = null; diff --git a/packages/react-reconciler/src/ReactProfilerTimer.js b/packages/react-reconciler/src/ReactProfilerTimer.js index c26f66d96924..3ea8ba9dbd20 100644 --- a/packages/react-reconciler/src/ReactProfilerTimer.js +++ b/packages/react-reconciler/src/ReactProfilerTimer.js @@ -10,6 +10,7 @@ import type {Fiber} from './ReactFiber'; import {enableProfilerTimer} from 'shared/ReactFeatureFlags'; +import {Profiler} from 'shared/ReactWorkTags'; // Intentionally not named imports because Rollup would use dynamic dispatch for // CommonJS interop named imports. @@ -27,7 +28,9 @@ export type ProfilerTimer = { }; let commitTime: number = 0; +let layoutEffectStartTime: number = -1; let profilerStartTime: number = -1; +let passiveEffectStartTime: number = -1; function getCommitTime(): number { return commitTime; @@ -77,9 +80,73 @@ function stopProfilerTimerIfRunningAndRecordDelta( } } +function recordLayoutEffectDuration(fiber: Fiber): void { + if (!enableProfilerTimer) { + return; + } + + if (layoutEffectStartTime >= 0) { + const elapsedTime = now() - layoutEffectStartTime; + + layoutEffectStartTime = -1; + + // Store duration on the next nearest Profiler ancestor. + let parentFiber = fiber.return; + while (parentFiber !== null) { + if (parentFiber.tag === Profiler) { + const parentStateNode = parentFiber.stateNode; + parentStateNode.effectDuration += elapsedTime; + break; + } + parentFiber = parentFiber.return; + } + } +} + +function recordPassiveEffectDuration(fiber: Fiber): void { + if (!enableProfilerTimer) { + return; + } + + if (passiveEffectStartTime >= 0) { + const elapsedTime = now() - passiveEffectStartTime; + + passiveEffectStartTime = -1; + + // Store duration on the next nearest Profiler ancestor. + let parentFiber = fiber.return; + while (parentFiber !== null) { + if (parentFiber.tag === Profiler) { + const parentStateNode = parentFiber.stateNode; + parentStateNode.passiveEffectDuration += elapsedTime; + break; + } + parentFiber = parentFiber.return; + } + } +} + +function startLayoutEffectTimer(): void { + if (!enableProfilerTimer) { + return; + } + layoutEffectStartTime = now(); +} + +function startPassiveEffectTimer(): void { + if (!enableProfilerTimer) { + return; + } + passiveEffectStartTime = now(); +} + export { getCommitTime, recordCommitTime, + recordLayoutEffectDuration, + recordPassiveEffectDuration, + startLayoutEffectTimer, + startPassiveEffectTimer, startProfilerTimer, stopProfilerTimerIfRunning, stopProfilerTimerIfRunningAndRecordDelta, diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index ad47793c8259..1dec14c3675b 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -120,10 +120,9 @@ describe('Profiler', () => { it('should warn if required params are missing', () => { expect(() => { ReactTestRenderer.create(); - }).toErrorDev( - 'Profiler must specify an "id" string and "onRender" function as props', - {withoutStack: true}, - ); + }).toErrorDev('Profiler must specify an "id" as a prop', { + withoutStack: true, + }); }); } @@ -1262,6 +1261,1077 @@ describe('Profiler', () => { expect(callback).toHaveBeenCalledTimes(1); }); }); + + describe('onCommit callback', () => { + beforeEach(() => { + jest.resetModules(); + + loadModules({enableSchedulerTracing}); + }); + + it('should report time spent in layout effects and commit lifecycles', () => { + const callback = jest.fn(); + + const ComponetWithEffects = () => { + React.useLayoutEffect(() => { + Scheduler.unstable_advanceTime(10); + return () => { + Scheduler.unstable_advanceTime(100); + }; + }, []); + React.useLayoutEffect(() => { + Scheduler.unstable_advanceTime(1000); + return () => { + Scheduler.unstable_advanceTime(10000); + }; + }); + React.useEffect(() => { + // This passive effect is here to verify that its time isn't reported. + Scheduler.unstable_advanceTime(5); + }); + return null; + }; + + class ComponentWithCommitHooks extends React.Component { + componentDidMount() { + Scheduler.unstable_advanceTime(100000); + } + componentDidUpdate() { + Scheduler.unstable_advanceTime(1000000); + } + render() { + return null; + } + } + + Scheduler.unstable_advanceTime(1); + + const renderer = ReactTestRenderer.create( + + + + , + ); + + expect(callback).toHaveBeenCalledTimes(1); + + let call = callback.mock.calls[0]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('mount-test'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(101010); // durations + expect(call[3]).toBe(1); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + Scheduler.unstable_advanceTime(1); + + renderer.update( + + + + , + ); + + expect(callback).toHaveBeenCalledTimes(2); + + call = callback.mock.calls[1]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('update-test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(1011000); // durations + expect(call[3]).toBe(101017); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + Scheduler.unstable_advanceTime(1); + + renderer.update( + , + ); + + expect(callback).toHaveBeenCalledTimes(3); + + call = callback.mock.calls[2]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('unmount-test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(10100); // durations + expect(call[3]).toBe(1112023); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + it('should report time spent in layout effects and commit lifecycles with cascading renders', () => { + const callback = jest.fn(); + + const ComponetWithEffects = ({shouldCascade}) => { + const [didCascade, setDidCascade] = React.useState(false); + React.useLayoutEffect(() => { + if (shouldCascade && !didCascade) { + setDidCascade(true); + } + Scheduler.unstable_advanceTime(didCascade ? 30 : 10); + return () => { + Scheduler.unstable_advanceTime(100); + }; + }, [didCascade, shouldCascade]); + return null; + }; + + class ComponentWithCommitHooks extends React.Component { + state = { + didCascade: false, + }; + componentDidMount() { + Scheduler.unstable_advanceTime(1000); + } + componentDidUpdate() { + Scheduler.unstable_advanceTime(10000); + if (this.props.shouldCascade && !this.state.didCascade) { + this.setState({didCascade: true}); + } + } + render() { + return null; + } + } + + Scheduler.unstable_advanceTime(1); + + const renderer = ReactTestRenderer.create( + + + + , + ); + + expect(callback).toHaveBeenCalledTimes(2); + + let call = callback.mock.calls[0]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('mount-test'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(1010); // durations + expect(call[3]).toBe(1); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + call = callback.mock.calls[1]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('mount-test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(130); // durations + expect(call[3]).toBe(1011); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + Scheduler.unstable_advanceTime(1); + + renderer.update( + + + + , + ); + + expect(callback).toHaveBeenCalledTimes(4); + + call = callback.mock.calls[2]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('update-test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(10130); // durations + expect(call[3]).toBe(1142); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + call = callback.mock.calls[3]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('update-test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(10000); // durations + expect(call[3]).toBe(11272); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + it('should bubble time spent in layout effects to higher profilers', () => { + const callback = jest.fn(); + + const ComponetWithEffects = ({ + cleanupDuration, + duration, + setCountRef, + }) => { + const setCount = React.useState(0)[1]; + if (setCountRef != null) { + setCountRef.current = setCount; + } + React.useLayoutEffect(() => { + Scheduler.unstable_advanceTime(duration); + return () => { + Scheduler.unstable_advanceTime(cleanupDuration); + }; + }); + Scheduler.unstable_advanceTime(1); + return null; + }; + + const setCountRef = React.createRef(null); + + let renderer = null; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + + + + + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + let call = callback.mock.calls[0]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root-mount'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(1010); // durations + expect(call[3]).toBe(2); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + ReactTestRenderer.act(() => setCountRef.current(count => count + 1)); + + expect(callback).toHaveBeenCalledTimes(2); + + call = callback.mock.calls[1]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root-mount'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(110); // durations + expect(call[3]).toBe(1013); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + ReactTestRenderer.act(() => { + renderer.update( + + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(3); + + call = callback.mock.calls[2]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root-update'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(1100); // durations + expect(call[3]).toBe(1124); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + it('should properly report time in layout effects even when there are errors', () => { + const callback = jest.fn(); + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + return this.state.error === null + ? this.props.children + : this.props.fallback; + } + } + + const ComponetWithEffects = ({ + cleanupDuration, + duration, + effectDuration, + shouldThrow, + }) => { + React.useLayoutEffect(() => { + Scheduler.unstable_advanceTime(effectDuration); + if (shouldThrow) { + throw Error('expected'); + } + return () => { + Scheduler.unstable_advanceTime(cleanupDuration); + }; + }); + Scheduler.unstable_advanceTime(duration); + return null; + }; + + Scheduler.unstable_advanceTime(1); + + // Test an error that happens during an effect + + ReactTestRenderer.act(() => { + ReactTestRenderer.create( + + + }> + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + + let call = callback.mock.calls[0]; + + // Initial render (with error) + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(100100); // durations + expect(call[3]).toBe(10011); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + call = callback.mock.calls[1]; + + // Cleanup render from error boundary + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(100000000); // durations + expect(call[3]).toBe(10110111); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + it('should properly report time in layout effect cleanup functions even when there are errors', () => { + const callback = jest.fn(); + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + return this.state.error === null + ? this.props.children + : this.props.fallback; + } + } + + const ComponetWithEffects = ({ + cleanupDuration, + duration, + effectDuration, + shouldThrow = false, + }) => { + React.useLayoutEffect(() => { + Scheduler.unstable_advanceTime(effectDuration); + return () => { + Scheduler.unstable_advanceTime(cleanupDuration); + if (shouldThrow) { + throw Error('expected'); + } + }; + }); + Scheduler.unstable_advanceTime(duration); + return null; + }; + + Scheduler.unstable_advanceTime(1); + + let renderer = null; + + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + + + }> + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + let call = callback.mock.calls[0]; + + // Initial render + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(100100); // durations + expect(call[3]).toBe(10011); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + callback.mockClear(); + + // Test an error that happens during an cleanup function + + ReactTestRenderer.act(() => { + renderer.update( + + + }> + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + + call = callback.mock.calls[0]; + + // Update (that throws) + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(1101100); // durations + expect(call[3]).toBe(120121); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + call = callback.mock.calls[1]; + + // Cleanup render from error boundary + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(100001000); // durations + expect(call[3]).toBe(11221221); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + if (enableSchedulerTracing) { + it('should report interactions that were active', () => { + const callback = jest.fn(); + + const ComponetWithEffects = () => { + const [didMount, setDidMount] = React.useState(false); + React.useLayoutEffect(() => { + Scheduler.unstable_advanceTime(didMount ? 1000 : 100); + if (!didMount) { + setDidMount(true); + } + return () => { + Scheduler.unstable_advanceTime(10000); + }; + }, [didMount]); + Scheduler.unstable_advanceTime(10); + return null; + }; + + const interaction = { + id: 0, + name: 'mount', + timestamp: Scheduler.unstable_now(), + }; + + Scheduler.unstable_advanceTime(1); + + SchedulerTracing.unstable_trace( + interaction.name, + interaction.timestamp, + () => { + ReactTestRenderer.create( + + + , + ); + }, + ); + + expect(callback).toHaveBeenCalledTimes(2); + + let call = callback.mock.calls[0]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('mount'); + expect(call[4]).toMatchInteractions([interaction]); + + call = callback.mock.calls[1]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('update'); + expect(call[4]).toMatchInteractions([interaction]); + }); + } + }); + + describe('onPostCommit callback', () => { + beforeEach(() => { + jest.resetModules(); + + loadModules({enableSchedulerTracing}); + }); + + it('should report time spent in passive effects', () => { + const callback = jest.fn(); + + const ComponetWithEffects = () => { + React.useLayoutEffect(() => { + // This layout effect is here to verify that its time isn't reported. + Scheduler.unstable_advanceTime(5); + }); + React.useEffect(() => { + Scheduler.unstable_advanceTime(10); + return () => { + Scheduler.unstable_advanceTime(100); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_advanceTime(1000); + return () => { + Scheduler.unstable_advanceTime(10000); + }; + }); + return null; + }; + + Scheduler.unstable_advanceTime(1); + + let renderer; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + let call = callback.mock.calls[0]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('mount-test'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(1010); // durations + expect(call[3]).toBe(1); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + Scheduler.unstable_advanceTime(1); + + ReactTestRenderer.act(() => { + renderer.update( + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + + call = callback.mock.calls[1]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('update-test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(11000); // durations + expect(call[3]).toBe(1017); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + Scheduler.unstable_advanceTime(1); + + ReactTestRenderer.act(() => { + renderer.update( + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(3); + + call = callback.mock.calls[2]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('unmount-test'); + expect(call[1]).toBe('update'); + // TODO (bvaughn) The duration reported below should be 10100, but is 0 + // by the time the passive effect is flushed its parent Fiber pointer is gone. + // If we refactor to preserve the unmounted Fiber tree we could fix this. + // The current implementation would require too much extra overhead to track this. + expect(call[2]).toBe(0); // durations + expect(call[3]).toBe(12023); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + it('should report time spent in passive effects with cascading renders', () => { + const callback = jest.fn(); + + const ComponetWithEffects = () => { + const [didMount, setDidMount] = React.useState(false); + React.useEffect(() => { + if (!didMount) { + setDidMount(true); + } + Scheduler.unstable_advanceTime(didMount ? 30 : 10); + return () => { + Scheduler.unstable_advanceTime(100); + }; + }, [didMount]); + return null; + }; + + Scheduler.unstable_advanceTime(1); + + ReactTestRenderer.act(() => { + ReactTestRenderer.create( + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + + let call = callback.mock.calls[0]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('mount-test'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(10); // durations + expect(call[3]).toBe(1); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + call = callback.mock.calls[1]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('mount-test'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(130); // durations + expect(call[3]).toBe(11); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + it('should bubble time spent in effects to higher profilers', () => { + const callback = jest.fn(); + + const ComponetWithEffects = ({ + cleanupDuration, + duration, + setCountRef, + }) => { + const setCount = React.useState(0)[1]; + if (setCountRef != null) { + setCountRef.current = setCount; + } + React.useEffect(() => { + Scheduler.unstable_advanceTime(duration); + return () => { + Scheduler.unstable_advanceTime(cleanupDuration); + }; + }); + Scheduler.unstable_advanceTime(1); + return null; + }; + + const setCountRef = React.createRef(null); + + let renderer = null; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + + + + + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + let call = callback.mock.calls[0]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root-mount'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(1010); // durations + expect(call[3]).toBe(2); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + ReactTestRenderer.act(() => setCountRef.current(count => count + 1)); + + expect(callback).toHaveBeenCalledTimes(2); + + call = callback.mock.calls[1]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root-mount'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(110); // durations + expect(call[3]).toBe(1013); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + ReactTestRenderer.act(() => { + renderer.update( + + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(3); + + call = callback.mock.calls[2]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root-update'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(1100); // durations + expect(call[3]).toBe(1124); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + it('should properly report time in passive effects even when there are errors', () => { + const callback = jest.fn(); + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + return this.state.error === null + ? this.props.children + : this.props.fallback; + } + } + + const ComponetWithEffects = ({ + cleanupDuration, + duration, + effectDuration, + shouldThrow, + }) => { + React.useEffect(() => { + Scheduler.unstable_advanceTime(effectDuration); + if (shouldThrow) { + throw Error('expected'); + } + return () => { + Scheduler.unstable_advanceTime(cleanupDuration); + }; + }); + Scheduler.unstable_advanceTime(duration); + return null; + }; + + Scheduler.unstable_advanceTime(1); + + // Test an error that happens during an effect + + ReactTestRenderer.act(() => { + ReactTestRenderer.create( + + + }> + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + + let call = callback.mock.calls[0]; + + // Initial render (with error) + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(100100); // durations + expect(call[3]).toBe(10011); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + call = callback.mock.calls[1]; + + // Cleanup render from error boundary + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(100000000); // durations + expect(call[3]).toBe(10110111); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + it('should properly report time in passive effect cleanup functions even when there are errors', () => { + const callback = jest.fn(); + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + return this.state.error === null + ? this.props.children + : this.props.fallback; + } + } + + const ComponetWithEffects = ({ + cleanupDuration, + duration, + effectDuration, + shouldThrow = false, + }) => { + React.useEffect(() => { + Scheduler.unstable_advanceTime(effectDuration); + return () => { + Scheduler.unstable_advanceTime(cleanupDuration); + if (shouldThrow) { + throw Error('expected'); + } + }; + }); + Scheduler.unstable_advanceTime(duration); + return null; + }; + + Scheduler.unstable_advanceTime(1); + + let renderer = null; + + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + + + }> + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + let call = callback.mock.calls[0]; + + // Initial render + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('mount'); + expect(call[2]).toBe(100100); // durations + expect(call[3]).toBe(10011); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + callback.mockClear(); + + // Test an error that happens during an cleanup function + + ReactTestRenderer.act(() => { + renderer.update( + + + }> + + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + + call = callback.mock.calls[0]; + + // Update (that throws) + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(1101000); // durations + expect(call[3]).toBe(120121); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + + call = callback.mock.calls[1]; + + // Cleanup render from error boundary + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('update'); + expect(call[2]).toBe(100000000); // durations + expect(call[3]).toBe(11221121); // commit start time (before mutations or effects) + expect(call[4]).toEqual(enableSchedulerTracing ? new Set() : undefined); // interaction events + }); + + if (enableSchedulerTracing) { + it('should report interactions that were active', () => { + const callback = jest.fn(); + + const ComponetWithEffects = () => { + const [didMount, setDidMount] = React.useState(false); + React.useEffect(() => { + Scheduler.unstable_advanceTime(didMount ? 1000 : 100); + if (!didMount) { + setDidMount(true); + } + return () => { + Scheduler.unstable_advanceTime(10000); + }; + }, [didMount]); + Scheduler.unstable_advanceTime(10); + return null; + }; + + const interaction = { + id: 0, + name: 'mount', + timestamp: Scheduler.unstable_now(), + }; + + Scheduler.unstable_advanceTime(1); + + ReactTestRenderer.act(() => { + SchedulerTracing.unstable_trace( + interaction.name, + interaction.timestamp, + () => { + ReactTestRenderer.create( + + + , + ); + }, + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + + let call = callback.mock.calls[0]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('mount'); + expect(call[4]).toMatchInteractions([interaction]); + + call = callback.mock.calls[1]; + + expect(call).toHaveLength(enableSchedulerTracing ? 5 : 4); + expect(call[0]).toBe('root'); + expect(call[1]).toBe('update'); + expect(call[4]).toMatchInteractions([interaction]); + }); + } + }); }); describe('interaction tracing', () => { @@ -1771,6 +2841,45 @@ describe('Profiler', () => { ]); }); + it('should not mark an interaction complete while passive effects are outstanding', () => { + const onCommit = jest.fn(); + const onPostCommit = jest.fn(); + + const ComponetWithEffects = () => { + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive effect'); + }); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout effect'); + }); + Scheduler.unstable_yieldValue('render'); + return null; + }; + + SchedulerTracing.unstable_trace('mount', Scheduler.unstable_now(), () => { + ReactTestRenderer.create( + + + , + ); + }); + + expect(Scheduler).toHaveYielded(['render', 'layout effect']); + + expect(onCommit).toHaveBeenCalled(); + expect(onPostCommit).not.toHaveBeenCalled(); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + expect(Scheduler).toFlushAndYieldThrough(['passive effect']); + + expect(onCommit).toHaveBeenCalled(); + expect(onPostCommit).toHaveBeenCalled(); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + }); + it('should report the expected times when a high-priority update interrupts a low-priority update', () => { const onRender = jest.fn();