mixpanel-browser
Version:
The official Mixpanel JavaScript browser client library
255 lines (214 loc) • 7.69 kB
JavaScript
import { EV_CHANGE, EV_HASHCHANGE, EV_INPUT, EV_SCROLLEND, EV_SELECT, EV_SUBMIT, EV_TOGGLE, isDefinitelyNonInteractive, logger } from './utils';
import { ShadowDOMObserver } from './shadow-dom-observer';
/** @const */ var DEFAULT_DEAD_CLICK_TIMEOUT_MS = 500;
/** @const */ var INTERACTION_EVENTS = [EV_CHANGE, EV_INPUT, EV_SUBMIT, EV_SELECT, EV_TOGGLE];
/** @const */ var LAYOUT_EVENTS = [EV_SCROLLEND];
/** @const */ var NAVIGATION_EVENTS = [EV_HASHCHANGE];
/** @const */ var MUTATION_OBSERVER_CONFIG = {
characterData: true,
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'hidden', 'checked', 'selected', 'value', 'display', 'visibility']
};
function DeadClickTracker(onDeadClickCallback) {
this.eventListeners = [];
this.mutationObserver = null;
this.shadowDOMObserver = null;
this.isTracking = false;
this.lastChangeEventTimestamp = 0;
this.pendingClicks = [];
this.onDeadClickCallback = onDeadClickCallback;
this.processingActive = false;
this.processingTimeout = null;
}
DeadClickTracker.prototype.addClick = function(event) {
var element = this.shadowDOMObserver && this.shadowDOMObserver.getEventTarget(event);
if (!element) {
element = event['target'] || event['srcElement'];
}
if (!element || isDefinitelyNonInteractive(element)) {
return false;
}
if (this.shadowDOMObserver) {
this.shadowDOMObserver.observeFromEvent(event);
}
this.pendingClicks.push({
element: element,
event: event,
timestamp: Date.now()
});
return true;
};
DeadClickTracker.prototype.trackClick = function(event, config) {
if (!this.isTracking) {
return false;
}
var added = this.addClick(event);
if (added) {
this.triggerProcessing(config);
}
return added;
};
DeadClickTracker.prototype.getDeadClicks = function(config) {
if (this.pendingClicks.length === 0) {
return [];
}
var timeoutMs = config['timeout_ms'];
var now = Date.now();
var clicksToEvaluate = this.pendingClicks.slice(); // Copy array
this.pendingClicks = []; // Clear original
var deadClicks = [];
for (var i = 0; i < clicksToEvaluate.length; i++) {
var click = clicksToEvaluate[i];
if (now - click.timestamp >= timeoutMs) {
// Click has exceeded timeout, check if it's dead by looking for changes after this specific click
if (!this.hasChangesAfter(click.timestamp)) {
deadClicks.push(click);
}
} else {
// Still pending - add back
this.pendingClicks.push(click);
}
}
return deadClicks;
};
DeadClickTracker.prototype.hasChangesAfter = function(timestamp) {
// 100ms tolerance for race condition between when we record the click and the change event
return this.lastChangeEventTimestamp >= (timestamp - 100);
};
DeadClickTracker.prototype.recordChangeEvent = function() {
this.lastChangeEventTimestamp = Date.now();
};
DeadClickTracker.prototype.triggerProcessing = function(config) {
// Prevent multiple concurrent processing chains
if (this.processingActive) {
return;
}
this.processingActive = true;
this.processRecursively(config);
};
DeadClickTracker.prototype.processRecursively = function(config) {
if (!this.isTracking || !this.onDeadClickCallback) {
this.processingActive = false;
return;
}
var timeoutMs = config['timeout_ms'];
var self = this;
this.processingTimeout = setTimeout(function() {
if (!self.processingActive) {
return;
}
var deadClicks = self.getDeadClicks(config);
for (var i = 0; i < deadClicks.length; i++) {
self.onDeadClickCallback(deadClicks[i].event);
}
if (self.pendingClicks.length > 0) {
self.processRecursively(config);
} else {
self.processingActive = false;
}
}, timeoutMs);
};
DeadClickTracker.prototype.startTracking = function() {
if (this.isTracking) {
return;
}
this.isTracking = true;
var self = this;
INTERACTION_EVENTS.forEach(function(event) {
var handler = function() {
self.recordChangeEvent();
};
document.addEventListener(event, handler, { capture: true, passive: true });
self.eventListeners.push({ target: document, event: event, handler: handler, options: { capture: true, passive: true } });
});
NAVIGATION_EVENTS.forEach(function(event) {
var handler = function() {
self.recordChangeEvent();
};
window.addEventListener(event, handler);
self.eventListeners.push({ target: window, event: event, handler: handler });
});
LAYOUT_EVENTS.forEach(function(event) {
var handler = function() {
self.recordChangeEvent();
};
window.addEventListener(event, handler, { passive: true });
self.eventListeners.push({ target: window, event: event, handler: handler, options: { passive: true } });
});
var selectionHandler = function() {
self.recordChangeEvent();
};
document.addEventListener('selectionchange', selectionHandler);
self.eventListeners.push({ target: document, event: 'selectionchange', handler: selectionHandler });
// Set up MutationObserver
if (window.MutationObserver) {
try {
this.mutationObserver = new window.MutationObserver(function() {
self.recordChangeEvent();
});
this.mutationObserver.observe(document.body || document.documentElement, MUTATION_OBSERVER_CONFIG);
} catch (e) {
logger.critical('Error while setting up mutation observer', e);
}
}
// Set up Shadow DOM observer
if (window.customElements) {
try {
this.shadowDOMObserver = new ShadowDOMObserver(
function() {
self.recordChangeEvent();
},
MUTATION_OBSERVER_CONFIG
);
this.shadowDOMObserver.start();
} catch (e) {
logger.critical('Error while setting up shadow DOM observer', e);
this.shadowDOMObserver = null;
}
}
};
DeadClickTracker.prototype.stopTracking = function() {
if (!this.isTracking) {
return;
}
this.isTracking = false;
this.pendingClicks = [];
this.lastChangeEventTimestamp = 0;
this.processingActive = false;
if (this.processingTimeout) {
clearTimeout(this.processingTimeout);
this.processingTimeout = null;
}
// Remove all event listeners
for (var i = 0; i < this.eventListeners.length; i++) {
var listener = this.eventListeners[i];
try {
listener.target.removeEventListener(listener.event, listener.handler, listener.options);
} catch (e) {
logger.critical('Error while removing event listener', e);
}
}
this.eventListeners = [];
if (this.mutationObserver) {
try {
this.mutationObserver.disconnect();
} catch (e) {
logger.critical('Error while disconnecting mutation observer', e);
}
this.mutationObserver = null;
}
if (this.shadowDOMObserver) {
try {
this.shadowDOMObserver.stop();
} catch (e) {
logger.critical('Error while stopping shadow DOM observer', e);
}
this.shadowDOMObserver = null;
}
};
export {
DeadClickTracker,
DEFAULT_DEAD_CLICK_TIMEOUT_MS
};