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