diff --git a/packages/audience/pixel/README.md b/packages/audience/pixel/README.md index dbd66f08c6..6ebd349f6b 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). No event fires on pages where the document does not scroll. | +| `scroll_depth` | Scroll milestone reached (25%, 50%, 75%, 90%, 100%) | `depth` (integer). Fires on standard document scroll or on any internal scroll container larger than half the viewport. Milestones reset on each `page` call. | ### Disabling specific auto-capture diff --git a/packages/audience/pixel/package.json b/packages/audience/pixel/package.json index 18c07f584f..f80e7af5b0 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.2", + "version": "0.2.0", "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 b6066a49d3..b7de12a476 100644 --- a/packages/audience/pixel/src/autocapture.test.ts +++ b/packages/audience/pixel/src/autocapture.test.ts @@ -48,13 +48,14 @@ describe('autocapture', () => { }); function setup(options: Record = {}) { - teardown = setupAutocapture( + const result = setupAutocapture( { forms: true, clicks: true, scroll: false, ...options, }, enqueue, () => consent, ); + teardown = result.teardown; } // ---------- Form submissions ---------- @@ -494,7 +495,7 @@ describe('autocapture', () => { describe('config defaults', () => { it('enables both listeners when no options specified', () => { - teardown = setupAutocapture({}, enqueue, () => consent); + teardown = setupAutocapture({}, enqueue, () => consent).teardown; const form = document.createElement('form'); form.action = '/signup'; @@ -612,7 +613,7 @@ describe('autocapture', () => { // Scroll to 25% → scrollY = (2000-500) * 0.25 = 375 (window as Record).scrollY = 375; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); @@ -620,7 +621,7 @@ describe('autocapture', () => { // Scroll to 55% → should fire 50 (25 already fired) (window as Record).scrollY = 825; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).toHaveBeenCalledTimes(2); @@ -632,7 +633,7 @@ describe('autocapture', () => { // Jump straight to 80% → should fire 25, 50, 75 (window as Record).scrollY = 1200; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); @@ -646,18 +647,18 @@ describe('autocapture', () => { // Scroll to 60% (window as Record).scrollY = 900; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); const countAfterFirst = enqueue.mock.calls.length; // Scroll back up to 30%, then to 60% again (window as Record).scrollY = 450; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); (window as Record).scrollY = 900; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); // No new milestones should have fired @@ -669,7 +670,7 @@ describe('autocapture', () => { // Scroll to 100% (window as Record).scrollY = 1500; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); @@ -685,7 +686,7 @@ describe('autocapture', () => { setup({ scroll: true }); (window as Record).scrollY = 1500; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).not.toHaveBeenCalled(); @@ -696,9 +697,9 @@ describe('autocapture', () => { // Fire multiple scroll events without flushing rAF (window as Record).scrollY = 375; - window.dispatchEvent(new Event('scroll')); - window.dispatchEvent(new Event('scroll')); - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); // Only one rAF should have been scheduled expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); @@ -736,7 +737,7 @@ describe('autocapture', () => { // 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')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).not.toHaveBeenCalled(); @@ -752,7 +753,7 @@ describe('autocapture', () => { setup({ scroll: false }); (window as Record).scrollY = 1500; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).not.toHaveBeenCalled(); @@ -760,10 +761,10 @@ describe('autocapture', () => { it('enables scroll tracking by default', () => { // Call setupAutocapture directly to verify production defaults - teardown = setupAutocapture({}, enqueue, () => consent); + teardown = setupAutocapture({}, enqueue, () => consent).teardown; (window as Record).scrollY = 375; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); @@ -780,12 +781,218 @@ describe('autocapture', () => { teardown(); (window as Record).scrollY = 1500; - window.dispatchEvent(new Event('scroll')); + document.dispatchEvent(new Event('scroll')); flushRAF(); expect(enqueue).not.toHaveBeenCalled(); }); }); + + describe('SPA internal scroll containers', () => { + /** + * Stub an internal element's scroll geometry. The element must be + * appended to document.body so the capture-phase listener on `document` + * receives events dispatched on it. + */ + function setContainerGeometry( + el: HTMLElement, + scrollHeight: number, + clientHeight: number, + scrollTop: number, + ) { + Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true }); + Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true }); + Object.defineProperty(el, 'scrollTop', { value: scrollTop, configurable: true, writable: true }); + } + + beforeEach(() => { + // Document itself does not scroll (SPA pattern). + setScrollGeometry(600, 600, 0); + }); + + it('fires milestones when an internal container scrolls', () => { + setup({ scroll: true }); + + const container = document.createElement('div'); + // 500px container in a 600px viewport → clientHeight/innerHeight = 83% → passes filter + setContainerGeometry(container, 2000, 500, 0); + document.body.appendChild(container); + + // Scroll to 26% → scrollTop = (2000-500)*0.26 = 390 + (container as Record).scrollTop = 390; + container.dispatchEvent(new Event('scroll')); + flushRAF(); + + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('fires all five milestones when container is scrolled to 100%', () => { + setup({ scroll: true }); + + const container = document.createElement('div'); + setContainerGeometry(container, 2000, 500, 0); + document.body.appendChild(container); + + // 100% → scrollTop = 2000-500 = 1500 + (container as Record).scrollTop = 1500; + container.dispatchEvent(new Event('scroll')); + flushRAF(); + + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 50 }); + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 75 }); + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 90 }); + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 100 }); + expect(enqueue).toHaveBeenCalledTimes(5); + }); + + it('ignores containers smaller than 50% of viewport height', () => { + setup({ scroll: true }); + + const small = document.createElement('div'); + // clientHeight = 200 px, innerHeight = 600 → 200 ≤ 300 → filtered out + setContainerGeometry(small, 2000, 200, 0); + document.body.appendChild(small); + + (small as Record).scrollTop = 1500; + small.dispatchEvent(new Event('scroll')); + flushRAF(); + + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('fires each milestone only once across multiple large containers (global dedup)', () => { + setup({ scroll: true }); + + const main = document.createElement('div'); + const sidebar = document.createElement('div'); + setContainerGeometry(main, 2000, 500, 0); + setContainerGeometry(sidebar, 1000, 500, 0); + document.body.appendChild(main); + document.body.appendChild(sidebar); + + // Scroll main to 30% → fires 25 + (main as Record).scrollTop = 450; + main.dispatchEvent(new Event('scroll')); + flushRAF(); + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); + + // Scroll sidebar to 60% → 25 already fired, should only fire 50 + (sidebar as Record).scrollTop = 300; + sidebar.dispatchEvent(new Event('scroll')); + flushRAF(); + expect(enqueue).toHaveBeenCalledTimes(2); + expect(enqueue).toHaveBeenLastCalledWith('scroll_depth', { depth: 50 }); + }); + + it('does not fire for a detached element not in the document', () => { + setup({ scroll: true }); + + const detached = document.createElement('div'); + setContainerGeometry(detached, 2000, 500, 0); + // Not appended to body — capture phase won't reach document. + (detached as Record).scrollTop = 1500; + detached.dispatchEvent(new Event('scroll')); + flushRAF(); + + expect(enqueue).not.toHaveBeenCalled(); + }); + }); + + describe('reset', () => { + beforeEach(() => { + setScrollGeometry(2000, 500, 0); + }); + + it('allows milestones to re-fire after resetScroll() (SPA route change)', () => { + const result = setupAutocapture({ scroll: true }, enqueue, () => consent); + teardown = result.teardown; + + // Fire 25 milestone. + (window as Record).scrollY = 375; + document.dispatchEvent(new Event('scroll')); + flushRAF(); + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); + expect(enqueue).toHaveBeenCalledTimes(1); + + // Simulate SPA route change: scroll back to top, then call resetScroll. + (window as Record).scrollY = 0; + result.resetScroll(); + + // Scrolling to 25% again should re-fire the milestone. + (window as Record).scrollY = 375; + document.dispatchEvent(new Event('scroll')); + flushRAF(); + expect(enqueue).toHaveBeenCalledTimes(2); + expect(enqueue).toHaveBeenLastCalledWith('scroll_depth', { depth: 25 }); + }); + + it('cancels pending rAF so stale scroll position cannot fire against new page', () => { + const result = setupAutocapture({ scroll: true }, enqueue, () => consent); + teardown = result.teardown; + + // User has scrolled to 50% — rAF is scheduled but has not yet fired. + (window as Record).scrollY = 750; + document.dispatchEvent(new Event('scroll')); + expect(enqueue).not.toHaveBeenCalled(); // rAF not flushed yet + + // SPA navigates: pixel.page() calls resetScroll() before the rAF fires. + // The reused container still reports scrollY = 750 momentarily. + result.resetScroll(); + + // Flushing the (now-cancelled) rAF must not fire any milestone against + // the new page view. + flushRAF(); + expect(enqueue).not.toHaveBeenCalled(); + }); + }); + + describe('concurrent containers', () => { + function setContainerGeometry( + el: HTMLElement, + scrollHeight: number, + clientHeight: number, + scrollTop: number, + ) { + Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true }); + Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true }); + Object.defineProperty(el, 'scrollTop', { value: scrollTop, configurable: true, writable: true }); + } + + beforeEach(() => { + setScrollGeometry(600, 600, 0); + }); + + it('processes every container that scrolled within a single rAF', () => { + setup({ scroll: true }); + + const main = document.createElement('div'); + const sidebar = document.createElement('div'); + setContainerGeometry(main, 2000, 500, 0); + setContainerGeometry(sidebar, 1000, 500, 0); + document.body.appendChild(main); + document.body.appendChild(sidebar); + + // Two large containers scroll in the same frame (before rAF flush). + // main → 30% (450/1500), sidebar → 60% (300/500). + // Without per-container queueing, only the second target would be + // checked and main's milestone would be lost. + (main as Record).scrollTop = 450; + main.dispatchEvent(new Event('scroll')); + (sidebar as Record).scrollTop = 300; + sidebar.dispatchEvent(new Event('scroll')); + + flushRAF(); + + // Both 25 (from main) and 50 (from sidebar) should fire — global dedup + // applies across all containers processed in the frame. + expect(enqueue).toHaveBeenCalledTimes(2); + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 }); + expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 50 }); + }); + }); }); // ---------- Email hashing ---------- diff --git a/packages/audience/pixel/src/autocapture.ts b/packages/audience/pixel/src/autocapture.ts index c0f27b7fd7..9c2705d05f 100644 --- a/packages/audience/pixel/src/autocapture.ts +++ b/packages/audience/pixel/src/autocapture.ts @@ -56,31 +56,37 @@ function getFieldNames(form: HTMLFormElement): string[] { // -- Scroll depth tracking -------------------------------------------------- -/** Milestones that fire exactly once per page load. */ +/** Milestones that fire exactly once per page view. */ const SCROLL_MILESTONES = [25, 50, 75, 90, 100]; /** * 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. + * scrolls. Works for both standard document scroll and SPA internal scroll + * containers. Milestones reset when `reset()` is called (e.g. on SPA route + * changes via `pixel.page()`). + * + * Uses a capture-phase listener on `document` so scroll events on any + * descendant element are caught without enumerating containers upfront. + * Small containers (clientHeight ≤ 50% of viewport) are ignored to avoid + * noise from dropdowns, tooltips, and autocomplete lists. * * Consent is checked at fire time, not at attach time. */ function setupScrollTracking( enqueue: EnqueueFn, getConsent: ConsentFn, -): () => void { +): { teardown: () => void; reset: () => void } { const fired = new Set(); + const pending = new Set(); let rafId = 0; - const checkAndFire = (): void => { + const checkAndFire = (el: Element, scrollPos: number): void => { if (!canTrack(getConsent())) return; - const { scrollHeight, clientHeight } = document.documentElement; - if (scrollHeight <= clientHeight) return; // Page is not scrollable. + const scrollable = el.scrollHeight - el.clientHeight; + if (scrollable <= 0) return; - const scrollable = scrollHeight - clientHeight; - const pct = Math.min(100, Math.round((window.scrollY / scrollable) * 100)); + const pct = Math.min(100, Math.round((scrollPos / scrollable) * 100)); for (let i = 0; i < SCROLL_MILESTONES.length; i++) { const milestone = SCROLL_MILESTONES[i]; @@ -91,29 +97,62 @@ function setupScrollTracking( } }; - // Check initial scroll position (e.g. anchor links, restored scroll). - checkAndFire(); + const cancelPending = (): void => { + pending.clear(); + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + }; + + const onScroll = (e: Event): void => { + if (fired.size === SCROLL_MILESTONES.length) return; - const onScroll = (): void => { - if (rafId) return; // Already scheduled + const target = e.target === document + ? document.documentElement + : e.target as Element; + + // Ignore small containers (dropdowns, tooltips, autocompletes). + if (target.clientHeight <= window.innerHeight * 0.5) return; + + pending.add(target); + + if (rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; - checkAndFire(); + pending.forEach((el) => { + const scrollPos = el === document.documentElement + ? window.scrollY + : el.scrollTop; + checkAndFire(el, scrollPos); + }); + pending.clear(); }); }; - window.addEventListener('scroll', onScroll, { passive: true }); - - return () => { - window.removeEventListener('scroll', onScroll); - if (rafId) cancelAnimationFrame(rafId); + // Check initial scroll position (e.g. anchor links, restored scroll). + checkAndFire(document.documentElement, window.scrollY); + + document.addEventListener('scroll', onScroll, { capture: true, passive: true }); + + return { + teardown: () => { + document.removeEventListener('scroll', onScroll, { capture: true }); + cancelPending(); + }, + // Cancel any pending rAF so a stale scroll position from the previous + // page view can't fire milestones against the freshly-cleared set. + reset: () => { + fired.clear(); + cancelPending(); + }, }; } /** * Attach document-level listeners for form submissions, outbound link clicks, - * and scroll depth milestones. - * Returns a teardown function that removes all listeners. + * and scroll depth milestones. `resetScroll()` clears fired scroll milestones + * (call from `Pixel.page()` on SPA route changes). * * - Single document-level listener per event type (event delegation). * - Consent is checked at fire time, not at attach time. @@ -123,8 +162,9 @@ export function setupAutocapture( options: AutocaptureOptions, enqueue: EnqueueFn, getConsent: ConsentFn, -): () => void { +): { teardown: () => void; resetScroll: () => void } { const teardowns: Array<() => void> = []; + let scrollReset: () => void = () => undefined; if (options.forms !== false) { const onSubmit = (e: Event): void => { @@ -195,8 +235,13 @@ export function setupAutocapture( } if (options.scroll !== false) { - teardowns.push(setupScrollTracking(enqueue, getConsent)); + const scroll = setupScrollTracking(enqueue, getConsent); + teardowns.push(scroll.teardown); + scrollReset = scroll.reset; } - return () => teardowns.forEach((fn) => fn()); + return { + teardown: () => teardowns.forEach((fn) => fn()), + resetScroll: scrollReset, + }; } diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index bef2e9d29e..c0be0380e3 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -2,7 +2,11 @@ import { Pixel } from './pixel'; // Mock autocapture module const mockTeardownAutocapture = jest.fn(); -const mockSetupAutocapture = jest.fn().mockReturnValue(mockTeardownAutocapture); +const mockResetScrollDepth = jest.fn(); +const mockSetupAutocapture = jest.fn().mockReturnValue({ + teardown: mockTeardownAutocapture, + resetScroll: mockResetScrollDepth, +}); jest.mock('./autocapture', () => ({ setupAutocapture: (...args: unknown[]) => mockSetupAutocapture(...args), })); @@ -515,6 +519,33 @@ describe('Pixel', () => { ); }); + it('forwards scroll: false to setupAutocapture', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ + key: 'pk_imapik-test-local', + consent: 'anonymous', + autocapture: { scroll: false }, + }); + + expect(mockSetupAutocapture).toHaveBeenCalledWith( + expect.objectContaining({ scroll: false }), + expect.any(Function), + expect.any(Function), + ); + }); + + it('calls resetScroll when page() is called', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_imapik-test-local', consent: 'anonymous' }); + + mockResetScrollDepth.mockClear(); + pixel.page(); + + expect(mockResetScrollDepth).toHaveBeenCalledTimes(1); + }); + it('enqueue callback fires TrackMessage with session', () => { mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-xyz', isNew: false }); diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index a3b3f433b9..3acc92a6cf 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -61,6 +61,8 @@ export class Pixel { private teardownAutocapture?: () => void; + private resetScrollDepth?: () => void; + private teardownCmp?: () => void; private initialPageViewFired = false; @@ -119,17 +121,21 @@ export class Pixel { this.page(); } - // Attach autocapture listeners (forms + outbound clicks) - this.teardownAutocapture = setupAutocapture( - { forms: autocapture?.forms, clicks: autocapture?.clicks }, + const autocaptureResult = setupAutocapture( + { forms: autocapture?.forms, clicks: autocapture?.clicks, scroll: autocapture?.scroll }, (eventName, properties) => this.track(eventName, properties), () => this.consent!.level, ); + this.teardownAutocapture = autocaptureResult.teardown; + this.resetScrollDepth = autocaptureResult.resetScroll; } page(properties?: Record): void { if (!this.isTrackingAllowed()) return; + // Reset scroll milestones so each page view starts from 0. + this.resetScrollDepth?.(); + const { sessionId, isNew } = getOrCreateSession(this.domain); this.refreshSession(sessionId, isNew); const attribution = collectSessionAttribution(); @@ -173,6 +179,7 @@ export class Pixel { this.teardownAutocapture(); this.teardownAutocapture = undefined; } + this.resetScrollDepth = undefined; if (this.queue) { this.queue.destroy(); this.queue = null;