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',
+ // );
+ // })
+});