diff --git a/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx b/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx
index 64952df96..379512d28 100644
--- a/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx
+++ b/desktop/app/src/sandy-chrome/DesignComponentDemos.tsx
@@ -10,7 +10,7 @@
import React from 'react';
import {Typography, Card, Table, Collapse, Button, Tabs} from 'antd';
import {Layout, Link} from '../ui';
-import {NUX, theme} from 'flipper-plugin';
+import {NUX, theme, Tracked, TrackingScope} from 'flipper-plugin';
import reactElementToJSXString from 'react-element-to-jsx-string';
import {CodeOutlined} from '@ant-design/icons';
@@ -310,6 +310,47 @@ const demos: PreviewProps[] = [
),
},
},
+ {
+ title: 'Tracked',
+ description:
+ 'A component that tracks component interactions. For Facebook internal builds, global stats for these interactions will be tracked. Wrap this component around another element to track its events',
+ props: [
+ [
+ 'events',
+ 'string | string[] (default: "onClick")',
+ 'The event(s) of the child component that should be tracked',
+ ],
+ [
+ 'action',
+ 'string (optional)',
+ 'Describes the element the user interacted with. Will by default be derived from the title, key or contents of the element',
+ ],
+ ],
+ demos: {
+ 'Basic example': (
+
+
+
+ ),
+ },
+ },
+ {
+ title: 'TrackingScope',
+ description:
+ 'Describes more precisely the place in the UI for all underlying Tracked elements. Multiple Tracking scopes are automatically nested. Use the `withTrackingScope` HoC to automatically wrap a component definition in a tracking scope',
+ props: [
+ ['scope', 'string', 'The name of the scope. For example "Login Dialog"'],
+ ],
+ demos: {
+ 'Basic example': (
+
+
+
+
+
+ ),
+ },
+ },
];
function ComponentPreview({title, demos, description, props}: PreviewProps) {
diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx
index eb91341d7..8f09482e9 100644
--- a/desktop/flipper-plugin/src/__tests__/api.node.tsx
+++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx
@@ -29,14 +29,18 @@ test('Correct top level API exposed', () => {
"Layout",
"NUX",
"TestUtils",
+ "Tracked",
+ "TrackingScope",
"batch",
"createState",
"produce",
"renderReactRoot",
+ "sleep",
"styled",
"theme",
"usePlugin",
"useValue",
+ "withTrackingScope",
]
`);
diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts
index a340eb3ae..6ae754b33 100644
--- a/desktop/flipper-plugin/src/index.ts
+++ b/desktop/flipper-plugin/src/index.ts
@@ -52,6 +52,14 @@ export {
} from './ui/NUX';
export {renderReactRoot} from './utils/renderReactRoot';
+export {
+ Tracked,
+ TrackingScope,
+ setGlobalInteractionReporter as _setGlobalInteractionReporter,
+ withTrackingScope,
+} from './ui/Tracked';
+
+export {sleep} from './utils/sleep';
// It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors.
diff --git a/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx b/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx
index 2f279f1da..a89040b14 100644
--- a/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx
+++ b/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx
@@ -12,6 +12,7 @@ import {SandyPluginContext} from './PluginContext';
import {SandyPluginInstance} from './Plugin';
import {SandyDevicePluginInstance} from './DevicePlugin';
import {BasePluginInstance} from './PluginBase';
+import {TrackingScope} from '../ui/Tracked';
type Props = {
plugin: SandyPluginInstance | SandyDevicePluginInstance;
@@ -32,8 +33,10 @@ export const SandyPluginRenderer = memo(({plugin}: Props) => {
}, [plugin]);
return (
-
- {createElement(plugin.definition.module.Component)}
-
+
+
+ {createElement(plugin.definition.module.Component)}
+
+
);
});
diff --git a/desktop/flipper-plugin/src/ui/Tracked.tsx b/desktop/flipper-plugin/src/ui/Tracked.tsx
new file mode 100644
index 000000000..d2fd4918c
--- /dev/null
+++ b/desktop/flipper-plugin/src/ui/Tracked.tsx
@@ -0,0 +1,183 @@
+/**
+ * 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.
+ *
+ * @format
+ */
+
+import React from 'react';
+import {Children, cloneElement, createContext, useContext} from 'react';
+import reactElementToJSXString from 'react-element-to-jsx-string';
+
+export type InteractionReport = {
+ // Duration of the event handler itself, not including any time the promise handler might have been pending
+ duration: number;
+ // Duration of the total promise chain return by the event handler.
+ totalDuration: number;
+ success: 1 | 0;
+ error?: string;
+ scope: string;
+ action: string;
+ componentType: string;
+ event: string;
+};
+
+export type InteractionReporter = (report: InteractionReport) => void;
+
+let globalInteractionReporter: InteractionReporter = () => {};
+
+export function setGlobalInteractionReporter(reporter: InteractionReporter) {
+ globalInteractionReporter = reporter;
+}
+
+// For unit tests only
+export function resetGlobalInteractionReporter() {
+ globalInteractionReporter = () => {};
+}
+
+const DEFAULT_SCOPE = 'Flipper';
+
+const TrackingScopeContext = createContext(DEFAULT_SCOPE);
+
+export function TrackingScope({
+ scope,
+ children,
+}: {
+ scope: string;
+ children: React.ReactNode;
+}) {
+ const baseScope = useContext(TrackingScopeContext);
+ return (
+
+ {children}
+
+ );
+}
+
+export function Tracked({
+ events = 'onClick',
+ children,
+ action,
+}: {
+ /**
+ * Name of the event handler properties of the child component that should be wrapped
+ */
+ events?: string | string[];
+
+ /*
+ * Name of the interaction describing what the user interacted with. If omitted, will take a description of the child
+ */
+ action?: string;
+ children: React.ReactNode;
+}): React.ReactElement {
+ const scope = useContext(TrackingScopeContext);
+ return Children.map(children, (child: any) => {
+ if (!child || typeof child !== 'object') {
+ return child;
+ }
+ if (child.type === Tracked) return child; // avoid double trapping
+ const newProps: any = {};
+ (typeof events === 'string' ? [events] : events).forEach((event) => {
+ const base = child.props[event];
+ if (!base) {
+ return;
+ }
+ newProps[event] = wrapInteractionHandler(
+ base,
+ child,
+ event,
+ scope,
+ action,
+ );
+ });
+ return cloneElement(child, newProps);
+ }) as any;
+}
+
+// Exported for test
+export function wrapInteractionHandler(
+ fn: Function,
+ element: React.ReactElement,
+ event: string,
+ scope: string,
+ action?: string,
+) {
+ function report(start: number, initialEnd: number, error?: any) {
+ globalInteractionReporter({
+ duration: initialEnd - start,
+ totalDuration: Date.now() - start,
+ success: error ? 0 : 1,
+ error: error ? '' + error : undefined,
+ componentType: describeElementType(element),
+ action: action ?? describeElement(element),
+ scope,
+ event,
+ });
+ }
+
+ return function trappedInteractionHandler(this: any) {
+ let res: any;
+ const start = Date.now();
+ const r = report.bind(null, start);
+ try {
+ // eslint-disable-next-line
+ res = fn.apply(this, arguments);
+ if (Date.now() - start > 20) {
+ console.warn('Slow interaction');
+ }
+ } catch (e) {
+ r(Date.now(), e);
+ throw e;
+ }
+ const initialEnd = Date.now();
+ if (typeof res?.then === 'function') {
+ // async / promise
+ res.then(
+ () => r(initialEnd),
+ (error: any) => r(initialEnd, error),
+ );
+ } else {
+ // not a Promise
+ r(initialEnd);
+ }
+ return res;
+ };
+}
+
+export function describeElement(element: React.ReactElement): string {
+ const describing = element.props.title ?? element.props.children;
+ if (typeof describing === 'string') {
+ return describing;
+ }
+ if (typeof element.key === 'string') {
+ return element.key;
+ }
+ return reactElementToJSXString(element).substr(0, 200).replace(/\n/g, ' ');
+}
+
+function describeElementType(element: React.ReactElement): string {
+ const t = element.type as any;
+ // cases: native dom element, named (class) component, named function component, classname
+ return typeof t === 'string'
+ ? t
+ : t?.displayName ?? t?.name ?? t?.constructor?.name ?? 'unknown';
+}
+
+export function withTrackingScope(component: T): T;
+export function withTrackingScope(Component: any) {
+ return function WithTrackingScope(props: any) {
+ const scope =
+ Component.displayName ?? Component.name ?? Component.constructor?.name;
+ if (!scope) {
+ throw new Error('Failed to find component name for trackingScope');
+ }
+ return (
+
+
+
+ );
+ };
+}
diff --git a/desktop/flipper-plugin/src/ui/__tests__/Tracked.node.tsx b/desktop/flipper-plugin/src/ui/__tests__/Tracked.node.tsx
new file mode 100644
index 000000000..10e2153d7
--- /dev/null
+++ b/desktop/flipper-plugin/src/ui/__tests__/Tracked.node.tsx
@@ -0,0 +1,300 @@
+/**
+ * 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.
+ *
+ * @format
+ */
+
+import {render, fireEvent} from '@testing-library/react';
+import {TestUtils} from 'flipper-plugin';
+import {sleep} from 'flipper-plugin/src/utils/sleep';
+import React, {Component} from 'react';
+import {
+ setGlobalInteractionReporter,
+ resetGlobalInteractionReporter,
+ InteractionReport,
+ Tracked,
+ wrapInteractionHandler,
+ describeElement,
+ TrackingScope,
+ withTrackingScope,
+} from '../Tracked';
+
+let events: InteractionReport[] = [];
+
+beforeEach(() => {
+ events = [];
+ setGlobalInteractionReporter((e) => {
+ e.duration = 0; // avoid test unstability
+ e.totalDuration = 0;
+ events.push(e);
+ });
+});
+
+afterEach(() => {
+ resetGlobalInteractionReporter();
+});
+
+test('Tracked button', () => {
+ const rendering = render(
+
+
+ ,
+ );
+
+ fireEvent.click(rendering.getByTestId('test'));
+ expect(events[0]).toEqual({
+ action: ``,
+ componentType: 'button',
+ duration: 0,
+ totalDuration: 0,
+ error: undefined,
+ event: 'onClick',
+ scope: 'Flipper',
+ success: 1,
+ });
+});
+
+test('Tracked button - custom handler', () => {
+ const rendering = render(
+
+
+ ,
+ );
+
+ fireEvent.doubleClick(rendering.getByTestId('test'));
+ expect(events[0]).toEqual({
+ action: ``,
+ componentType: 'button',
+ duration: 0,
+ totalDuration: 0,
+ error: undefined,
+ event: 'onDoubleClick',
+ scope: 'Flipper',
+ success: 1,
+ });
+});
+
+test('Throwing action', () => {
+ const fn = wrapInteractionHandler(
+ () => {
+ throw new Error('Oops');
+ },
+ ,
+ 'click',
+ 'test',
+ );
+
+ expect(() => {
+ fn();
+ }).toThrow('Oops');
+ expect(events[0]).toEqual({
+ action: ``,
+ componentType: 'button',
+ duration: 0,
+ totalDuration: 0,
+ error: 'Error: Oops',
+ event: 'click',
+ scope: 'test',
+ success: 0,
+ });
+});
+
+test('Async action', async () => {
+ const fn = wrapInteractionHandler(
+ async () => {
+ Promise.resolve(3);
+ },
+ ,
+ 'click',
+ 'test',
+ );
+
+ const res = fn();
+ expect(typeof fn).toBe('function');
+ await res;
+ expect(events[0]).toEqual({
+ action: ``,
+ componentType: 'button',
+ duration: 0,
+ totalDuration: 0,
+ error: undefined,
+ event: 'click',
+ scope: 'test',
+ success: 1,
+ });
+});
+
+test('Throwing async action', async () => {
+ const fn = wrapInteractionHandler(
+ async () => {
+ throw new Error('Oops');
+ },
+ ,
+ 'click',
+ 'test',
+ );
+
+ const res = fn();
+ expect(typeof fn).toBe('function');
+ let error = undefined;
+ try {
+ await res;
+ } catch (e) {
+ error = e;
+ }
+ expect('' + error).toBe(`Error: Oops`);
+ expect(events[0]).toEqual({
+ action: ``,
+ componentType: 'button',
+ duration: 0,
+ totalDuration: 0,
+ error: `Error: Oops`,
+ event: 'click',
+ scope: 'test',
+ success: 0,
+ });
+});
+
+test('timing', async () => {
+ let data: InteractionReport | undefined = undefined;
+ setGlobalInteractionReporter((e) => {
+ data = e;
+ });
+
+ const fn = wrapInteractionHandler(
+ async () => {
+ const start = Date.now();
+ while (Date.now() - start < 500) {
+ // nothing
+ }
+ await sleep(1000);
+ },
+ ,
+ 'click',
+ 'test',
+ );
+ await fn();
+ expect(data!.duration > 100).toBe(true);
+ expect(data!.duration < 1000).toBe(true);
+ expect(data!.totalDuration > 800).toBe(true);
+ expect(data!.totalDuration < 2000).toBe(true);
+});
+
+test('describeElement', () => {
+ // String only child
+ expect(describeElement()).toBe('Hi!');
+ // title
+ expect(
+ describeElement(
+ ,
+ ),
+ ).toBe('b');
+ // key + text
+ expect(describeElement()).toBe('Hi!');
+ // Rich JSX
+ expect(
+ describeElement(
+ ,
+ ),
+ ).toBe('');
+ // Rich JSX with key
+ expect(
+ describeElement(
+ ,
+ ),
+ ).toBe('test');
+});
+
+test('Scoped Tracked button', () => {
+ const rendering = render(
+
+
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(rendering.getByTestId('test'));
+ expect(events[0].scope).toEqual('outer:inner');
+});
+
+test('Scoped Tracked button in plugin', () => {
+ const res = TestUtils.renderPlugin({
+ plugin() {
+ return {};
+ },
+ Component() {
+ return (
+
+
+
+
+
+
+
+ );
+ },
+ });
+
+ fireEvent.click(res.renderer.getByTestId('test'));
+ expect(events[0].scope).toEqual('plugin:TestPlugin:outer:inner');
+});
+
+test('withScope - fn', () => {
+ const MyCoolComponent = withTrackingScope(function MyCoolComponent() {
+ return (
+
+
+
+ );
+ });
+
+ const res = TestUtils.renderPlugin({
+ plugin() {
+ return {};
+ },
+ Component() {
+ return ;
+ },
+ });
+
+ fireEvent.click(res.renderer.getByTestId('test'));
+ expect(events[0].scope).toEqual('plugin:TestPlugin:MyCoolComponent');
+});
+
+test('withScope - class', () => {
+ const MyCoolComponent = withTrackingScope(
+ class MyCoolComponent extends Component {
+ render() {
+ return (
+
+
+
+ );
+ }
+ },
+ );
+
+ const res = TestUtils.renderPlugin({
+ plugin() {
+ return {};
+ },
+ Component() {
+ return ;
+ },
+ });
+
+ fireEvent.click(res.renderer.getByTestId('test'));
+ expect(events[0].scope).toEqual('plugin:TestPlugin:MyCoolComponent');
+});
diff --git a/desktop/flipper-plugin/src/utils/sleep.tsx b/desktop/flipper-plugin/src/utils/sleep.tsx
new file mode 100644
index 000000000..312ce9cc1
--- /dev/null
+++ b/desktop/flipper-plugin/src/utils/sleep.tsx
@@ -0,0 +1,14 @@
+/**
+ * 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.
+ *
+ * @format
+ */
+
+export async function sleep(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx
index 845bae1e5..8e593c1b5 100644
--- a/docs/extending/flipper-plugin.mdx
+++ b/docs/extending/flipper-plugin.mdx
@@ -365,6 +365,20 @@ See `View > Flipper Style Guide` inside the Flipper application for more details
An element that can be used to provide a New User eXperience: Hints that give a one time introduction to new features to the current user.
See `View > Flipper Style Guide` inside the Flipper application for more details.
+### Tracked
+
+An element that can be used to track user interactions.
+See `View > Flipper Style Guide` inside the Flipper application for more details.
+
+### TrackingScope
+
+Defines the location of underlying Tracked elements more precisely.
+See `View > Flipper Style Guide` inside the Flipper application for more details.
+
+### withTrackingScope
+
+Higher order component that wraps a component automatically in a [`TrackingScope`](#TrackingScope) using the component name as `scope`.
+
### theme object
Provides a standard set of colors and spacings, used by the Flipper style guide.