posthog-js
Version:
Posthog-js allows you to automatically capture usage and send events to PostHog.
794 lines • 50.3 kB
JavaScript
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __values = (this && this.__values) || function(o) {
var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
if (m) return m.call(o);
if (o && typeof o.length === "number") return {
next: function () {
if (o && i >= o.length) o = void 0;
return { value: o && o[i++], done: !o };
}
};
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
};
Object.defineProperty(exports, "__esModule", { value: true });
var globals_1 = require("@posthog/browser-common/utils/globals");
var globals_2 = require("../utils/globals");
var core_1 = require("@posthog/core");
var autocapture_utils_1 = require("@posthog/browser-common/utils/autocapture-utils");
var autocapture_1 = require("../autocapture");
var element_utils_1 = require("@posthog/browser-common/utils/element-utils");
var prototype_utils_1 = require("@posthog/browser-common/utils/prototype-utils");
var general_utils_1 = require("@posthog/browser-common/utils/general-utils");
function asCandidate(event, extra) {
var eventTarget = (0, autocapture_utils_1.getEventTarget)(event);
if (eventTarget) {
return __assign({ node: eventTarget, originalEvent: event, timestamp: Date.now() }, extra);
}
return null;
}
// Firefox denies property access on a node from another origin or from a realm that was torn
// down, and both a composed path and a mutation record can hand us one. A node we cannot read
// is a node we cannot inspect, so answer no instead of letting the denial escape.
function isInspectableElement(node) {
return !!(0, core_1.trySafe)(function () { return (0, element_utils_1.isElementNode)(node); });
}
function swipeDirection(dx, dy) {
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0 ? 'right' : 'left';
}
return dy >= 0 ? 'down' : 'up';
}
// the event name doubles as the property prefix ($dead_click_* / $dead_swipe_*)
function deadEventName(candidate) {
return candidate.type === 'swipe' ? '$dead_swipe' : '$dead_click';
}
// surfaces that respond to a gesture without anything our observers can see — a canvas or
// WebGL repaint is not a DOM mutation, and native media controls live in a closed UA shadow
// root — so a swipe over them can never be judged dead
var UNOBSERVABLE_SURFACE_SELECTOR = 'canvas,video,audio,embed,object';
// a click within this window of a visibility or window focus/blur change is treated as the click
// that woke/focused the tab (or opened a new tab/window) and suppressed rather than flagged dead.
// wider than the other thresholds because a real "tab back, then click the page" gesture has a
// human-scale gap (refocus, move the mouse, click); still short enough that a genuinely dead click
// a while after refocusing is caught.
var LIVENESS_SUPPRESSION_MS = 1000;
function hasModifierKey(event) {
return event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
}
function checkTimeout(value, thresholdMs) {
return (0, core_1.isNumber)(value) && value >= thresholdMs;
}
// a liveness transition only counts if it fired within the suppression window on one side of the
// click. this is the single definition of "in window" — it gates every recorded delay, so once a
// delay lands on a candidate `_checkClicks` can trust it is already in range.
function livenessDelayInWindow(delay, windowMs) {
if (windowMs === void 0) { windowMs = LIVENESS_SUPPRESSION_MS; }
return delay >= 0 && delay < windowMs ? delay : undefined;
}
// a liveness signal that fired shortly BEFORE the click. read once when the candidate is queued;
// only a change inside the suppression window counts, so a long-ago transition cannot affect it.
function priorLivenessDelay(clickTimestamp, lastSeenAt, windowMs) {
if (windowMs === void 0) { windowMs = LIVENESS_SUPPRESSION_MS; }
return (0, core_1.isNumber)(lastSeenAt) ? livenessDelayInWindow(clickTimestamp - lastSeenAt, windowMs) : undefined;
}
// How dead-click detection works
// ================================
// A click (or swipe) is queued as a candidate, then re-examined ~1s later in `_checkClicks`. It is
// reported as a `$dead_click` only if NO liveness/suppression signal fired within its applicable
// window AND at least one timeout signal fired. In short: something-happened-fast wins
// (alive), otherwise nothing-happened-in-time loses (dead).
//
// Gate signals (checked in `_ignore` before the click is ever queued — never even a candidate):
// - element is inside the PostHog toolbar
// - same node clicked again within 1s (dedupe of repeated clicks)
// - target is the <html> node or not an element
// - target matches `css_selector_ignorelist`
// - a modifier key is held (ctrl/meta/alt/shift) unless `capture_clicks_with_modifier_keys`
// - (clicks only) target is an anchor — a legitimate activation; swipes are still candidates
//
// Liveness / suppression signals (any one, within its window => alive, dropped).
// These say "the click did something", so they only ever suppress; none can mark a click dead:
// - mutation: a DOM mutation < mutation_threshold_ms (default 2500)
// - scroll: the page/an element scrolled < scroll_threshold_ms (default 100)
// - selection: selecting/unselecting a range or moving an editable caret during the matching
// mouse gesture, or < selection_change_threshold_ms (default 100) either side of the click
// - visibility: a visibilitychange (either direction — the tab going hidden because the click
// opened a new tab, or becoming visible as the click woke/focused it)
// < LIVENESS_SUPPRESSION_MS on either side of the click
// - focus: a window focus/blur (a click that opened a new window/popup may only surface as
// the current window losing focus) < LIVENESS_SUPPRESSION_MS on either side
// selection/visibility/focus are recorded on each candidate, not read from a single shared
// timestamp when the click is checked. A pre-click selection is stored only inside its window;
// post-click changes keep the closest delay. For visibility/focus this also matters because
// hiding/blurring the tab can suspend the check until a later transition overwrites it.
//
// Timeout signals (a click with no liveness signal is dead if any one fired). Note visibility and
// focus are deliberately absent here — they are liveness-only and never mark a click dead:
// - mutation timeout: a mutation, but only after mutation_threshold_ms
// - scroll timeout: a scroll, but only after scroll_threshold_ms
// - selection timeout: a selectionchange, but only after selection_change_threshold_ms
// - absolute timeout: nothing at all within mutation_threshold_ms * 1.1 (the catch-all backstop)
var LazyLoadedDeadClicksAutocapture = /** @class */ (function () {
function LazyLoadedDeadClicksAutocapture(instance, config) {
var _this = this;
this.instance = instance;
this._observedRoots = new WeakSet();
this._hadSelection = false;
this._clicks = [];
this._deadSwipesCaptured = 0;
this._defaultConfig = function (defaultOnCapture) { return ({
element_attribute_ignorelist: [],
scroll_threshold_ms: 100,
selection_change_threshold_ms: 100,
mutation_threshold_ms: 2500,
capture_clicks_with_modifier_keys: false,
capture_dead_swipes: true,
swipe_threshold_px: 30,
max_dead_swipes_per_page_load: 10,
__onCapture: defaultOnCapture,
}); };
this._clearMouseSelection = function () {
clearTimeout(_this._mouseSelectionTimer);
_this._mouseSelectionTimer = undefined;
_this._mouseSelection = undefined;
};
this._onMouseDown = function (event) {
_this._observeGesturePath(event);
_this._clearMouseSelection();
if (event.button === 0) {
var gesture = {
path: event.composedPath(),
trusted: event.isTrusted,
selectionChanged: false,
selectionInitiallyRelated: false,
};
var selection = globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.getSelection();
gesture.selectionInitiallyRelated =
(selection === null || selection === void 0 ? void 0 : selection.type) === 'Range' && _this._selectionTouchesGesture(selection, gesture);
_this._mouseSelection = gesture;
}
};
this._onMouseOut = function (event) {
// A release outside the document may never reach our mouseup listener.
if ((0, core_1.isNull)(event.relatedTarget)) {
_this._clearMouseSelection();
}
};
this._onMouseUp = function (event) {
var e_1, _a;
var gesture = _this._mouseSelection;
var mouseEvent = event;
if (!gesture || mouseEvent.button !== 0 || event.isTrusted !== gesture.trusted) {
return;
}
var path = event.composedPath();
try {
// Native clicks target the nearest common ancestor of the press and release targets.
// Composed paths preserve this relationship through accessible shadow boundaries.
for (var _b = __values(gesture.path), _c = _b.next(); !_c.done; _c = _b.next()) {
var node = _c.value;
if (isInspectableElement(node) && path.indexOf(node) !== -1) {
gesture.clickTarget = node;
break;
}
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (_c && !_c.done && (_a = _b.return)) _a.call(_b);
}
finally { if (e_1) throw e_1.error; }
}
gesture.clickDetail = mouseEvent.detail;
// Mouseup and its native click are dispatched together. If no click follows, discard
// the released gesture before a later activation can inherit its selection activity.
_this._mouseSelectionTimer = globals_2.assignableWindow.setTimeout(_this._clearMouseSelection, 0);
};
this._onClick = function (event) {
// a click without a preceding mousedown, e.g. a keyboard activation, reaches us here first
_this._observeGesturePath(event);
var mouseEvent = event;
var click = asCandidate(mouseEvent, { type: 'click' });
var gesture = _this._mouseSelection;
if (click &&
gesture &&
mouseEvent.button === 0 &&
mouseEvent.detail > 0 &&
mouseEvent.detail === gesture.clickDetail &&
event.isTrusted === gesture.trusted &&
click.node === gesture.clickTarget) {
click.selectionChangedDuringGesture =
gesture.selectionChanged && _this._config.selection_change_threshold_ms > 0;
_this._clearMouseSelection();
}
if (!(0, core_1.isNull)(click) && !_this._ignore(click)) {
_this._queueCandidate(click);
}
_this._scheduleCheck();
};
this._onScroll = function () {
var candidateNow = Date.now();
_this._lastScroll = candidateNow;
// very naive throttle
if (candidateNow % 50 === 0) {
// we can see many scrolls between scheduled checks,
// so we update scroll delay as we see them
// to avoid false positives
_this._clicks.forEach(function (click) {
if ((0, core_1.isUndefined)(click.scrollDelayMs)) {
click.scrollDelayMs = candidateNow - click.timestamp;
}
});
}
};
this._onSelectionChange = function (event) {
var _a;
var selection = globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.getSelection();
var hadSelection = _this._hadSelection;
// isCollapsed can be true for a range inside a shadow root; type reflects the actual selection.
_this._hadSelection = (selection === null || selection === void 0 ? void 0 : selection.type) === 'Range';
var focusNode = selection === null || selection === void 0 ? void 0 : selection.focusNode;
var focusElement = focusNode && ((0, element_utils_1.isElementNode)(focusNode) ? focusNode : focusNode.parentElement);
// Selection endpoints may be retargeted outside an editor's shadow root.
var documentActiveElement = globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.activeElement;
var activeElement = documentActiveElement;
while ((_a = activeElement === null || activeElement === void 0 ? void 0 : activeElement.shadowRoot) === null || _a === void 0 ? void 0 : _a.activeElement) {
activeElement = activeElement.shadowRoot.activeElement;
}
var isEditing = (0, autocapture_utils_1.isTextSelectionTarget)((0, autocapture_utils_1.getEventTarget)(event)) ||
(0, autocapture_utils_1.isTextSelectionTarget)(focusElement !== null && focusElement !== void 0 ? focusElement : null) ||
(0, autocapture_utils_1.isTextSelectionTarget)(activeElement !== null && activeElement !== void 0 ? activeElement : null);
// A caret reported outside focused content may belong to a closed shadow root.
// Preserve that activity when its actual target cannot be inspected.
var hasRetargetedCaret = (selection === null || selection === void 0 ? void 0 : selection.type) === 'Caret' &&
activeElement &&
!activeElement.shadowRoot &&
focusNode &&
focusNode !== documentActiveElement &&
focusNode.contains(documentActiveElement !== null && documentActiveElement !== void 0 ? documentActiveElement : null);
// Ordinary clicks can move an invisible caret on known non-editable content.
if (!hadSelection && !_this._hadSelection && !isEditing && !hasRetargetedCaret) {
return;
}
var firedAt = Date.now();
_this._lastSelectionChanged = firedAt;
var gesture = _this._mouseSelection;
// An opaque closed-root caret is only a possible editor. Keep its existing timed
// fallback, but do not extend that uncertainty across a long press on inert content.
if (gesture && (hadSelection || _this._hadSelection || isEditing)) {
var eventTarget = (0, autocapture_utils_1.getEventTarget)(event);
var editor = void 0;
if ((0, autocapture_utils_1.isTextSelectionTarget)(eventTarget)) {
editor = eventTarget;
}
else if ((selection === null || selection === void 0 ? void 0 : selection.type) === 'Caret') {
if ((0, autocapture_utils_1.isTextSelectionTarget)(activeElement !== null && activeElement !== void 0 ? activeElement : null) || hasRetargetedCaret) {
editor = activeElement;
}
else if (isEditing) {
editor = focusElement;
}
}
// Endpoint-less clearing must belong to the range present at press time; an
// unrelated range removed elsewhere in the document must not suppress this click.
var related = editor
? _this._selectionIsInGesture(editor, gesture)
: selection && (focusNode || selection.anchorNode)
? _this._selectionTouchesGesture(selection, gesture)
: hadSelection && !_this._hadSelection && gesture.selectionInitiallyRelated;
gesture.selectionChanged = gesture.selectionChanged || related;
}
// Keep the closest selection change after each candidate. A nearby change suppresses the
// candidate; a later one retains the existing post-click timeout behavior.
_this._clicks.forEach(function (candidate) {
var delay = firedAt - candidate.timestamp;
if (delay >= 0 &&
((0, core_1.isUndefined)(candidate.selectionChangedDelayMs) || delay < candidate.selectionChangedDelayMs)) {
candidate.selectionChangedDelayMs = delay;
}
});
};
this._onVisibilityChange = function () {
if ((globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.visibilityState) === 'hidden') {
_this._clearMouseSelection();
}
// record both directions: a tab going _hidden_ right after a click (the click opened a new
// tab) is as much a liveness signal as it becoming visible (the click that woke the tab).
// stamp queued candidates now, before a hidden tab can suspend `_checkClicks`.
var firedAt = Date.now();
_this._lastVisibilityChange = firedAt;
_this._recordLivenessSignal('visibilityChangedDelayMs', firedAt);
};
this._onFocusChange = function () {
var firedAt = Date.now();
_this._lastFocusChange = firedAt;
_this._recordLivenessSignal('focusChangedDelayMs', firedAt);
};
this._onTouchStart = function (event) {
_this._observeGesturePath(event);
var touches = event.touches;
// only single-finger gestures are swipes; a second finger (pinch/zoom) is not,
// so a multi-touch start clears any tracked origin rather than measuring against it
var touch = (touches === null || touches === void 0 ? void 0 : touches.length) === 1 ? touches[0] : undefined;
_this._touchStart = touch ? { x: touch.clientX, y: touch.clientY, timestamp: Date.now() } : undefined;
};
this._onTouchCancel = function () {
_this._touchStart = undefined;
};
this._onTouchEnd = function (event) {
var _a, _b, _c;
var start = _this._touchStart;
_this._touchStart = undefined;
if ((0, core_1.isUndefined)(start) || _this._deadSwipesCaptured >= _this._config.max_dead_swipes_per_page_load) {
return;
}
var touchEvent = event;
var touch = (_a = touchEvent.changedTouches) === null || _a === void 0 ? void 0 : _a[0];
if (!touch) {
return;
}
var dx = touch.clientX - start.x;
var dy = touch.clientY - start.y;
var distanceSq = dx * dx + dy * dy;
// compare squared distances to avoid a sqrt on every touch; only the actual swipes need it
var threshold = _this._config.swipe_threshold_px;
if (distanceSq < threshold * threshold) {
// a short movement is a tap or a jitter, not a swipe
return;
}
// a gesture that already did something while the finger was down — dragging scrolled
// the page, a finger-following carousel mutated the DOM, a text selection grew — is not
// dead, so it never becomes a candidate. surviving candidates keep the touchend
// timestamp: the thresholds in _checkClicks measure how quickly the page responds
// after the finger lifts, just as they measure the response to a click
var gestureCausedActivity = [_this._lastScroll, _this._lastMutation, _this._lastSelectionChanged].some(function (lastActivityAt) { return (0, core_1.isNumber)(lastActivityAt) && lastActivityAt >= start.timestamp; });
if (gestureCausedActivity) {
return;
}
// hit-test the stack under where the finger landed rather than the candidate's
// ancestors: canvas apps usually receive their touches through transparent overlay
// elements stacked on top of the canvas, so the surface is beneath, not above.
// this runs at most once per dead-candidate gesture, when the page is provably idle
// (the activity gate above just passed), so a forced layout flush is unlikely
if (_this._pageHasUnobservableSurfaces()) {
var stack = (_c = (_b = globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.elementsFromPoint) === null || _b === void 0 ? void 0 : _b.call(globals_1.document, start.x, start.y)) !== null && _c !== void 0 ? _c : [];
if (stack.some(function (el) { var _a; return (_a = el.matches) === null || _a === void 0 ? void 0 : _a.call(el, UNOBSERVABLE_SURFACE_SELECTOR); })) {
return;
}
}
var swipe = asCandidate(touchEvent, {
type: 'swipe',
swipeDirection: swipeDirection(dx, dy),
swipeDistancePx: Math.round(Math.sqrt(distanceSq)),
});
if (!(0, core_1.isNull)(swipe) && !_this._ignore(swipe)) {
_this._queueCandidate(swipe);
}
_this._scheduleCheck();
};
this._config = this._asRequiredConfig(config);
this._onCapture = this._config.__onCapture;
}
LazyLoadedDeadClicksAutocapture.prototype._asRequiredConfig = function (providedConfig) {
var _a, _b, _c, _d, _e, _f, _g, _h;
var defaultConfig = this._defaultConfig((providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.__onCapture) || this._captureDeadClick.bind(this));
return {
element_attribute_ignorelist: (_a = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.element_attribute_ignorelist) !== null && _a !== void 0 ? _a : defaultConfig.element_attribute_ignorelist,
scroll_threshold_ms: (_b = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.scroll_threshold_ms) !== null && _b !== void 0 ? _b : defaultConfig.scroll_threshold_ms,
selection_change_threshold_ms: (_c = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.selection_change_threshold_ms) !== null && _c !== void 0 ? _c : defaultConfig.selection_change_threshold_ms,
mutation_threshold_ms: (_d = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.mutation_threshold_ms) !== null && _d !== void 0 ? _d : defaultConfig.mutation_threshold_ms,
capture_clicks_with_modifier_keys: (_e = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.capture_clicks_with_modifier_keys) !== null && _e !== void 0 ? _e : defaultConfig.capture_clicks_with_modifier_keys,
capture_dead_swipes: (_f = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.capture_dead_swipes) !== null && _f !== void 0 ? _f : defaultConfig.capture_dead_swipes,
swipe_threshold_px: (_g = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.swipe_threshold_px) !== null && _g !== void 0 ? _g : defaultConfig.swipe_threshold_px,
max_dead_swipes_per_page_load: (_h = providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.max_dead_swipes_per_page_load) !== null && _h !== void 0 ? _h : defaultConfig.max_dead_swipes_per_page_load,
css_selector_ignorelist: providedConfig === null || providedConfig === void 0 ? void 0 : providedConfig.css_selector_ignorelist,
__onCapture: defaultConfig.__onCapture,
};
};
LazyLoadedDeadClicksAutocapture.prototype.start = function (observerTarget) {
this._startClickObserver();
this._startScrollObserver();
this._startSelectionChangedObserver();
this._startVisibilityChangeObserver();
this._startFocusChangeObserver();
this._startMutationObserver(observerTarget);
if (this._config.capture_dead_swipes) {
this._startSwipeObserver();
}
};
LazyLoadedDeadClicksAutocapture.prototype._startMutationObserver = function (observerTarget) {
var _this = this;
if (!this._mutationObserver) {
var NativeMutationObserver = (0, prototype_utils_1.getNativeMutationObserverImplementation)(globals_2.assignableWindow);
this._mutationObserver = new NativeMutationObserver(function (mutations) {
_this._onMutation(mutations);
});
this._observeRoot(observerTarget);
}
};
// An observed subtree stops at a shadow boundary, so a click that re-renders inside a shadow
// root looks like nothing happened. Each root we want changes from needs its own observe call.
LazyLoadedDeadClicksAutocapture.prototype._observeRoot = function (root) {
if (!root || !this._mutationObserver) {
return;
}
if (this._observedRoots.has(root)) {
return;
}
this._mutationObserver.observe(root, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
this._observedRoots.add(root);
this._observeShadowRoots(root);
};
// Observe every open shadow root this node hosts or contains. Nested roots are covered
// because each root observed here is scanned in turn. Every scanned element is added to
// `scanned`, when given, so a caller can skip nodes an earlier scan already covered.
LazyLoadedDeadClicksAutocapture.prototype._observeShadowRoots = function (node, scanned) {
var _a, _b, _c;
if ((0, element_utils_1.isElementNode)(node) && node.shadowRoot) {
this._observeRoot(node.shadowRoot);
}
var descendants = (_c = (_b = (_a = node).querySelectorAll) === null || _b === void 0 ? void 0 : _b.call(_a, '*')) !== null && _c !== void 0 ? _c : [];
for (var i = 0; i < descendants.length; i++) {
scanned === null || scanned === void 0 ? void 0 : scanned.add(descendants[i]);
this._observeRoot(descendants[i].shadowRoot);
}
};
// A gesture inside an open shadow root exposes that root in its composed path, so we can
// watch the root before the application's own handlers render into it.
LazyLoadedDeadClicksAutocapture.prototype._observeGesturePath = function (event) {
var _a, _b;
var path = (_b = (_a = event.composedPath) === null || _a === void 0 ? void 0 : _a.call(event)) !== null && _b !== void 0 ? _b : [];
for (var i = 0; i < path.length; i++) {
var node = path[i];
if (isInspectableElement(node)) {
this._observeRoot(node.shadowRoot);
}
}
};
LazyLoadedDeadClicksAutocapture.prototype.stop = function () {
var _a;
(_a = this._mutationObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
this._mutationObserver = undefined;
this._observedRoots = new WeakSet();
globals_2.assignableWindow.removeEventListener('click', this._onClick, { capture: true });
globals_2.assignableWindow.removeEventListener('mousedown', this._onMouseDown, { capture: true });
globals_2.assignableWindow.removeEventListener('mouseup', this._onMouseUp, { capture: true });
globals_2.assignableWindow.removeEventListener('mouseout', this._onMouseOut, { capture: true });
globals_2.assignableWindow.removeEventListener('dragstart', this._clearMouseSelection, { capture: true });
globals_2.assignableWindow.removeEventListener('pointercancel', this._clearMouseSelection, { capture: true });
globals_2.assignableWindow.removeEventListener('blur', this._clearMouseSelection);
this._clearMouseSelection();
globals_2.assignableWindow.removeEventListener('scroll', this._onScroll, { capture: true });
globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.removeEventListener('selectionchange', this._onSelectionChange);
globals_2.assignableWindow.removeEventListener('touchstart', this._onTouchStart, { capture: true });
globals_2.assignableWindow.removeEventListener('touchend', this._onTouchEnd, { capture: true });
globals_2.assignableWindow.removeEventListener('touchcancel', this._onTouchCancel, { capture: true });
globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.removeEventListener('visibilitychange', this._onVisibilityChange);
globals_2.assignableWindow.removeEventListener('blur', this._onFocusChange);
globals_2.assignableWindow.removeEventListener('focus', this._onFocusChange);
// so a gesture in flight when we stopped can't pair with a touchend after a restart
this._touchStart = undefined;
};
LazyLoadedDeadClicksAutocapture.prototype._onMutation = function (mutations) {
var e_2, _a;
var _b;
// every scan in this batch reads the DOM as it is now, so a node an earlier scan visited
// needs no scan of its own
var scanned = new Set();
try {
for (var mutations_1 = __values(mutations), mutations_1_1 = mutations_1.next(); !mutations_1_1.done; mutations_1_1 = mutations_1.next()) {
var mutation = mutations_1_1.value;
// one record we cannot read must not end the batch: every record behind it would lose
// its sign of life and the timeout backstop would report a false dead click
try {
// a root observed directly keeps reporting after its host leaves the page, and a change
// off the page is no sign of life. only an explicit `false` is skipped, since older
// browsers lack `isConnected`
if (((_b = mutation.target) === null || _b === void 0 ? void 0 : _b.isConnected) === false) {
continue;
}
this._lastMutation = Date.now();
// added content can bring a shadow root of its own, which the observer that reported
// the addition cannot see into
var addedNodes = mutation.addedNodes;
for (var i = 0; i < addedNodes.length; i++) {
var node = addedNodes[i];
if (isInspectableElement(node) && !scanned.has(node)) {
this._observeShadowRoots(node, scanned);
}
}
}
catch (_c) { }
}
}
catch (e_2_1) { e_2 = { error: e_2_1 }; }
finally {
try {
if (mutations_1_1 && !mutations_1_1.done && (_a = mutations_1.return)) _a.call(mutations_1);
}
finally { if (e_2) throw e_2.error; }
}
};
LazyLoadedDeadClicksAutocapture.prototype._startClickObserver = function () {
// Queue the click before application handlers can mutate the DOM and deliver mutation callbacks.
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'click', this._onClick, { capture: true });
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'mousedown', this._onMouseDown, { capture: true });
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'mouseup', this._onMouseUp, { capture: true });
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'mouseout', this._onMouseOut, { capture: true });
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'dragstart', this._clearMouseSelection, { capture: true });
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'pointercancel', this._clearMouseSelection, { capture: true });
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'blur', this._clearMouseSelection);
};
// Only store a pre-candidate selection delay when it is already inside the suppression window,
// so it can never feed the post-click selection timeout. Keep the candidate queued until the
// check so it can still deduplicate another click on the same node.
LazyLoadedDeadClicksAutocapture.prototype._queueCandidate = function (candidate) {
candidate.selectionChangedDelayMs = priorLivenessDelay(candidate.timestamp, this._lastSelectionChanged, this._config.selection_change_threshold_ms);
candidate.visibilityChangedDelayMs = priorLivenessDelay(candidate.timestamp, this._lastVisibilityChange);
candidate.focusChangedDelayMs = priorLivenessDelay(candidate.timestamp, this._lastFocusChange);
this._clicks.push(candidate);
};
LazyLoadedDeadClicksAutocapture.prototype._scheduleCheck = function () {
var _this = this;
if (this._clicks.length && (0, core_1.isUndefined)(this._checkClickTimer)) {
this._checkClickTimer = globals_2.assignableWindow.setTimeout(function () {
_this._checkClicks();
}, 1000);
}
};
// `capture: true` is required to get scroll events for other scrollable elements
// on the page, not just the window
// see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture
//
// `passive: true` is used to tell the browser that the scroll event handler will not call `preventDefault()`
// This allows the browser to optimize scrolling performance by not waiting for our handling of the scroll event
// see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#passive
LazyLoadedDeadClicksAutocapture.prototype._startScrollObserver = function () {
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'scroll', this._onScroll, { capture: true });
};
LazyLoadedDeadClicksAutocapture.prototype._startSelectionChangedObserver = function () {
var _a;
// A selection may already exist when the lazy-loaded detector starts.
this._hadSelection = ((_a = globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.getSelection()) === null || _a === void 0 ? void 0 : _a.type) === 'Range';
// Document selections fire selectionchange directly on document and the event does not bubble.
(0, general_utils_1.addEventListener)(globals_1.document, 'selectionchange', this._onSelectionChange);
};
LazyLoadedDeadClicksAutocapture.prototype._selectionIsInGesture = function (node, gesture) {
var target = gesture.path[0];
try {
while (node) {
if (gesture.path.indexOf(node) !== -1 || ((0, element_utils_1.isElementNode)(target) && target.contains(node))) {
return true;
}
// Firefox can expose selection endpoints behind a closed root while mouse events
// expose only its host. Compare both representations without traversing other content.
node = node.getRootNode().host;
}
}
catch (_a) {
// an endpoint we cannot read is an endpoint we cannot match to the gesture
}
return false;
};
LazyLoadedDeadClicksAutocapture.prototype._selectionTouchesGesture = function (selection, gesture) {
var _a;
var target = gesture.path[0];
var range = selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined;
var sameRoot = !!range && (0, core_1.trySafe)(function () { return range.startContainer.getRootNode() === target.getRootNode(); });
if (range && sameRoot && (0, element_utils_1.isElementNode)(target)) {
if (!range.collapsed) {
return range.intersectsNode(target);
}
// A shadow selection can be represented as a collapsed boundary immediately before
// its host. Resolve that boundary rather than associating its whole parent container.
var node = (0, element_utils_1.isElementNode)(range.startContainer)
? ((_a = range.startContainer.childNodes[range.startOffset]) !== null && _a !== void 0 ? _a : range.startContainer)
: range.startContainer;
return this._selectionIsInGesture(node, gesture);
}
return (this._selectionIsInGesture(selection.focusNode, gesture) ||
this._selectionIsInGesture(selection.anchorNode, gesture));
};
LazyLoadedDeadClicksAutocapture.prototype._startVisibilityChangeObserver = function () {
(0, general_utils_1.addEventListener)(globals_1.document, 'visibilitychange', this._onVisibilityChange);
};
// a click that opens a new window/popup may leave the current tab visible, so its only trace is
// the window losing focus. focus/blur are liveness signals too — they suppress a dead click, and
// like visibility they never cause one.
LazyLoadedDeadClicksAutocapture.prototype._startFocusChangeObserver = function () {
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'blur', this._onFocusChange);
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'focus', this._onFocusChange);
};
// stamp each queued candidate with how long AFTER its click this liveness signal fired, the
// moment the event arrives. recording it immediately — rather than reading a single shared
// timestamp when `_checkClicks` finally runs — is what makes it robust: a hidden tab suspends
// `_checkClicks`, and by the time it resumes a later transition (the tab coming back) would have
// overwritten the click-correlated one. keeps the closest transition, only within the window.
LazyLoadedDeadClicksAutocapture.prototype._recordLivenessSignal = function (field, firedAt) {
this._clicks.forEach(function (click) {
var delay = livenessDelayInWindow(firedAt - click.timestamp);
if ((0, core_1.isNumber)(delay) && ((0, core_1.isUndefined)(click[field]) || delay < click[field])) {
click[field] = delay;
}
});
};
// `capture: true` mirrors the scroll observer so we see gestures on nested scrollable
// elements too. `passive: true` tells the browser we won't call `preventDefault()`.
LazyLoadedDeadClicksAutocapture.prototype._startSwipeObserver = function () {
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'touchstart', this._onTouchStart, { capture: true, passive: true });
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'touchend', this._onTouchEnd, { capture: true, passive: true });
// a cancelled gesture (system back-swipe, incoming call, etc.) never fires touchend,
// so clear the start point to avoid measuring the next gesture from a stale origin
(0, general_utils_1.addEventListener)(globals_2.assignableWindow, 'touchcancel', this._onTouchCancel, { capture: true, passive: true });
};
// whether the page contains surfaces whose response to a gesture we cannot observe —
// cached, recomputed lazily and only when the DOM has mutated since the last look
LazyLoadedDeadClicksAutocapture.prototype._pageHasUnobservableSurfaces = function () {
var domChangedSinceCheck = (0, core_1.isUndefined)(this._surfacesCheckedAt) ||
((0, core_1.isNumber)(this._lastMutation) && this._lastMutation > this._surfacesCheckedAt);
if ((0, core_1.isUndefined)(this._hasUnobservableSurfaces) || domChangedSinceCheck) {
this._surfacesCheckedAt = Date.now();
this._hasUnobservableSurfaces = !!(globals_1.document === null || globals_1.document === void 0 ? void 0 : globals_1.document.querySelector(UNOBSERVABLE_SURFACE_SELECTOR));
}
return !!this._hasUnobservableSurfaces;
};
LazyLoadedDeadClicksAutocapture.prototype._ignore = function (candidate) {
if (!candidate) {
return true;
}
// swipes run only the shared gates; clicks layer their own intent gates on top
return candidate.type === 'swipe' ? this._ignoreCommon(candidate) : this._ignoreClick(candidate);
};
// gates that apply to every dead candidate, whatever the gesture
LazyLoadedDeadClicksAutocapture.prototype._ignoreCommon = function (candidate) {
if ((0, element_utils_1.isElementInToolbar)(candidate.node)) {
return true;
}
var alreadySeenInLastSecond = this._clicks.some(function (c) {
return (c.type === candidate.type &&
c.node === candidate.node &&
Math.abs(c.timestamp - candidate.timestamp) < 1000);
});
if (alreadySeenInLastSecond) {
return true;
}
if ((0, element_utils_1.isTag)(candidate.node, 'html') || !(0, element_utils_1.isElementNode)(candidate.node)) {
return true;
}
return !(0, autocapture_utils_1.shouldCaptureDeadClick)(candidate.node, {
css_selector_ignorelist: this._config.css_selector_ignorelist,
});
};
// click-only gates, layered on top of the shared ones
LazyLoadedDeadClicksAutocapture.prototype._ignoreClick = function (candidate) {
// clicks with modifier keys (open in new tab, etc.) are intentional; touch swipes have no modifiers
if (!this._config.capture_clicks_with_modifier_keys && hasModifierKey(candidate.originalEvent)) {
return true;
}
if (this._ignoreCommon(candidate)) {
return true;
}
// an anchor is a legitimate click activation so we skip it, but a swipe that fails to
// navigate is the signal we want — which is why anchors are skipped for clicks only.
// reaching here means _ignoreCommon confirmed an element node, so this is safe to call
return (0, autocapture_utils_1.shouldSkipDeadClick)(candidate.node);
};
LazyLoadedDeadClicksAutocapture.prototype._checkClicks = function () {
var e_3, _a, _b;
var _c;
if (!this._clicks.length) {
return;
}
clearTimeout(this._checkClickTimer);
this._checkClickTimer = undefined;
var clicksToCheck = this._clicks;
this._clicks = [];
try {
for (var clicksToCheck_1 = __values(clicksToCheck), clicksToCheck_1_1 = clicksToCheck_1.next(); !clicksToCheck_1_1.done; clicksToCheck_1_1 = clicksToCheck_1.next()) {
var click = clicksToCheck_1_1.value;
click.mutationDelayMs =
(_c = click.mutationDelayMs) !== null && _c !== void 0 ? _c : (this._lastMutation && click.timestamp <= this._lastMutation
? this._lastMutation - click.timestamp
: undefined);
click.absoluteDelayMs = Date.now() - click.timestamp;
// Selection/visibility/focus delays are recorded on each candidate as their events fire.
// The pre-candidate directions are handled in `_queueCandidate`. Nothing uses a shared
// timestamp here, so a later event cannot overwrite the change related to this candidate.
var scrollTimeout = checkTimeout(click.scrollDelayMs, this._config.scroll_threshold_ms);
var selectionChangedTimeout = checkTimeout(click.selectionChangedDelayMs, this._config.selection_change_threshold_ms);
var mutationTimeout = checkTimeout(click.mutationDelayMs, this._config.mutation_threshold_ms);
// we want to timeout eventually even if nothing else catches it...
// we leave a little longer than the maximum threshold to give the other checks a chance to catch it
var absoluteTimeout = checkTimeout(click.absoluteDelayMs, this._config.mutation_threshold_ms * 1.1);
var hadScroll = (0, core_1.isNumber)(click.scrollDelayMs) && click.scrollDelayMs < this._config.scroll_threshold_ms;
var hadMutation = (0, core_1.isNumber)(click.mutationDelayMs) && click.mutationDelayMs < this._config.mutation_threshold_ms;
var hadSelectionChange = click.selectionChangedDuringGesture ||
((0, core_1.isNumber)(click.selectionChangedDelayMs) &&
click.selectionChangedDelayMs < this._config.selection_change_threshold_ms);
// visibility/focus delays are only ever recorded when already inside the suppression
// window (see `livenessDelayInWindow`), so their presence alone means "suppress"
var hadVisibilityChange = (0, core_1.isNumber)(click.visibilityChangedDelayMs);
var hadFocusChange = (0, core_1.isNumber)(click.focusChangedDelayMs);
if (hadScroll || hadMutation || hadSelectionChange || hadVisibilityChange || hadFocusChange) {
continue;
}
if (scrollTimeout || mutationTimeout || absoluteTimeout || selectionChangedTimeout) {
var prefix = deadEventName(click);
if (prefix === '$dead_swipe') {
if (this._deadSwipesCaptured >= this._config.max_dead_swipes_per_page_load) {
// the page-load budget for dead swipes is spent — drop, don't requeue
continue;
}
this._deadSwipesCaptured++;
}
this._onCapture(click, (_b = {},
_b["".concat(prefix, "_last_mutation_timestamp")] = this._lastMutation,
_b["".concat(prefix, "_event_timestamp")] = click.timestamp,
_b["".concat(prefix, "_scroll_timeout")] = scrollTimeout,
_b["".concat(prefix, "_mutation_timeout")] = mutationTimeout,
_b["".concat(prefix, "_absolute_timeout")] = absoluteTimeout,
_b["".concat(prefix, "_selection_changed_timeout")] = selectionChangedTimeout,
// visibility changes only ever suppress a dead click, never cause one; kept in the
// payload (always false) so the event shape is unchanged for existing consumers
_b["".concat(prefix, "_visibility_changed_timeout")] = false,
_b));
}
else if (click.absoluteDelayMs < this._config.mutation_threshold_ms) {
// keep waiting until next check
this._clicks.push(click);
}
}
}
catch (e_3_1) { e_3 = { error: e_3_1 }; }
finally {
try {
if (clicksToCheck_1_1 && !clicksToCheck_1_1.done && (_a = clicksToCheck_1.return)) _a.call(clicksToCheck_1);
}
finally { if (e_3) throw e_3.error; }
}
this._scheduleCheck();
};
LazyLoadedDeadClicksAutocapture.prototype._captureDeadClick = function (click, properties) {
var _a;
// TODO need to check safe and captur-able as with autocapture
// TODO autocaputure config
var prefix = deadEventName(click);
this.instance.capture(prefix, __assign(__assign(__assign({}, properties), (0, autocapture_1.autocapturePropertiesForElement)(click.node, {
e: click.originalEvent,
maskAllElementAttributes: this.instance.config.mask_all_element_attributes,
maskAllText: this.instance.config.mask_all_text,
elementAttributeIgnoreList: this._config.element_attribute_ignorelist,
// TRICKY: it appears that we were moving to elementsChainAsString, but the UI still depends on elements, so :shrug:
elementsChainAsString: false,
disableCaptureUrlHashes: this.instance.config.disable_capture_url_hashes,
}).props), (_a = {}, _a["".concat(prefix, "_scroll_delay_ms")] = click.scrollDelayMs, _a["".concat(prefix, "_mutation_delay_ms")] = click.mutationDelayMs, _a["".concat(prefix, "_absolute_delay_ms")] = click.absoluteDelayMs, _a["".concat(prefix, "_selection_changed_delay_ms")] = click.selectionChangedDelayMs, _a["".concat(prefix, "_visibility_changed_delay_ms")] = click.visibilityChangedDelayMs, _a["".concat(prefix, "_focus_changed_delay_ms")] = click.focusChangedDelayMs,
// undefined for clicks (stripped on serialization), like the delay fields above
_a.$dead_swipe_direction = click.swipeDirection, _a.$dead_swipe_distance_px = click.swipeDistancePx, _a)), {
timestamp: new Date(click.timestamp),
});
};
return LazyLoadedDeadClicksAutocapture;
}());
globals_2.assignableWindow.__PosthogExtensions__ = globals_2.assignableWindow.__PosthogExtensions__ || {};
globals_2.assignableWindow.__PosthogExtensions__.initDeadClicksAutocapture = function (ph, config) {
return new LazyLoadedDeadClicksAutocapture(ph, config);
};
exports.default = LazyLoadedDeadClicksAutocapture;
//# sourceMappingURL=dead-clicks-autocapture.js.map