Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/audience/pixel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ All events fire automatically with no instrumentation required.
| `session_end` | Page unload (`visibilitychange` / `pagehide`) | `sessionId`, `duration` (seconds) |
| `form_submitted` | HTML form submission | `formAction`, `formId`, `formName`, `fieldNames`. `emailHash` at `full` consent only. |
| `link_clicked` | Outbound link click (external domains only) | `linkUrl`, `linkText`, `elementId`, `outbound: true` |
| `scroll_depth` | Scroll milestone reached (25%, 50%, 75%, 90%, 100%) | `depth` (integer). On above-the-fold pages (no scroll possible), fires `depth: 100` with `aboveFold: true` after a 2-second dwell. |
| `scroll_depth` | Scroll milestone reached (25%, 50%, 75%, 90%, 100%) | `depth` (integer). No event fires on pages where the document does not scroll. |

### Disabling specific auto-capture

Expand Down
2 changes: 1 addition & 1 deletion packages/audience/pixel/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@imtbl/pixel",
"description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data",
"version": "0.1.1",
"version": "0.1.2",
"author": "Immutable",
"private": true,
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
Expand Down
124 changes: 10 additions & 114 deletions packages/audience/pixel/src/autocapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,6 @@ afterAll(() => {
});
});

// jsdom doesn't implement ResizeObserver — provide a minimal no-op stub so any
// test that calls setupAutocapture without a specialised mock still works.
// The scroll depth describe block overrides this with a controllable mock.
(global as Record<string, unknown>).ResizeObserver = class {
// eslint-disable-next-line class-methods-use-this
observe() { /* no-op */ }

// eslint-disable-next-line class-methods-use-this
disconnect() { /* no-op */ }
};

describe('autocapture', () => {
let enqueue: jest.Mock;
let consent: ConsentLevel;
Expand Down Expand Up @@ -562,19 +551,11 @@ describe('autocapture', () => {
let rafCallbacks: Array<() => void>;
let originalRAF: typeof requestAnimationFrame;
let originalCAF: typeof cancelAnimationFrame;
let resizeCallback: (() => void) | null;
let originalResizeObserver: typeof ResizeObserver;

function fireResizeObserver() {
resizeCallback?.();
}

beforeEach(() => {
rafCallbacks = [];
originalRAF = window.requestAnimationFrame;
originalCAF = window.cancelAnimationFrame;
resizeCallback = null;
originalResizeObserver = (global as Record<string, unknown>).ResizeObserver as typeof ResizeObserver;

// Mock rAF: collect callbacks, flush manually
let nextId = 1;
Expand All @@ -584,26 +565,11 @@ describe('autocapture', () => {
return id;
});
window.cancelAnimationFrame = jest.fn();

// Mock ResizeObserver. Real RO delivers entries on a microtask/rAF; this
// mock fires synchronously inside observe() and via fireResizeObserver()
// for subsequent triggers. Outcome is equivalent for all current cases,
// but tests won't catch ordering bugs that depend on the async boundary.
(global as Record<string, unknown>).ResizeObserver = class MockResizeObserver {
constructor(cb: () => void) { resizeCallback = cb; }

// eslint-disable-next-line class-methods-use-this
observe() { resizeCallback?.(); }

// eslint-disable-next-line class-methods-use-this
disconnect() { /* no-op */ }
};
});

afterEach(() => {
window.requestAnimationFrame = originalRAF;
window.cancelAnimationFrame = originalCAF;
(global as Record<string, unknown>).ResizeObserver = originalResizeObserver;
});

function flushRAF() {
Expand Down Expand Up @@ -714,17 +680,6 @@ describe('autocapture', () => {
expect(enqueue).toHaveBeenCalledTimes(5);
});

it('does not include aboveFold property on scrollable pages', () => {
setup({ scroll: true });

(window as Record<string, unknown>).scrollY = 375;
window.dispatchEvent(new Event('scroll'));
flushRAF();

expect(enqueue.mock.calls[0][1]).toEqual({ depth: 25 });
expect(enqueue.mock.calls[0][1]).not.toHaveProperty('aboveFold');
});

it('does not fire at consent none', () => {
consent = 'none';
setup({ scroll: true });
Expand Down Expand Up @@ -763,88 +718,29 @@ describe('autocapture', () => {
});
});

describe('above-the-fold pages', () => {
describe('non-scrollable pages', () => {
beforeEach(() => {
// 400px content in a 600px viewport → no scroll
// 400px content in a 600px viewport → document does not scroll.
// Same shape applies to SPAs / pages with internal scroll containers
// where document.documentElement.scrollHeight equals window.innerHeight.
setScrollGeometry(400, 600, 0);
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('fires scroll_depth 100 with aboveFold after dwell time', () => {
setup({ scroll: true });

// Should NOT fire immediately
expect(enqueue).not.toHaveBeenCalled();

// Advance past dwell time
jest.advanceTimersByTime(2000);

expect(enqueue).toHaveBeenCalledWith('scroll_depth', {
depth: 100,
aboveFold: true,
});
expect(enqueue).toHaveBeenCalledTimes(1);
});

it('does not fire before dwell time elapses', () => {
setup({ scroll: true });

jest.advanceTimersByTime(1999);
expect(enqueue).not.toHaveBeenCalled();
});

it('does not fire if consent is none when dwell timer triggers', () => {
it('does not fire any milestones on setup', () => {
setup({ scroll: true });

consent = 'none';
jest.advanceTimersByTime(2000);

expect(enqueue).not.toHaveBeenCalled();
});

it('cancels dwell timer on teardown', () => {
it('does not fire any milestones on subsequent scroll events', () => {
setup({ scroll: true });

teardown();
jest.advanceTimersByTime(2000);

expect(enqueue).not.toHaveBeenCalled();
});

it('cancels dwell timer when page grows beyond viewport', () => {
setup({ scroll: true });

// Sanity check: the dwell timer was actually scheduled (otherwise the
// assertion below passes vacuously). Advance partway, no fire yet.
jest.advanceTimersByTime(1000);
expect(enqueue).not.toHaveBeenCalled();

// Simulate content loading and page growing.
setScrollGeometry(2000, 600, 0);
fireResizeObserver();
// Even if a scroll event fires (e.g. iOS overscroll bounce), there is
// nothing to scroll past, so no milestone should fire.
window.dispatchEvent(new Event('scroll'));
flushRAF();

// Advance well past dwell time — no above-fold event should fire.
jest.advanceTimersByTime(5000);
expect(enqueue).not.toHaveBeenCalled();
});

it('does not throw or attach observer when ResizeObserver is unavailable', () => {
const original = (global as Record<string, unknown>).ResizeObserver;
delete (global as Record<string, unknown>).ResizeObserver;

try {
expect(() => setup({ scroll: true })).not.toThrow();
// No above-fold event ever fires (no observer to start the timer).
jest.advanceTimersByTime(2000);
expect(enqueue).not.toHaveBeenCalled();
} finally {
(global as Record<string, unknown>).ResizeObserver = original;
}
});
});

describe('configuration', () => {
Expand Down
89 changes: 12 additions & 77 deletions packages/audience/pixel/src/autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,41 +60,28 @@ function getFieldNames(form: HTMLFormElement): string[] {
const SCROLL_MILESTONES = [25, 50, 75, 90, 100];

/**
* Minimum dwell time (ms) before firing `scroll_depth: 100` on pages where
* all content is visible without scrolling (above-the-fold). Filters out
* immediate bounces while still capturing genuine engagement.
*/
const ABOVE_FOLD_DWELL_MS = 2000;

function getScrollPercent(): number {
const { scrollHeight, clientHeight } = document.documentElement;
if (scrollHeight <= clientHeight) return 100;
const scrollable = scrollHeight - clientHeight;
return Math.min(100, Math.round((window.scrollY / scrollable) * 100));
}

/**
* Setup scroll depth milestone tracking.
* Fires `scroll_depth` once per milestone (25/50/75/90/100) as the user
* scrolls. No milestone fires on pages where the document doesn't scroll —
* short pages and SPAs with internal scroll containers behave the same way.
*
* - Scrollable pages: passive `scroll` listener, throttled via `requestAnimationFrame`,
* fires `scroll_depth` once per milestone (25 / 50 / 75 / 90 / 100).
* - Above-the-fold pages (no scroll possible): fires `scroll_depth` with
* `depth: 100, above_fold: true` after a short dwell to filter bounces.
* - Consent is checked at fire time, not at attach time.
* Consent is checked at fire time, not at attach time.
*/
function setupScrollTracking(
enqueue: EnqueueFn,
getConsent: ConsentFn,
): () => void {
const fired = new Set<number>();
let rafId = 0;
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
let ro: ResizeObserver | null = null;

const checkAndFire = (): void => {
if (!canTrack(getConsent())) return;

const pct = getScrollPercent();
const { scrollHeight, clientHeight } = document.documentElement;
if (scrollHeight <= clientHeight) return; // Page is not scrollable.

const scrollable = scrollHeight - clientHeight;
const pct = Math.min(100, Math.round((window.scrollY / scrollable) * 100));

for (let i = 0; i < SCROLL_MILESTONES.length; i++) {
const milestone = SCROLL_MILESTONES[i];
if (pct >= milestone && !fired.has(milestone)) {
Expand All @@ -104,55 +91,8 @@ function setupScrollTracking(
}
};

// ResizeObserver manages the above-fold dwell timer reactively, so the
// "is this page above-fold?" decision is never locked in at init time.
// It fires on initial observe() and again whenever the document height
// changes (images load, JS renders content, fonts swap, etc.).
// Feature-detected: ~3% of supported browsers lack ResizeObserver. On those
// we skip the synthetic above-fold event entirely rather than throwing and
// breaking the rest of autocapture (forms, clicks).
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => {
const nowAboveFold = document.documentElement.scrollHeight <= window.innerHeight;
if (nowAboveFold) {
if (dwellTimer === null && !fired.has(100)) {
// All content fits in the viewport — start the dwell timer.
// We deliberately skip intermediate milestones: the user didn't scroll
// to 25/50/75, the content was simply short enough to fit.
dwellTimer = setTimeout(() => {
dwellTimer = null;
if (!canTrack(getConsent())) return;
if (fired.has(100)) return;
// Defense-in-depth: real ResizeObserver delivery is batched to a
// microtask, so a height change racing with the timer firing is
// theoretically possible. Re-check before emitting.
if (document.documentElement.scrollHeight > window.innerHeight) return;
fired.add(100);
enqueue('scroll_depth', { depth: 100, aboveFold: true });
// After the synthetic event fires we deliberately leave the scroll
// listener attached. If the page later grows and the user scrolls,
// intermediate milestones (25/50/75/90) may also fire — we treat
// that as legitimate engagement signal rather than suppressing it.
ro?.disconnect();
ro = null;
}, ABOVE_FOLD_DWELL_MS);
}
} else if (dwellTimer !== null) {
// Page grew beyond the viewport — cancel the above-fold path and let
// the scroll listener fire milestones naturally as the user scrolls.
clearTimeout(dwellTimer);
dwellTimer = null;
}
});

ro.observe(document.documentElement);
}

// For scrollable pages: check if the user already scrolled past a milestone
// before our listener attached (e.g. anchor links, restored scroll position).
if (document.documentElement.scrollHeight > window.innerHeight) {
checkAndFire();
}
// Check initial scroll position (e.g. anchor links, restored scroll).
checkAndFire();

const onScroll = (): void => {
if (rafId) return; // Already scheduled
Expand All @@ -167,11 +107,6 @@ function setupScrollTracking(
return () => {
window.removeEventListener('scroll', onScroll);
if (rafId) cancelAnimationFrame(rafId);
if (dwellTimer !== null) clearTimeout(dwellTimer);
if (ro) {
ro.disconnect();
ro = null;
}
};
}

Expand Down
Loading