diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 758c1923fce61..0f73c7dab9bed 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -14,7 +14,10 @@ * environment. */ -import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type { + Fiber, + TransitionTracingCallbacks, +} from 'react-reconciler/src/ReactInternalTypes'; import type {UpdateQueue} from 'react-reconciler/src/ReactUpdateQueue'; import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes'; import type {RootTag} from 'react-reconciler/src/ReactRootTags'; @@ -64,6 +67,10 @@ type TextInstance = {| context: HostContext, |}; type HostContext = Object; +type CreateRootOptions = { + transitionCallbacks?: TransitionTracingCallbacks, + ... +}; const NO_CONTEXT = {}; const UPPERCASE_CONTEXT = {}; @@ -967,7 +974,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { }, // TODO: Replace ReactNoop.render with createRoot + root.render - createRoot() { + createRoot(options?: CreateRootOptions) { const container = { rootID: '' + idCounter++, pendingChildren: [], @@ -981,6 +988,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { null, false, '', + options && options.transitionCallbacks + ? options.transitionCallbacks + : null, ); return { _Scheduler: Scheduler, diff --git a/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js b/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js new file mode 100644 index 0000000000000..a291be232e53f --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactTransitionTracing-test.js @@ -0,0 +1,651 @@ +let React; +let ReactNoop; +let Scheduler; +let act; + +let getCacheForType; +let useState; +let useTransition; +let Suspense; +let TracingMarker; +let startTransition; + +let caches; +let seededCache; + +describe('ReactInteractionTracing', () => { + beforeEach(() => { + jest.resetModules(); + const ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableTransitionTracing = true; + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + + act = require('jest-react').act; + + useState = React.useState; + useTransition = React.useTransition; + startTransition = React.startTransition; + Suspense = React.Suspense; + TracingMarker = React.unstable_TracingMarker; + + getCacheForType = React.unstable_getCacheForType; + + caches = []; + seededCache = null; + }); + + const onTransitionStart = jest.fn(); + const onTransitionProgress = jest.fn(); + const onTransitionIncomplete = jest.fn(); + const onTransitionComplete = jest.fn(); + + const onMarkerProgress = jest.fn(); + const onMarkerIncomplete = jest.fn(); + const onMarkerComplete = jest.fn(); + + const transitionCallbacks = { + onTransitionStart: (name, startTime) => { + onTransitionStart({name, startTime}); + }, + onTransitionProgress: (name, startTime, currentTime, pending) => { + onTransitionProgress({ + name, + startTime, + currentTime, + pending, + }); + }, + onTransitionIncomplete: (name, startTime, deletions) => { + onTransitionIncomplete({ + name, + startTime, + deletions, + }); + }, + onTransitionComplete: (name, startTime, endTime) => { + onTransitionComplete({ + name, + startTime, + endTime, + }); + }, + onMarkerProgress: (name, marker, startTime, currentTime, pending) => { + onMarkerProgress({ + name, + marker, + startTime, + currentTime, + pending, + }); + }, + onMarkerIncomplete: (name, marker, startTime, deletions) => { + onMarkerIncomplete({ + name, + marker, + startTime, + deletions, + }); + }, + onMarkerComplete: (name, marker, startTime, endTime) => { + onMarkerComplete({ + name, + marker, + startTime, + endTime, + }); + }, + }; + + function createTextCache() { + if (seededCache !== null) { + const cache = seededCache; + seededCache = null; + return cache; + } + + const data = new Map(); + const cache = { + data, + resolve(text) { + const record = data.get(text); + + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + }, + reject(text, error) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'rejected'; + record.value = error; + thenable.pings.forEach(t => t()); + } + }, + }; + caches.push(cache); + return cache; + } + + function readText(text) { + const textCache = getCacheForType(createTextCache); + const record = textCache.data.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.unstable_yieldValue(`Suspend [${text}]`); + throw record.value; + case 'rejected': + Scheduler.unstable_yieldValue(`Error [${text}]`); + throw record.value; + case 'resolved': + return record.value; + } + } else { + Scheduler.unstable_yieldValue(`Suspend [${text}]`); + + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.data.set(text, newRecord); + + throw thenable; + } + } + + function AsyncText({text}) { + const fullText = readText(text); + Scheduler.unstable_yieldValue(fullText); + return fullText; + } + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function seedNextTextCache(text) { + if (seededCache === null) { + seededCache = createTextCache(); + } + seededCache.resolve(text); + } + + function resolveMostRecentTextCache(text) { + if (caches.length === 0) { + throw Error('Cache does not exist'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].resolve(text)`. + caches[caches.length - 1].resolve(text); + } + } + + const resolveText = resolveMostRecentTextCache; + + function rejectMostRecentTextCache(text, error) { + if (caches.length === 0) { + throw Error('Cache does not exist.'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].reject(text, error)`. + caches[caches.length - 1].reject(text, error); + } + } + + const rejectText = rejectMostRecentTextCache; + + function advanceTimers(ms) { + // Note: This advances Jest's virtual time but not React's. Use + // ReactNoop.expire for that. + if (typeof ms !== 'number') { + throw new Error('Must specify ms'); + } + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + // We cannot use a timer since we're faking them + return Promise.resolve().then(() => {}); + } + + fit('should correctly trace interactions for async roots', async () => { + let navigateToPageTwo; + function App() { + const [navigate, setNavigate] = useState(false); + navigateToPageTwo = () => { + setNavigate(true); + }; + + return ( +
+ {navigate ? ( + + } + name="suspense page"> + + + + ) : ( + + )} +
+ ); + } + + const root = ReactNoop.createRoot({transitionCallbacks}); + await act(async () => { + root.render(); + ReactNoop.expire(1000); + await advanceTimers(1000); + + expect(Scheduler).toFlushAndYield(['Page One']); + }); + + await act(async () => { + startTransition(() => navigateToPageTwo(), 'page transition'); + + expect(Scheduler).toFlushAndYield(['Suspend [Page Two]', 'Loading...']); + + ReactNoop.expire(1000); + await advanceTimers(1000); + await resolveText('Page Two'); + + expect(Scheduler).toFlushAndYield(['Page Two']); + }); + + expect(onTransitionStart).toHaveBeenCalledTimes(1); + expect(onTransitionStart.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 0, + }); + + expect(onTransitionProgress).toHaveBeenCalledTimes(1); + expect(onTransitionProgress.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 0, + currentTime: 0, + pending: [{name: 'suspense page'}], + }); + + expect(onMarkerProgress).toHaveBeenCalledTimes(1); + expect(onMarkerProgress.mock.calls[0][0]).toEqual({ + name: 'page transition', + marker: 'page loaded', + startTime: 0, + currentTime: 0, + pending: [{name: 'suspense page'}], + }); + + expect(onTransitionComplete).toHaveBeenCalledTimes(1); + expect(onTransitionComplete.mock.calls[0][0]).toEqual({ + name: 'page transition', + startTime: 0, + endTime: 1000, + }); + + expect(onMarkerComplete).toHaveBeenCalledTimes(1); + expect(onMarkerComplete.mock.calls[0][0]).toEqual({ + name: 'page transition', + marker: 'page loaded', + startTime: 0, + endTime: 1000, + }); + }); + + // it('tracing marker leaf components', async () => { + // let transition; + // function App() { + // const [navigate, setNavigate] = useState(false); + // const [, startTransition] = useTransition(); + // transition = () => { + // startTransition(() => { + // setNavigate(true); + // }, 'page transition'); + // }; + + // return ( + //
+ // {navigate ? ( + // + // }> + // + // + // + // }> + // + // + // + // + // ) : ( + // + // )} + //
+ // ); + // } + + // const root = ReactNoop.createRoot(); + // await act(async () => { + // root.render(); + + // expect(Scheduler).toFlushAndYield(['Page One']); + // }); + + // await act(async () => { + // transition(); + + // expect(Scheduler).toFlushAndYield([ + // 'Suspend [Subtree One]', + // 'Suspend [Subtree Two]', + // ]); + + // ReactNoop.expire(1000); + // await advanceTimers(1000); + + // expect(Scheduler).toFlushAndYield(['Subtree One']); + + // ReactNoop.expire(1000); + // await advanceTimers(1000); + + // expect(Scheduler).toFlushAndYield(['Subtree Two']); + // // state on tracing marker -> + // // pending transitions in current Tree + // // Whenever we should a fallback for the first time, we add + // }); + + // expect(jestInteractionCallback).toHaveBeenCalledTimes(2); + // //first call + // expect(jestInteractionCallback.mock.calls[0][0]).toEqual({ + // interactionName: 'page transition', + // markerName: 'Subtree One Loaded', + // startTime: 0, + // endTime: 1000, + // status: 'Complete', + // isServer: false, + // }); + + // // second call + // expect(jestInteractionCallback.mock.calls[1][0]).toEqual({ + // interactionName: 'page transition', + // markerName: 'Subtree Two Loaded', + // startTime: 0, + // endTime: 1000, + // status: 'Complete', + // isServer: false, + // }); + // }); + + // it('tracing marker in subtree that is not rerendered does not log', async () => { + // let transition; + // function App() { + // const [navigate, setNavigate] = useState(false); + // const [, startTransition] = useTransition(); + // transition = () => { + // startTransition(() => { + // setNavigate(true); + // }, 'page transition'); + // }; + + // return ( + //
+ // {navigate ? ( + // + // }> + // + // + // + // }> + // + // + // + // + // ) : ( + // + // )} + // + //
+ // ); + // } + + // const root = ReactNoop.createRoot(); + // await act(async () => { + // root.render(); + + // expect(Scheduler).toFlushAndYield(['Page One']); + // }); + + // await act(async () => { + // transition(); + + // expect(Scheduler).toFlushAndYield([ + // 'Suspend [Subtree One]', + // 'Suspend [Subtree Two]', + // ]); + + // ReactNoop.expire(1000); + // await advanceTimers(1000); + + // expect(Scheduler).toFlushAndYield(['Subtree One']); + + // ReactNoop.expire(1000); + // await advanceTimers(1000); + + // expect(Scheduler).toFlushAndYield(['Subtree Two']); + // }); + + // expect(jestInteractionCallback).toHaveBeenCalledTimes(2); + // //first call + // expect(jestInteractionCallback.mock.calls[0][0]).toEqual({ + // interactionName: 'page transition', + // markerName: 'Subtree One Loaded', + // startTime: 0, + // endTime: 1000, + // status: 'Complete', + // isServer: false, + // }); + + // // second call + // expect(jestInteractionCallback.mock.calls[1][0]).toEqual({ + // interactionName: 'page transition', + // markerName: 'Subtree Two Loaded', + // startTime: 0, + // endTime: 1000, + // status: 'Complete', + // isServer: false, + // }); + // }); + + // it('trace interaction on app mount'); + + // it('test transition incomplete and different deletions'); + + // it('warns if not in Profiler with interaction callback', () => { + // let transition; + // function App() { + // const [navigate, setNavigate] = useState(false); + // const [, startTransition] = useTransition(); + // transition = () => { + // startTransition(() => { + // setNavigate(true); + // }, 'page transition'); + // }; + + // return ( + //
+ // {navigate ? ( + // + // + // }> + // + // + // + // + // ) : ( + // + // )} + //
+ // ); + // } + + // const root = ReactNoop.createRoot(); + // await act(async () => { + // root.render(); + + // expect(Scheduler).toFlushAndYield(['Page One']); + // }); + + // await act(async () => { + // transition(); + + // expect(Scheduler).toFlushAndYield(['Suspend [Page Two]', 'Loading']); + // ReactNoop.expire(1000); + // await advanceTimers(1000); + // await resolveText('A'); + + // expect(Scheduler).toFlushAndYield(['Page Two']); + // }); + + // expect(interactionCompleteCallback).toHaveBeenCalledTimes(1); + // expect(interactionCompleteCallback.mock.calls[0][0]).toEqual({ + // interactionName: 'page transition', + // markerName: 'page loaded', + // startTime: 0, + // endTime: 1000, + // });; + + // }); + + // it( + // 'closest interaction callback used if there are multipl Profiler components in subtree', + // ); + + // it('Multiple suspense boundaries and tracing markers'); + + // it('Multiple interactions happening at the same time'); + + // it( + // 'interactions carry through when a function component schedules an update during a layout effect', + // ); + + // it('can implement a timeout'); + + // it('can implement a ingoreInteraction on a suspense boundary'); + + // it('suspense boundary deleted is an incompleted'); + + // it('marker deleted is an incomplete'); + + // it('multiple interactions batched together'); + + // IN REACT DOM WE NEED END TIME TESTS + + // // batched updates - if there's a click on the page and it bubbles down and more than one view + // // sets state, we wait to process the state until the click goes down and goes back up + + // // in 18, we batch updates by default. We always wait until the end of the frame before we update + // it( + // 'is called when a function component schedules a batched update during a layout effect', + // ); + + // it('Updates in passive effects do not carry through interactions'); + + // it('Should work for sync roots (?)'); + + // it('should properly trace work scheduled during the begin render phase'); + + // it('Should properly trace interactions within batched updates'); + + // it( + // 'should report the expected times when a high-priority update interrupts a low-priority update', + // ); + + // it( + // 'should trace work spawned by a commit phase lifecycle and setState callback', + // ); + + // it( + // 'should trace interactions associated with a parent component state update', + // ); + + // it('does not prematurely complete for suspended sync renders'); + + // it('traces cascading work after suspended sync renders'); + + // it('Should not trace interaction through hidden subtree'); + + // it( + // 'On page unmount marks all outstanding interactions as incomplete or deleted', + // ); + + // it('handles high-pri renderers between suspended and resolved (sync) trees'); + + // it( + // 'Should properly trace interactions when there is work of interleaved priorities', + // ); + + // it( + // 'should properly trace interactions through a multi-pass SuspenseList render', + // ); + + // it( + // 'is not called when a function component schedules an update during a passive effect', + // ); + + // it('Should work for render phase updates'); + + // it('Should properly trace interactions across multiple Profilers'); + + // it('Does not record time for components outside the Profiler tree'); + + // it('does not include time spent outside of profile root'); + + // it('should bubble time spent in effects to higher profilers (?)'); + + // it('should have a status of incomplete if part of the subtree was unmounted'); + + // it('should have a status of deleted if the tracing marker gets unmounted'); + + // it('useTransition tests (not just start transition)); + + // describe('hydration', () => { + // it('traces interaction across hydration'); + // it('traces interaction across client-rendered hydration'); + // it('traces interaction across suspended hydration'); + // it('traces interaction across suspended hydration from server'); + // }); + + // describe('devtools', () => { + // it( + // 'should store traced interactions on the HostNode so DevTools can access them', + // ); + // }) +});