diff --git a/packages/audience/pixel/README.md b/packages/audience/pixel/README.md index fddf61ca69..dbd66f08c6 100644 --- a/packages/audience/pixel/README.md +++ b/packages/audience/pixel/README.md @@ -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 diff --git a/packages/audience/pixel/package.json b/packages/audience/pixel/package.json index 74b4400ad5..18c07f584f 100644 --- a/packages/audience/pixel/package.json +++ b/packages/audience/pixel/package.json @@ -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", diff --git a/packages/audience/pixel/src/autocapture.test.ts b/packages/audience/pixel/src/autocapture.test.ts index 63dcc0097f..b6066a49d3 100644 --- a/packages/audience/pixel/src/autocapture.test.ts +++ b/packages/audience/pixel/src/autocapture.test.ts @@ -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).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; @@ -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).ResizeObserver as typeof ResizeObserver; // Mock rAF: collect callbacks, flush manually let nextId = 1; @@ -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).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).ResizeObserver = originalResizeObserver; }); function flushRAF() { @@ -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).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 }); @@ -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).ResizeObserver; - delete (global as Record).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).ResizeObserver = original; - } - }); }); describe('configuration', () => { diff --git a/packages/audience/pixel/src/autocapture.ts b/packages/audience/pixel/src/autocapture.ts index 92608c2278..c0f27b7fd7 100644 --- a/packages/audience/pixel/src/autocapture.ts +++ b/packages/audience/pixel/src/autocapture.ts @@ -60,27 +60,11 @@ 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, @@ -88,13 +72,16 @@ function setupScrollTracking( ): () => void { const fired = new Set(); let rafId = 0; - let dwellTimer: ReturnType | 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)) { @@ -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 @@ -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; - } }; }