/**
* 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, {useMemo} 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';
export const TrackingScopeContext = createContext(DEFAULT_SCOPE);
export function TrackingScope({
scope,
children,
}: {
scope: string;
children: React.ReactNode;
}) {
const baseScope = useContext(TrackingScopeContext);
return (
{children}
);
}
/**
* Gives the name of the current scope that is currently rendering.
* Typically the current plugin id, but can be further refined by using TrackingScopes
*/
export function useCurrentScopeName(): string {
return useContext(TrackingScopeContext);
}
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 = useCurrentScopeName();
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;
}
export function useTrackedCallback(
action: string,
fn: T,
deps: any[],
): T {
const scope = useContext(TrackingScopeContext);
return useMemo(() => {
return wrapInteractionHandler(fn, null, '', scope, action);
// eslint-disable-next-line
}, deps) as any;
}
export function wrapInteractionHandler(
fn: T,
element: React.ReactElement | null | string,
event: string,
scope: string,
action?: string,
): T {
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:
element === null
? 'unknown'
: typeof element === 'string'
? element
: describeElementType(element),
action:
action ??
(element && typeof element != 'string'
? describeElement(element)
: 'unknown'),
scope,
event,
});
}
const res = 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;
} as any;
res.flipperTracked = true; // Avoid double wrapping / handling, if e.g. Button is wrapped in Tracked
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 stringifyElement(element);
}
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 || typeof scope !== 'string') {
throw new Error('Failed to find component name for trackingScope');
}
return (
);
};
}
// @ts-ignore
global.FlipperTrackingScopeContext = TrackingScopeContext;
//@ts-ignore
global.FlipperTracked = Tracked;
// @ts-ignore
global.flipperTrackInteraction = function flipperTrackInteraction(
elementType: string,
event: string,
scope: string,
action: string | React.ReactElement | null,
fn: Function,
...args: any[]
): void {
// @ts-ignore
if (fn.flipperTracked) {
return fn(...args);
}
return wrapInteractionHandler(
fn,
elementType,
event,
scope,
!action
? 'unknown action'
: typeof action === 'string'
? action
: stringifyElement(action),
)(...args);
};
function stringifyElement(element: any): string {
if (!element) return 'unknown element';
if (typeof element === 'string') return element;
if (Array.isArray(element))
return element.filter(Boolean).map(stringifyElement).join('');
return reactElementToJSXString(element).substr(0, 200).replace(/\n/g, ' ');
}