diff --git a/packages/interaction-tracking/README.md b/packages/interaction-tracking/README.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/interaction-tracking/index.js b/packages/interaction-tracking/index.js new file mode 100644 index 0000000000000..ee8856443dfa3 --- /dev/null +++ b/packages/interaction-tracking/index.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +'use strict'; + +export * from './src/InteractionTracking'; diff --git a/packages/interaction-tracking/npm/index.js b/packages/interaction-tracking/npm/index.js new file mode 100644 index 0000000000000..6f7a570506f7d --- /dev/null +++ b/packages/interaction-tracking/npm/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/interaction-tracking.production.min.js'); +} else { + module.exports = require('./cjs/interaction-tracking.development.js'); +} diff --git a/packages/interaction-tracking/package.json b/packages/interaction-tracking/package.json new file mode 100644 index 0000000000000..b77c29d595960 --- /dev/null +++ b/packages/interaction-tracking/package.json @@ -0,0 +1,23 @@ +{ + "name": "interaction-tracking", + "description": "utility for tracking interaction events", + "version": "0.0.1", + "license": "MIT", + "files": [ + "LICENSE", + "README.md", + "index.js", + "cjs/", + "umd/" + ], + "keywords": [ + "interaction", + "tracking", + "react" + ], + "repository": "https://github.com/facebook/react", + "bugs": { + "url": "https://github.com/facebook/react/issues" + }, + "homepage": "https://reactjs.org/" +} diff --git a/packages/interaction-tracking/src/InteractionTracking.js b/packages/interaction-tracking/src/InteractionTracking.js new file mode 100644 index 0000000000000..c44169d9d8b08 --- /dev/null +++ b/packages/interaction-tracking/src/InteractionTracking.js @@ -0,0 +1,289 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import { + enableInteractionTracking, + enableInteractionTrackingObserver, +} from 'shared/ReactFeatureFlags'; + +export type Interaction = {| + __count: number, + id: number, + name: string, + timestamp: number, +|}; + +export type Subscriber = { + // A new interaction has been created via the track() method. + onInteractionTracked: (interaction: Interaction) => void, + + // All scheduled async work for an interaction has finished. + onInteractionScheduledWorkCompleted: (interaction: Interaction) => void, + + // New async work has been scheduled for a set of interactions. + // When this work is later run, onWorkStarted/onWorkStopped will be called. + // A batch of async/yieldy work may be scheduled multiple times before completing. + // In that case, onWorkScheduled may be called more than once before onWorkStopped. + // Work is scheduled by a "thread" which is identified by a unique ID. + onWorkScheduled: (interactions: Set, threadID: number) => void, + + // A batch of scheduled work has been canceled. + // Work is done by a "thread" which is identified by a unique ID. + onWorkCanceled: (interactions: Set, threadID: number) => void, + + // A batch of work has started for a set of interactions. + // When this work is complete, onWorkStopped will be called. + // Work is not always completed synchronously; yielding may occur in between. + // A batch of async/yieldy work may also be re-started before completing. + // In that case, onWorkStarted may be called more than once before onWorkStopped. + // Work is done by a "thread" which is identified by a unique ID. + onWorkStarted: (interactions: Set, threadID: number) => void, + + // A batch of work has completed for a set of interactions. + // Work is done by a "thread" which is identified by a unique ID. + onWorkStopped: (interactions: Set, threadID: number) => void, +}; + +export type InteractionsRef = { + current: Set, +}; + +export type SubscriberRef = { + current: Subscriber | null, +}; + +const DEFAULT_THREAD_ID = 0; + +// Counters used to generate unique IDs. +let interactionIDCounter: number = 0; +let threadIDCounter: number = 0; + +// Set of currently tracked interactions. +// Interactions "stack"– +// Meaning that newly tracked interactions are appended to the previously active set. +// When an interaction goes out of scope, the previous set (if any) is restored. +let interactionsRef: InteractionsRef = (null: any); + +// Listener(s) to notify when interactions begin and end. +// Note that subscribers are only supported when enableInteractionTrackingObserver is enabled. +let subscriberRef: SubscriberRef = (null: any); + +if (enableInteractionTracking) { + interactionsRef = { + current: new Set(), + }; + if (enableInteractionTrackingObserver) { + subscriberRef = { + current: null, + }; + } +} + +// These values are exported for libraries with advanced use cases (i.e. React). +// They should not typically be accessed directly. +export {interactionsRef as __interactionsRef, subscriberRef as __subscriberRef}; + +export function clear(callback: Function): any { + if (!enableInteractionTracking) { + return callback(); + } + + const prevInteractions = interactionsRef.current; + interactionsRef.current = new Set(); + + try { + return callback(); + } finally { + interactionsRef.current = prevInteractions; + } +} + +export function getCurrent(): Set | null { + if (!enableInteractionTracking) { + return null; + } else { + return interactionsRef.current; + } +} + +export function getThreadID(): number { + return ++threadIDCounter; +} + +export function track( + name: string, + timestamp: number, + callback: Function, + threadID: number = DEFAULT_THREAD_ID, +): any { + if (!enableInteractionTracking) { + return callback(); + } + + const interaction: Interaction = { + __count: 0, + id: interactionIDCounter++, + name, + timestamp, + }; + + const prevInteractions = interactionsRef.current; + + // Tracked interactions should stack/accumulate. + // To do that, clone the current interactions. + // The previous set will be restored upon completion. + const interactions = new Set(prevInteractions); + interactions.add(interaction); + interactionsRef.current = interactions; + + if (enableInteractionTrackingObserver) { + // Update before calling callback in case it schedules follow-up work. + interaction.__count = 1; + + let returnValue; + const subscriber = subscriberRef.current; + + try { + if (subscriber !== null) { + subscriber.onInteractionTracked(interaction); + } + } finally { + try { + if (subscriber !== null) { + subscriber.onWorkStarted(interactions, threadID); + } + } finally { + try { + returnValue = callback(); + } finally { + interactionsRef.current = prevInteractions; + + try { + if (subscriber !== null) { + subscriber.onWorkStopped(interactions, threadID); + } + } finally { + interaction.__count--; + + // If no async work was scheduled for this interaction, + // Notify subscribers that it's completed. + if (subscriber !== null && interaction.__count === 0) { + subscriber.onInteractionScheduledWorkCompleted(interaction); + } + } + } + } + } + + return returnValue; + } else { + try { + return callback(); + } finally { + interactionsRef.current = prevInteractions; + } + } +} + +export function wrap( + callback: Function, + threadID: number = DEFAULT_THREAD_ID, +): Function { + if (!enableInteractionTracking) { + return callback; + } + + const wrappedInteractions = interactionsRef.current; + + if (enableInteractionTrackingObserver) { + const subscriber = subscriberRef.current; + if (subscriber !== null) { + subscriber.onWorkScheduled(wrappedInteractions, threadID); + } + + // Update the pending async work count for the current interactions. + // Update after calling subscribers in case of error. + wrappedInteractions.forEach(interaction => { + interaction.__count++; + }); + } + + const wrapped = () => { + const prevInteractions = interactionsRef.current; + interactionsRef.current = wrappedInteractions; + + if (enableInteractionTrackingObserver) { + const subscriber = subscriberRef.current; + + try { + let returnValue; + + try { + if (subscriber !== null) { + subscriber.onWorkStarted(wrappedInteractions, threadID); + } + } finally { + try { + returnValue = callback.apply(undefined, arguments); + } finally { + interactionsRef.current = prevInteractions; + + if (subscriber !== null) { + subscriber.onWorkStopped(wrappedInteractions, threadID); + } + } + } + + return returnValue; + } finally { + // Update pending async counts for all wrapped interactions. + // If this was the last scheduled async work for any of them, + // Mark them as completed. + wrappedInteractions.forEach(interaction => { + interaction.__count--; + + if (subscriber !== null && interaction.__count === 0) { + subscriber.onInteractionScheduledWorkCompleted(interaction); + } + }); + } + } else { + try { + return callback.apply(undefined, arguments); + } finally { + interactionsRef.current = prevInteractions; + } + } + }; + + if (enableInteractionTrackingObserver) { + wrapped.cancel = () => { + const subscriber = subscriberRef.current; + + try { + if (subscriber !== null) { + subscriber.onWorkCanceled(wrappedInteractions, threadID); + } + } finally { + // Update pending async counts for all wrapped interactions. + // If this was the last scheduled async work for any of them, + // Mark them as completed. + wrappedInteractions.forEach(interaction => { + interaction.__count--; + + if (subscriber && interaction.__count === 0) { + subscriber.onInteractionScheduledWorkCompleted(interaction); + } + }); + } + }; + } + + return wrapped; +} diff --git a/packages/interaction-tracking/src/__tests__/InteractionTracking-test.internal.js b/packages/interaction-tracking/src/__tests__/InteractionTracking-test.internal.js new file mode 100644 index 0000000000000..0b1b17b1cf347 --- /dev/null +++ b/packages/interaction-tracking/src/__tests__/InteractionTracking-test.internal.js @@ -0,0 +1,878 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @jest-environment node + */ +'use strict'; + +describe('InteractionTracking', () => { + let InteractionTracking; + let ReactFeatureFlags; + + let advanceTimeBy; + let currentTime; + + function loadModules({ + enableInteractionTracking, + enableInteractionTrackingObserver, + }) { + jest.resetModules(); + jest.useFakeTimers(); + + currentTime = 0; + Date.now = jest.fn().mockImplementation(() => currentTime); + + advanceTimeBy = amount => { + currentTime += amount; + }; + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableInteractionTracking = enableInteractionTracking; + ReactFeatureFlags.enableInteractionTrackingObserver = enableInteractionTrackingObserver; + + InteractionTracking = require('interaction-tracking'); + } + + describe('enableInteractionTracking enabled', () => { + beforeEach(() => loadModules({enableInteractionTracking: true})); + + it('should return the value of a tracked function', () => { + expect( + InteractionTracking.track('arbitrary', currentTime, () => 123), + ).toBe(123); + }); + + it('should return the value of a clear function', () => { + expect(InteractionTracking.clear(() => 123)).toBe(123); + }); + + it('should return the value of a wrapped function', () => { + let wrapped; + InteractionTracking.track('arbitrary', currentTime, () => { + wrapped = InteractionTracking.wrap(() => 123); + }); + expect(wrapped()).toBe(123); + }); + + it('should return an empty set when outside of a tracked event', () => { + expect(InteractionTracking.getCurrent()).toContainNoInteractions(); + }); + + it('should report the tracked interaction from within the track callback', done => { + advanceTimeBy(100); + + InteractionTracking.track('some event', currentTime, () => { + const interactions = InteractionTracking.getCurrent(); + expect(interactions).toMatchInteractions([ + {name: 'some event', timestamp: 100}, + ]); + + done(); + }); + }); + + it('should report the tracked interaction from within wrapped callbacks', done => { + let wrappedIndirection; + + function indirection() { + const interactions = InteractionTracking.getCurrent(); + expect(interactions).toMatchInteractions([ + {name: 'some event', timestamp: 100}, + ]); + + done(); + } + + advanceTimeBy(100); + + InteractionTracking.track('some event', currentTime, () => { + wrappedIndirection = InteractionTracking.wrap(indirection); + }); + + advanceTimeBy(50); + + wrappedIndirection(); + }); + + it('should clear the interaction stack for tracked callbacks', () => { + let innerTestReached = false; + + InteractionTracking.track('outer event', currentTime, () => { + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'outer event'}, + ]); + + InteractionTracking.clear(() => { + expect(InteractionTracking.getCurrent()).toMatchInteractions([]); + + InteractionTracking.track('inner event', currentTime, () => { + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'inner event'}, + ]); + + innerTestReached = true; + }); + }); + + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'outer event'}, + ]); + }); + + expect(innerTestReached).toBe(true); + }); + + it('should clear the interaction stack for wrapped callbacks', () => { + let innerTestReached = false; + let wrappedIndirection; + + const indirection = jest.fn(() => { + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'outer event'}, + ]); + + InteractionTracking.clear(() => { + expect(InteractionTracking.getCurrent()).toMatchInteractions([]); + + InteractionTracking.track('inner event', currentTime, () => { + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'inner event'}, + ]); + + innerTestReached = true; + }); + }); + + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'outer event'}, + ]); + }); + + InteractionTracking.track('outer event', currentTime, () => { + wrappedIndirection = InteractionTracking.wrap(indirection); + }); + + wrappedIndirection(); + + expect(innerTestReached).toBe(true); + }); + + it('should support nested tracked events', done => { + advanceTimeBy(100); + + let innerIndirectionTracked = false; + let outerIndirectionTracked = false; + + function innerIndirection() { + const interactions = InteractionTracking.getCurrent(); + expect(interactions).toMatchInteractions([ + {name: 'outer event', timestamp: 100}, + {name: 'inner event', timestamp: 150}, + ]); + + innerIndirectionTracked = true; + } + + function outerIndirection() { + const interactions = InteractionTracking.getCurrent(); + expect(interactions).toMatchInteractions([ + {name: 'outer event', timestamp: 100}, + ]); + + outerIndirectionTracked = true; + } + + InteractionTracking.track('outer event', currentTime, () => { + // Verify the current tracked event + let interactions = InteractionTracking.getCurrent(); + expect(interactions).toMatchInteractions([ + {name: 'outer event', timestamp: 100}, + ]); + + advanceTimeBy(50); + + const wrapperOuterIndirection = InteractionTracking.wrap( + outerIndirection, + ); + + let wrapperInnerIndirection; + let innerEventTracked = false; + + // Verify that a nested event is properly tracked + InteractionTracking.track('inner event', currentTime, () => { + interactions = InteractionTracking.getCurrent(); + expect(interactions).toMatchInteractions([ + {name: 'outer event', timestamp: 100}, + {name: 'inner event', timestamp: 150}, + ]); + + // Verify that a wrapped outer callback is properly tracked + wrapperOuterIndirection(); + expect(outerIndirectionTracked).toBe(true); + + wrapperInnerIndirection = InteractionTracking.wrap(innerIndirection); + + innerEventTracked = true; + }); + + expect(innerEventTracked).toBe(true); + + // Verify that the original event is restored + interactions = InteractionTracking.getCurrent(); + expect(interactions).toMatchInteractions([ + {name: 'outer event', timestamp: 100}, + ]); + + // Verify that a wrapped nested callback is properly tracked + wrapperInnerIndirection(); + expect(innerIndirectionTracked).toBe(true); + + done(); + }); + }); + + describe('error handling', () => { + it('should reset state appropriately when an error occurs in a track callback', done => { + advanceTimeBy(100); + + InteractionTracking.track('outer event', currentTime, () => { + expect(() => { + InteractionTracking.track('inner event', currentTime, () => { + throw Error('intentional'); + }); + }).toThrow(); + + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'outer event', timestamp: 100}, + ]); + + done(); + }); + }); + + it('should reset state appropriately when an error occurs in a wrapped callback', done => { + advanceTimeBy(100); + + InteractionTracking.track('outer event', currentTime, () => { + let wrappedCallback; + + InteractionTracking.track('inner event', currentTime, () => { + wrappedCallback = InteractionTracking.wrap(() => { + throw Error('intentional'); + }); + }); + + expect(wrappedCallback).toThrow(); + + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'outer event', timestamp: 100}, + ]); + + done(); + }); + }); + }); + + describe('advanced integration', () => { + it('should expose the current set of interactions to be externally manipulated', () => { + InteractionTracking.track('outer event', currentTime, () => { + expect(InteractionTracking.__interactionsRef.current).toBe( + InteractionTracking.getCurrent(), + ); + + InteractionTracking.__interactionsRef.current = new Set([ + {name: 'override event'}, + ]); + + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + {name: 'override event'}, + ]); + }); + }); + }); + + describe('interaction subscribers enabled', () => { + let onInteractionScheduledWorkCompleted; + let onInteractionTracked; + let onWorkCanceled; + let onWorkScheduled; + let onWorkStarted; + let onWorkStopped; + let subscriber; + let throwInOnInteractionScheduledWorkCompleted; + let throwInOnInteractionTracked; + let throwInOnWorkCanceled; + let throwInOnWorkScheduled; + let throwInOnWorkStarted; + let throwInOnWorkStopped; + + const firstEvent = {id: 0, name: 'first', timestamp: 0}; + const secondEvent = {id: 1, name: 'second', timestamp: 0}; + const threadID = 123; + + beforeEach(() => { + throwInOnInteractionScheduledWorkCompleted = false; + throwInOnInteractionTracked = false; + throwInOnWorkCanceled = false; + throwInOnWorkScheduled = false; + throwInOnWorkStarted = false; + throwInOnWorkStopped = false; + + onInteractionScheduledWorkCompleted = jest.fn(() => { + if (throwInOnInteractionScheduledWorkCompleted) { + throw Error('Expected error onInteractionScheduledWorkCompleted'); + } + }); + onInteractionTracked = jest.fn(() => { + if (throwInOnInteractionTracked) { + throw Error('Expected error onInteractionTracked'); + } + }); + onWorkCanceled = jest.fn(() => { + if (throwInOnWorkCanceled) { + throw Error('Expected error onWorkCanceled'); + } + }); + onWorkScheduled = jest.fn(() => { + if (throwInOnWorkScheduled) { + throw Error('Expected error onWorkScheduled'); + } + }); + onWorkStarted = jest.fn(() => { + if (throwInOnWorkStarted) { + throw Error('Expected error onWorkStarted'); + } + }); + onWorkStopped = jest.fn(() => { + if (throwInOnWorkStopped) { + throw Error('Expected error onWorkStopped'); + } + }); + }); + + describe('enableInteractionTrackingObserver enabled', () => { + beforeEach(() => { + loadModules({ + enableInteractionTracking: true, + enableInteractionTrackingObserver: true, + }); + + subscriber = { + onInteractionScheduledWorkCompleted, + onInteractionTracked, + onWorkCanceled, + onWorkScheduled, + onWorkStarted, + onWorkStopped, + }; + + InteractionTracking.__subscriberRef.current = subscriber; + }); + + it('should return the value of a tracked function', () => { + expect( + InteractionTracking.track('arbitrary', currentTime, () => 123), + ).toBe(123); + }); + + it('should return the value of a wrapped function', () => { + let wrapped; + InteractionTracking.track('arbitrary', currentTime, () => { + wrapped = InteractionTracking.wrap(() => 123); + }); + expect(wrapped()).toBe(123); + }); + + describe('error handling', () => { + it('should cover onInteractionTracked/onWorkStarted within', done => { + InteractionTracking.track(firstEvent.name, currentTime, () => { + const mock = jest.fn(); + + // It should call the callback before re-throwing + throwInOnInteractionTracked = true; + expect(() => + InteractionTracking.track( + secondEvent.name, + currentTime, + mock, + threadID, + ), + ).toThrow('Expected error onInteractionTracked'); + throwInOnInteractionTracked = false; + expect(mock).toHaveBeenCalledTimes(1); + + throwInOnWorkStarted = true; + expect(() => + InteractionTracking.track( + secondEvent.name, + currentTime, + mock, + threadID, + ), + ).toThrow('Expected error onWorkStarted'); + expect(mock).toHaveBeenCalledTimes(2); + + // It should restore the previous/outer interactions + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + firstEvent, + ]); + + done(); + }); + }); + + it('should cover onWorkStopped within track', done => { + InteractionTracking.track(firstEvent.name, currentTime, () => { + let innerInteraction; + const mock = jest.fn(() => { + innerInteraction = Array.from( + InteractionTracking.getCurrent(), + )[1]; + }); + + throwInOnWorkStopped = true; + expect(() => + InteractionTracking.track(secondEvent.name, currentTime, mock), + ).toThrow('Expected error onWorkStopped'); + throwInOnWorkStopped = false; + + // It should restore the previous/outer interactions + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + firstEvent, + ]); + + // It should update the interaction count so as not to interfere with subsequent calls + expect(innerInteraction.__count).toBe(0); + + done(); + }); + }); + + it('should cover the callback within track', done => { + expect(onWorkStarted).not.toHaveBeenCalled(); + expect(onWorkStopped).not.toHaveBeenCalled(); + + expect(() => { + InteractionTracking.track(firstEvent.name, currentTime, () => { + throw Error('Expected error callback'); + }); + }).toThrow('Expected error callback'); + + expect(onWorkStarted).toHaveBeenCalledTimes(1); + expect(onWorkStopped).toHaveBeenCalledTimes(1); + + done(); + }); + + it('should cover onWorkScheduled within wrap', done => { + InteractionTracking.track(firstEvent.name, currentTime, () => { + const interaction = Array.from( + InteractionTracking.getCurrent(), + )[0]; + const beforeCount = interaction.__count; + + throwInOnWorkScheduled = true; + expect(() => InteractionTracking.wrap(() => {})).toThrow( + 'Expected error onWorkScheduled', + ); + + // It should not update the interaction count so as not to interfere with subsequent calls + expect(interaction.__count).toBe(beforeCount); + + done(); + }); + }); + + it('should cover onWorkStarted within wrap', () => { + const mock = jest.fn(); + let interaction, wrapped; + InteractionTracking.track(firstEvent.name, currentTime, () => { + interaction = Array.from(InteractionTracking.getCurrent())[0]; + wrapped = InteractionTracking.wrap(mock); + }); + expect(interaction.__count).toBe(1); + + throwInOnWorkStarted = true; + expect(wrapped).toThrow('Expected error onWorkStarted'); + + // It should call the callback before re-throwing + expect(mock).toHaveBeenCalledTimes(1); + + // It should update the interaction count so as not to interfere with subsequent calls + expect(interaction.__count).toBe(0); + }); + + it('should cover onWorkStopped within wrap', done => { + InteractionTracking.track(firstEvent.name, currentTime, () => { + const outerInteraction = Array.from( + InteractionTracking.getCurrent(), + )[0]; + expect(outerInteraction.__count).toBe(1); + + let wrapped; + let innerInteraction; + + InteractionTracking.track(secondEvent.name, currentTime, () => { + innerInteraction = Array.from( + InteractionTracking.getCurrent(), + )[1]; + expect(outerInteraction.__count).toBe(1); + expect(innerInteraction.__count).toBe(1); + + wrapped = InteractionTracking.wrap(jest.fn()); + expect(outerInteraction.__count).toBe(2); + expect(innerInteraction.__count).toBe(2); + }); + + expect(outerInteraction.__count).toBe(2); + expect(innerInteraction.__count).toBe(1); + + throwInOnWorkStopped = true; + expect(wrapped).toThrow('Expected error onWorkStopped'); + throwInOnWorkStopped = false; + + // It should restore the previous interactions + expect(InteractionTracking.getCurrent()).toMatchInteractions([ + outerInteraction, + ]); + + // It should update the interaction count so as not to interfere with subsequent calls + expect(outerInteraction.__count).toBe(1); + expect(innerInteraction.__count).toBe(0); + + done(); + }); + }); + + it('should cover the callback within wrap', done => { + expect(onWorkStarted).not.toHaveBeenCalled(); + expect(onWorkStopped).not.toHaveBeenCalled(); + + let wrapped; + let interaction; + InteractionTracking.track(firstEvent.name, currentTime, () => { + interaction = Array.from(InteractionTracking.getCurrent())[0]; + wrapped = InteractionTracking.wrap(() => { + throw Error('Expected error wrap'); + }); + }); + + expect(onWorkStarted).toHaveBeenCalledTimes(1); + expect(onWorkStopped).toHaveBeenCalledTimes(1); + + expect(wrapped).toThrow('Expected error wrap'); + + expect(onWorkStarted).toHaveBeenCalledTimes(2); + expect(onWorkStopped).toHaveBeenCalledTimes(2); + expect(onWorkStopped).toHaveBeenLastNotifiedOfWork([interaction]); + + done(); + }); + + it('should cover onWorkCanceled within wrap', () => { + let interaction, wrapped; + InteractionTracking.track(firstEvent.name, currentTime, () => { + interaction = Array.from(InteractionTracking.getCurrent())[0]; + wrapped = InteractionTracking.wrap(jest.fn()); + }); + expect(interaction.__count).toBe(1); + + throwInOnWorkCanceled = true; + expect(wrapped.cancel).toThrow('Expected error onWorkCanceled'); + + expect(onWorkCanceled).toHaveBeenCalledTimes(1); + + // It should update the interaction count so as not to interfere with subsequent calls + expect(interaction.__count).toBe(0); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(firstEvent); + }); + }); + + it('calls lifecycle methods for track', () => { + expect(onInteractionTracked).not.toHaveBeenCalled(); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + InteractionTracking.track( + firstEvent.name, + currentTime, + () => { + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + firstEvent, + ); + expect( + onInteractionScheduledWorkCompleted, + ).not.toHaveBeenCalled(); + expect(onWorkStarted).toHaveBeenCalledTimes(1); + expect(onWorkStarted).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent]), + threadID, + ); + expect(onWorkStopped).not.toHaveBeenCalled(); + + InteractionTracking.track( + secondEvent.name, + currentTime, + () => { + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect( + onInteractionTracked, + ).toHaveBeenLastNotifiedOfInteraction(secondEvent); + expect( + onInteractionScheduledWorkCompleted, + ).not.toHaveBeenCalled(); + expect(onWorkStarted).toHaveBeenCalledTimes(2); + expect(onWorkStarted).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent, secondEvent]), + threadID, + ); + expect(onWorkStopped).not.toHaveBeenCalled(); + }, + threadID, + ); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes( + 1, + ); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(secondEvent); + expect(onWorkStopped).toHaveBeenCalledTimes(1); + expect(onWorkStopped).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent, secondEvent]), + threadID, + ); + }, + threadID, + ); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(firstEvent); + expect(onWorkScheduled).not.toHaveBeenCalled(); + expect(onWorkCanceled).not.toHaveBeenCalled(); + expect(onWorkStarted).toHaveBeenCalledTimes(2); + expect(onWorkStopped).toHaveBeenCalledTimes(2); + expect(onWorkStopped).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent]), + threadID, + ); + }); + + it('calls lifecycle methods for wrap', () => { + const unwrapped = jest.fn(); + let wrapped; + + InteractionTracking.track(firstEvent.name, currentTime, () => { + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + firstEvent, + ); + + InteractionTracking.track(secondEvent.name, currentTime, () => { + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionTracked).toHaveBeenLastNotifiedOfInteraction( + secondEvent, + ); + + wrapped = InteractionTracking.wrap(unwrapped, threadID); + expect(onWorkScheduled).toHaveBeenCalledTimes(1); + expect(onWorkScheduled).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent, secondEvent]), + threadID, + ); + }); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + wrapped(); + expect(unwrapped).toHaveBeenCalled(); + + expect(onWorkScheduled).toHaveBeenCalledTimes(1); + expect(onWorkCanceled).not.toHaveBeenCalled(); + expect(onWorkStarted).toHaveBeenCalledTimes(3); + expect(onWorkStarted).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent, secondEvent]), + threadID, + ); + expect(onWorkStopped).toHaveBeenCalledTimes(3); + expect(onWorkStopped).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent, secondEvent]), + threadID, + ); + + expect( + onInteractionScheduledWorkCompleted.mock.calls[0][0], + ).toMatchInteraction(firstEvent); + expect( + onInteractionScheduledWorkCompleted.mock.calls[1][0], + ).toMatchInteraction(secondEvent); + }); + + it('should call the correct interaction subscriber methods when a wrapped callback is canceled', () => { + const fnOne = jest.fn(); + const fnTwo = jest.fn(); + let wrappedOne, wrappedTwo; + InteractionTracking.track(firstEvent.name, currentTime, () => { + wrappedOne = InteractionTracking.wrap(fnOne, threadID); + InteractionTracking.track(secondEvent.name, currentTime, () => { + wrappedTwo = InteractionTracking.wrap(fnTwo, threadID); + }); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(2); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + expect(onWorkCanceled).not.toHaveBeenCalled(); + expect(onWorkStarted).toHaveBeenCalledTimes(2); + expect(onWorkStopped).toHaveBeenCalledTimes(2); + + wrappedTwo.cancel(); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(secondEvent); + expect(onWorkCanceled).toHaveBeenCalledTimes(1); + expect(onWorkCanceled).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent, secondEvent]), + threadID, + ); + + wrappedOne.cancel(); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(2); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(firstEvent); + expect(onWorkCanceled).toHaveBeenCalledTimes(2); + expect(onWorkCanceled).toHaveBeenLastNotifiedOfWork( + new Set([firstEvent]), + threadID, + ); + + expect(fnOne).not.toHaveBeenCalled(); + expect(fnTwo).not.toHaveBeenCalled(); + }); + + it('should not end an interaction twice if wrap is used to schedule follow up work within another wrap', () => { + const fnOne = jest.fn(() => { + wrappedTwo = InteractionTracking.wrap(fnTwo, threadID); + }); + const fnTwo = jest.fn(); + let wrappedOne, wrappedTwo; + InteractionTracking.track(firstEvent.name, currentTime, () => { + wrappedOne = InteractionTracking.wrap(fnOne, threadID); + }); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + wrappedOne(); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + + wrappedTwo(); + + expect(onInteractionTracked).toHaveBeenCalledTimes(1); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect( + onInteractionScheduledWorkCompleted, + ).toHaveBeenLastNotifiedOfInteraction(firstEvent); + }); + + it('should unsubscribe', () => { + InteractionTracking.__subscriberRef.current = null; + InteractionTracking.track(firstEvent.name, currentTime, () => {}); + + expect(onInteractionTracked).not.toHaveBeenCalled(); + }); + + describe('advanced integration', () => { + it('should return a unique threadID per request', () => { + expect(InteractionTracking.getThreadID()).not.toBe( + InteractionTracking.getThreadID(), + ); + }); + + it('should expose the current set of interaction subscribers to be called externally', () => { + expect( + InteractionTracking.__subscriberRef.current.onInteractionTracked, + ).toBe(onInteractionTracked); + }); + }); + }); + + describe('enableInteractionTrackingObserver disabled', () => { + beforeEach(() => { + loadModules({ + enableInteractionTracking: true, + enableInteractionTrackingObserver: false, + }); + }); + + it('should not create unnecessary objects', () => { + expect(InteractionTracking.__subscriberRef).toBe(null); + }); + }); + }); + }); + + describe('enableInteractionTracking disabled', () => { + beforeEach(() => loadModules({enableInteractionTracking: false})); + + it('should return the value of a tracked function', () => { + expect( + InteractionTracking.track('arbitrary', currentTime, () => 123), + ).toBe(123); + }); + + it('should return the value of a wrapped function', () => { + let wrapped; + InteractionTracking.track('arbitrary', currentTime, () => { + wrapped = InteractionTracking.wrap(() => 123); + }); + expect(wrapped()).toBe(123); + }); + + it('should return null for tracked interactions', () => { + expect(InteractionTracking.getCurrent()).toBe(null); + }); + + it('should execute tracked callbacks', done => { + InteractionTracking.track('some event', currentTime, () => { + expect(InteractionTracking.getCurrent()).toBe(null); + + done(); + }); + }); + + it('should return the value of a clear function', () => { + expect(InteractionTracking.clear(() => 123)).toBe(123); + }); + + it('should execute wrapped callbacks', done => { + const wrappedCallback = InteractionTracking.wrap(() => { + expect(InteractionTracking.getCurrent()).toBe(null); + + done(); + }); + + wrappedCallback(); + }); + + describe('advanced integration', () => { + it('should not create unnecessary objects', () => { + expect(InteractionTracking.__interactionsRef).toBe(null); + }); + }); + }); +}); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index b3d2b48500549..5e1012da9a398 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -39,6 +39,12 @@ export const warnAboutLegacyContextAPI = false; // Gather advanced timing metrics for Profiler subtrees. export const enableProfilerTimer = __PROFILE__; +// Track which interactions trigger each commit. +export const enableInteractionTracking = false; + +// Track which interactions trigger each commit. +export const enableInteractionTrackingObserver = false; + // Only used in www builds. export function addUserTimingListener() { invariant(false, 'Not implemented.'); diff --git a/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js index 1c012dd10a848..a625578c05821 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js @@ -21,6 +21,8 @@ export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = __DEV__; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const enableProfilerTimer = __PROFILE__; +export const enableInteractionTracking = false; +export const enableInteractionTrackingObserver = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js b/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js index ea6c7cf6d9ec1..55c140ea8633e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js @@ -21,6 +21,8 @@ export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const enableProfilerTimer = __PROFILE__; +export const enableInteractionTracking = false; +export const enableInteractionTrackingObserver = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 4efb4caf07df6..5fe3f9ecda2a1 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -26,6 +26,8 @@ export const { export const enableUserTimingAPI = __DEV__; export const warnAboutLegacyContextAPI = __DEV__; export const enableProfilerTimer = __PROFILE__; +export const enableInteractionTracking = false; +export const enableInteractionTrackingObserver = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 3c2b19bf1b580..ab68f6934442e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -21,6 +21,8 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; export const enableProfilerTimer = __PROFILE__; +export const enableInteractionTracking = false; +export const enableInteractionTrackingObserver = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index 142f1449fde2d..ba915efbfc21e 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -21,6 +21,8 @@ export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const enableProfilerTimer = __PROFILE__; +export const enableInteractionTracking = false; +export const enableInteractionTrackingObserver = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 1bc79a7b3f440..8767b19763dfe 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -21,6 +21,8 @@ export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; export const enableProfilerTimer = false; +export const enableInteractionTracking = false; +export const enableInteractionTrackingObserver = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index bb02dc0296ab9..81e829ed0bc9f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -21,6 +21,8 @@ export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = false; export const enableProfilerTimer = false; +export const enableInteractionTracking = false; +export const enableInteractionTrackingObserver = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index cebcf5f5cc8ad..9781ae0d6f1fc 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -32,6 +32,8 @@ export const warnAboutLegacyContextAPI = __DEV__; export let enableUserTimingAPI = __DEV__; export const enableProfilerTimer = __PROFILE__; +export const enableInteractionTracking = __PROFILE__; +export const enableInteractionTrackingObserver = __PROFILE__; let refCount = 0; export function addUserTimingListener() { diff --git a/scripts/jest/matchers/interactionTracking.js b/scripts/jest/matchers/interactionTracking.js new file mode 100644 index 0000000000000..5bbd52b8874ab --- /dev/null +++ b/scripts/jest/matchers/interactionTracking.js @@ -0,0 +1,103 @@ +'use strict'; + +const jestDiff = require('jest-diff'); + +function toContainNoInteractions(actualSet) { + return { + message: () => + this.isNot + ? `Expected interactions but there were none.` + : `Expected no interactions but there were ${actualSet.size}.`, + pass: actualSet.size === 0, + }; +} + +function toHaveBeenLastNotifiedOfInteraction( + mockFunction, + expectedInteraction +) { + const calls = mockFunction.mock.calls; + if (calls.length === 0) { + return { + message: () => 'Mock function was not called', + pass: false, + }; + } + + const [actualInteraction] = calls[calls.length - 1]; + + return toMatchInteraction(actualInteraction, expectedInteraction); +} + +function toHaveBeenLastNotifiedOfWork( + mockFunction, + expectedInteractions, + expectedThreadID = undefined +) { + const calls = mockFunction.mock.calls; + if (calls.length === 0) { + return { + message: () => 'Mock function was not called', + pass: false, + }; + } + + const [actualInteractions, actualThreadID] = calls[calls.length - 1]; + + if (expectedThreadID !== undefined) { + if (expectedThreadID !== actualThreadID) { + return { + message: () => jestDiff(expectedThreadID + '', actualThreadID + ''), + pass: false, + }; + } + } + + 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 = { + toContainNoInteractions, + toHaveBeenLastNotifiedOfInteraction, + toHaveBeenLastNotifiedOfWork, + toMatchInteraction, + toMatchInteractions, +}; diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 63bdd6e5292f4..6529b6110006c 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -43,6 +43,7 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { } expect.extend({ + ...require('./matchers/interactionTracking'), ...require('./matchers/toWarnDev'), ...require('./matchers/testRenderer'), }); diff --git a/scripts/jest/spec-equivalence-reporter/setupTests.js b/scripts/jest/spec-equivalence-reporter/setupTests.js index 76c71fe716e1f..bb263c7235aaf 100644 --- a/scripts/jest/spec-equivalence-reporter/setupTests.js +++ b/scripts/jest/spec-equivalence-reporter/setupTests.js @@ -46,6 +46,7 @@ global.spyOnProd = function(...args) { }; expect.extend({ + ...require('../matchers/interactionTracking'), ...require('../matchers/toWarnDev'), ...require('../matchers/testRenderer'), }); diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 54fa892cc0d47..aaa8a3974239b 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -394,6 +394,16 @@ const bundles = [ global: 'ReactScheduler', externals: [], }, + + /******* interaction-tracking (experimental) *******/ + { + label: 'interaction-tracking', + bundleTypes: [NODE_DEV, NODE_PROD, UMD_DEV, UMD_PROD], + moduleType: ISOMORPHIC, + entry: 'interaction-tracking', + global: 'InteractionTracking', + externals: [], + }, ]; // Based on deep-freeze by substack (public domain) diff --git a/scripts/rollup/modules.js b/scripts/rollup/modules.js index f814bc195e83f..dbf6e32481258 100644 --- a/scripts/rollup/modules.js +++ b/scripts/rollup/modules.js @@ -20,6 +20,7 @@ const importSideEffects = Object.freeze({ const knownGlobals = Object.freeze({ react: 'React', 'react-dom': 'ReactDOM', + 'interaction-tracking': 'InteractionTracking', }); // Given ['react'] in bundle externals, returns { 'react': 'React' }.