Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { useCallback, useEffect, useRef } from "react";
import { batch } from "react-redux";
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
const IMPRESSION_THRESHOLD = 0.3;
/**
* Shared telemetry hook for function-component widgets. Returns recorders
* for the four widget telemetry actions (impression, user event, enabled,
* error) so call sites don't hand-build payloads. `widget` is a
* WIDGET_REGISTRY entry; `widget.telemetryName` becomes `widget_name`.
*
* const {
* impressionRef,
* recordImpression,
* recordUserAction,
* recordEnabled,
* recordError,
* } = useWidgetTelemetry({ dispatch, widget, widgetSize });
*
* <article ref={impressionRef}>...</article>
* recordUserAction("learn_more", { source: "context_menu" });
*
* Per-call options on `recordUserAction`: `value` (action_value), `size`
* (overrides widgetSize), `alsoToMain: true` (routes via AlsoToMain), and
* `legacy: true` (co-dispatches `legacyUserEventType` with `{ userAction }`).
*
* `recordImpression()` is a manual one-shot fire that shares the observer's
* impressionFired guard; useful when a widget needs to record an impression
* outside the IntersectionObserver path.
*
* Constructor `legacyImpressionTypes` (array) and `legacyUserEventType`
* bridge the Bug 2012779 transition: while WIDGETS_TIMER_* / WIDGETS_LISTS_*
* legacy events still exist alongside the unified events, FocusTimer and
* Lists pass the matching legacy action types so the hook emits both. Both
* co-dispatches fire legacy first, unified second.
*/
export const useWidgetTelemetry = ({
dispatch,
widget,
widgetSize,
legacyImpressionTypes,
legacyUserEventType,
}) => {
const { telemetryName } = widget;
const sizeRef = useRef(widgetSize);
useEffect(() => {
sizeRef.current = widgetSize;
}, [widgetSize]);
// Legacy bridge types are fixed per call site, so capture once at mount;
// refs keep them out of the recorder callbacks' dependency arrays.
const legacyImpressionTypesRef = useRef(legacyImpressionTypes);
const legacyUserEventTypeRef = useRef(legacyUserEventType);
const buildPayload = useCallback(
({ size, rest } = {}) => ({
widget_name: telemetryName,
widget_size: size ?? sizeRef.current,
...rest,
}),
[telemetryName]
);
const impressionFired = useRef(false);
const fireImpression = useCallback(
size => {
if (impressionFired.current) {
return;
}
impressionFired.current = true;
const data = buildPayload({ size });
const legacyTypes = legacyImpressionTypesRef.current;
if (legacyTypes && legacyTypes.length) {
batch(() => {
// Legacy first, then unified, matching the pre-hook dispatch order
// in FocusTimer / Lists so existing tests don't need to flip.
for (const type of legacyTypes) {
dispatch(ac.AlsoToMain({ type }));
}
dispatch(ac.AlsoToMain({ type: at.WIDGETS_IMPRESSION, data }));
});
} else {
dispatch(ac.AlsoToMain({ type: at.WIDGETS_IMPRESSION, data }));
}
},
[dispatch, buildPayload]
);
// The observer owns observation directly so the callback ref can attach to
// elements that mount after the initial render (e.g. widgets that gate
// rendering on a Redux pref or async data).
const observerRef = useRef(null);
const observedEl = useRef(null);
useEffect(() => {
if (typeof IntersectionObserver === "undefined") {
return undefined;
}
const observer = new IntersectionObserver(
entries => {
// Filter to the currently-observed element so a queued callback for a
// previously-unobserved ref target doesn't fire a stale impression.
if (
entries.some(e => e.isIntersecting && e.target === observedEl.current)
) {
fireImpression();
observer.disconnect();
}
},
{ threshold: IMPRESSION_THRESHOLD }
);
observerRef.current = observer;
if (observedEl.current && !impressionFired.current) {
observer.observe(observedEl.current);
}
return () => {
observer.disconnect();
observerRef.current = null;
};
}, [fireImpression]);
const impressionRef = useCallback(el => {
if (observedEl.current === el) {
return;
}
const observer = observerRef.current;
if (observedEl.current && observer) {
observer.unobserve(observedEl.current);
}
observedEl.current = el;
if (el && observer && !impressionFired.current) {
observer.observe(el);
}
}, []);
const recordImpression = useCallback(
({ size } = {}) => {
fireImpression(size);
},
[fireImpression]
);
const recordUserAction = useCallback(
(userAction, { source, value, size, alsoToMain, legacy } = {}) => {
const route = alsoToMain ? ac.AlsoToMain : ac.OnlyToMain;
const rest = {
widget_source: source,
user_action: userAction,
};
if (value !== undefined) {
rest.action_value = value;
}
const data = buildPayload({ size, rest });
const main = route({ type: at.WIDGETS_USER_EVENT, data });
const legacyType = legacy ? legacyUserEventTypeRef.current : null;
if (legacyType) {
// Legacy first, then unified, matching the pre-hook dispatch order
// in FocusTimer / Lists so existing tests don't need to flip.
const legacyAction = route({
type: legacyType,
data: { userAction },
});
batch(() => {
dispatch(legacyAction);
dispatch(main);
});
} else {
dispatch(main);
}
},
[dispatch, buildPayload]
);
const recordEnabled = useCallback(
(enabled, { source, size } = {}) => {
const data = buildPayload({
size,
rest: { widget_source: source, enabled },
});
dispatch(ac.OnlyToMain({ type: at.WIDGETS_ENABLED, data }));
},
[dispatch, buildPayload]
);
const recordError = useCallback(
(errorType, { size } = {}) => {
const data = buildPayload({ size, rest: { error_type: errorType } });
dispatch(ac.AlsoToMain({ type: at.WIDGETS_ERROR, data }));
},
[dispatch, buildPayload]
);
return {
impressionRef,
recordImpression,
recordUserAction,
recordEnabled,
recordError,
};
};