From 1d0e63f6c567265ccd4301397c60e3688e9a4d94 Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Mon, 11 May 2026 16:15:46 +1000 Subject: [PATCH 1/5] feat(pixel): capture-phase scroll tracking for SPAs and internal scroll containers (SDK-276) Replaces the window-level scroll listener with a single capture-phase listener on document, so scroll_depth milestones fire on SPA pages where document.documentElement never scrolls but an internal overflow container does. Containers smaller than 50% of the viewport are ignored to avoid noise from dropdowns and tooltips. Milestones reset on each pixel.page() call so SPA route transitions start fresh. Also fixes a pre-existing bug where autocapture: { scroll: false } was silently ignored. Co-Authored-By: Claude Sonnet 4.6 --- packages/audience/pixel/README.md | 2 +- .../audience/pixel/src/autocapture.test.ts | 179 ++++++++++++++++-- packages/audience/pixel/src/autocapture.ts | 82 +++++--- packages/audience/pixel/src/pixel.test.ts | 6 +- packages/audience/pixel/src/pixel.ts | 13 +- 5 files changed, 236 insertions(+), 46 deletions(-) diff --git a/packages/audience/pixel/README.md b/packages/audience/pixel/README.md index dbd66f08c6..b87cb36318 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 both standard document scroll and SPA internal scroll containers (e.g. `overflow: auto` divs) larger than 50% of the viewport. Milestones reset on each `page` call so SPA route changes start fresh. No event fires on pages with no scrollable area at all. **iframe limitation:** a cross-origin iframe cannot be observed from the parent page — install the pixel inside the iframe itself for those integrations. | ### Disabling specific auto-capture diff --git a/packages/audience/pixel/src/autocapture.test.ts b/packages/audience/pixel/src/autocapture.test.ts index b6066a49d3..02f0482f48 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,154 @@ 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 }); + }); + }); }); // ---------- Email hashing ---------- diff --git a/packages/audience/pixel/src/autocapture.ts b/packages/audience/pixel/src/autocapture.ts index c0f27b7fd7..2e93f39f11 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(); let rafId = 0; + let pendingEl: Element | null = null; - 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,53 @@ function setupScrollTracking( } }; - // Check initial scroll position (e.g. anchor links, restored scroll). - checkAndFire(); + const onScroll = (e: Event): void => { + if (fired.size === SCROLL_MILESTONES.length) return; + + 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; - const onScroll = (): void => { - if (rafId) return; // Already scheduled + pendingEl = target; + + if (rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; - checkAndFire(); + const el = pendingEl; + pendingEl = null; + if (!el) return; + const scrollPos = el === document.documentElement + ? window.scrollY + : (el as HTMLElement).scrollTop; + checkAndFire(el, scrollPos); }); }; - 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 }); + if (rafId) cancelAnimationFrame(rafId); + }, + reset: () => { + fired.clear(); + }, }; } /** * Attach document-level listeners for form submissions, outbound link clicks, * and scroll depth milestones. - * Returns a teardown function that removes all listeners. + * Returns `{ teardown, resetScroll }` — call `teardown()` to remove all + * listeners, and `resetScroll()` to clear fired milestones (e.g. 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 +153,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 +226,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..25482ab3f0 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), })); 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; From 2dbc7fecd7b2004d0ed7bdf2b583ea20a72fdffa Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Mon, 11 May 2026 16:27:17 +1000 Subject: [PATCH 2/5] chore(pixel): tighten scroll_depth README, drop unnecessary cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trim scroll_depth README cell; iframe limitation belongs in docs.immutable.com (separate PR) and isn't scroll-specific anyway - Drop unnecessary (el as HTMLElement) cast — Element.scrollTop is in the DOM spec - Tighten setupAutocapture docstring Co-Authored-By: Claude Sonnet 4.6 --- packages/audience/pixel/README.md | 2 +- packages/audience/pixel/src/autocapture.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/audience/pixel/README.md b/packages/audience/pixel/README.md index b87cb36318..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). Fires on both standard document scroll and SPA internal scroll containers (e.g. `overflow: auto` divs) larger than 50% of the viewport. Milestones reset on each `page` call so SPA route changes start fresh. No event fires on pages with no scrollable area at all. **iframe limitation:** a cross-origin iframe cannot be observed from the parent page — install the pixel inside the iframe itself for those integrations. | +| `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/src/autocapture.ts b/packages/audience/pixel/src/autocapture.ts index 2e93f39f11..e4c6b34bb7 100644 --- a/packages/audience/pixel/src/autocapture.ts +++ b/packages/audience/pixel/src/autocapture.ts @@ -117,7 +117,7 @@ function setupScrollTracking( if (!el) return; const scrollPos = el === document.documentElement ? window.scrollY - : (el as HTMLElement).scrollTop; + : el.scrollTop; checkAndFire(el, scrollPos); }); }; @@ -140,10 +140,8 @@ function setupScrollTracking( /** * Attach document-level listeners for form submissions, outbound link clicks, - * and scroll depth milestones. - * Returns `{ teardown, resetScroll }` — call `teardown()` to remove all - * listeners, and `resetScroll()` to clear fired milestones (e.g. on SPA - * route changes). + * 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. From ecaa8f24eda8dffda15a68d7d70f73c227c985d8 Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Mon, 11 May 2026 16:39:49 +1000 Subject: [PATCH 3/5] fix(pixel): cancel pending rAF on scroll reset; queue all containers per frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Cursor bugbot findings: 1. Stale rAF after reset() (medium): if a scroll event scheduled a rAF just before pixel.page() called resetScrollDepth(), the rAF would read the (still-stale) scrollTop of a reused container and fire milestones against the freshly-cleared set — misattributing scroll depth to the new page view. reset() now cancels any pending rAF. 2. Single pendingEl drops concurrent container scrolls (low): if two large containers fired scroll events in the same frame, only the later target was checked. Replaced pendingEl with a Set so every container that scrolled within the frame is processed on rAF. Adds two regression tests. Co-Authored-By: Claude Sonnet 4.6 --- .../audience/pixel/src/autocapture.test.ts | 64 +++++++++++++++++++ packages/audience/pixel/src/autocapture.ts | 31 ++++++--- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/packages/audience/pixel/src/autocapture.test.ts b/packages/audience/pixel/src/autocapture.test.ts index 02f0482f48..b7de12a476 100644 --- a/packages/audience/pixel/src/autocapture.test.ts +++ b/packages/audience/pixel/src/autocapture.test.ts @@ -928,6 +928,70 @@ describe('autocapture', () => { 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 }); + }); }); }); diff --git a/packages/audience/pixel/src/autocapture.ts b/packages/audience/pixel/src/autocapture.ts index e4c6b34bb7..9c2705d05f 100644 --- a/packages/audience/pixel/src/autocapture.ts +++ b/packages/audience/pixel/src/autocapture.ts @@ -77,8 +77,8 @@ function setupScrollTracking( getConsent: ConsentFn, ): { teardown: () => void; reset: () => void } { const fired = new Set(); + const pending = new Set(); let rafId = 0; - let pendingEl: Element | null = null; const checkAndFire = (el: Element, scrollPos: number): void => { if (!canTrack(getConsent())) return; @@ -97,6 +97,14 @@ function setupScrollTracking( } }; + const cancelPending = (): void => { + pending.clear(); + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + }; + const onScroll = (e: Event): void => { if (fired.size === SCROLL_MILESTONES.length) return; @@ -107,18 +115,18 @@ function setupScrollTracking( // Ignore small containers (dropdowns, tooltips, autocompletes). if (target.clientHeight <= window.innerHeight * 0.5) return; - pendingEl = target; + pending.add(target); if (rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; - const el = pendingEl; - pendingEl = null; - if (!el) return; - const scrollPos = el === document.documentElement - ? window.scrollY - : el.scrollTop; - checkAndFire(el, scrollPos); + pending.forEach((el) => { + const scrollPos = el === document.documentElement + ? window.scrollY + : el.scrollTop; + checkAndFire(el, scrollPos); + }); + pending.clear(); }); }; @@ -130,10 +138,13 @@ function setupScrollTracking( return { teardown: () => { document.removeEventListener('scroll', onScroll, { capture: true }); - if (rafId) cancelAnimationFrame(rafId); + 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(); }, }; } From 69cad58a5659a1d3e11da090728da0cd951456e9 Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Mon, 11 May 2026 16:52:23 +1000 Subject: [PATCH 4/5] test(pixel): assert resetScroll and scroll option forwarding in pixel tests Co-Authored-By: Claude Sonnet 4.6 --- packages/audience/pixel/src/pixel.test.ts | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index 25482ab3f0..c0be0380e3 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -519,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 }); From de0b6d3a822d8b92593af104e635e59e4e6ddef9 Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Mon, 11 May 2026 17:00:13 +1000 Subject: [PATCH 5/5] chore(pixel): bump version to 0.2.0 Co-Authored-By: Claude Sonnet 4.6 --- packages/audience/pixel/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",