Skip to content

Commit 96d78d6

Browse files
bkboothclaude
andauthored
fix(pixel): drop synthetic above-fold scroll_depth event (SDK-275) (#2869)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 51a4793 commit 96d78d6

4 files changed

Lines changed: 24 additions & 193 deletions

File tree

packages/audience/pixel/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ All events fire automatically with no instrumentation required.
8585
| `session_end` | Page unload (`visibilitychange` / `pagehide`) | `sessionId`, `duration` (seconds) |
8686
| `form_submitted` | HTML form submission | `formAction`, `formId`, `formName`, `fieldNames`. `emailHash` at `full` consent only. |
8787
| `link_clicked` | Outbound link click (external domains only) | `linkUrl`, `linkText`, `elementId`, `outbound: true` |
88-
| `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. |
88+
| `scroll_depth` | Scroll milestone reached (25%, 50%, 75%, 90%, 100%) | `depth` (integer). No event fires on pages where the document does not scroll. |
8989

9090
### Disabling specific auto-capture
9191

packages/audience/pixel/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@imtbl/pixel",
33
"description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data",
4-
"version": "0.1.1",
4+
"version": "0.1.2",
55
"author": "Immutable",
66
"private": true,
77
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",

packages/audience/pixel/src/autocapture.test.ts

Lines changed: 10 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,6 @@ afterAll(() => {
3232
});
3333
});
3434

35-
// jsdom doesn't implement ResizeObserver — provide a minimal no-op stub so any
36-
// test that calls setupAutocapture without a specialised mock still works.
37-
// The scroll depth describe block overrides this with a controllable mock.
38-
(global as Record<string, unknown>).ResizeObserver = class {
39-
// eslint-disable-next-line class-methods-use-this
40-
observe() { /* no-op */ }
41-
42-
// eslint-disable-next-line class-methods-use-this
43-
disconnect() { /* no-op */ }
44-
};
45-
4635
describe('autocapture', () => {
4736
let enqueue: jest.Mock;
4837
let consent: ConsentLevel;
@@ -562,19 +551,11 @@ describe('autocapture', () => {
562551
let rafCallbacks: Array<() => void>;
563552
let originalRAF: typeof requestAnimationFrame;
564553
let originalCAF: typeof cancelAnimationFrame;
565-
let resizeCallback: (() => void) | null;
566-
let originalResizeObserver: typeof ResizeObserver;
567-
568-
function fireResizeObserver() {
569-
resizeCallback?.();
570-
}
571554

572555
beforeEach(() => {
573556
rafCallbacks = [];
574557
originalRAF = window.requestAnimationFrame;
575558
originalCAF = window.cancelAnimationFrame;
576-
resizeCallback = null;
577-
originalResizeObserver = (global as Record<string, unknown>).ResizeObserver as typeof ResizeObserver;
578559

579560
// Mock rAF: collect callbacks, flush manually
580561
let nextId = 1;
@@ -584,26 +565,11 @@ describe('autocapture', () => {
584565
return id;
585566
});
586567
window.cancelAnimationFrame = jest.fn();
587-
588-
// Mock ResizeObserver. Real RO delivers entries on a microtask/rAF; this
589-
// mock fires synchronously inside observe() and via fireResizeObserver()
590-
// for subsequent triggers. Outcome is equivalent for all current cases,
591-
// but tests won't catch ordering bugs that depend on the async boundary.
592-
(global as Record<string, unknown>).ResizeObserver = class MockResizeObserver {
593-
constructor(cb: () => void) { resizeCallback = cb; }
594-
595-
// eslint-disable-next-line class-methods-use-this
596-
observe() { resizeCallback?.(); }
597-
598-
// eslint-disable-next-line class-methods-use-this
599-
disconnect() { /* no-op */ }
600-
};
601568
});
602569

603570
afterEach(() => {
604571
window.requestAnimationFrame = originalRAF;
605572
window.cancelAnimationFrame = originalCAF;
606-
(global as Record<string, unknown>).ResizeObserver = originalResizeObserver;
607573
});
608574

609575
function flushRAF() {
@@ -714,17 +680,6 @@ describe('autocapture', () => {
714680
expect(enqueue).toHaveBeenCalledTimes(5);
715681
});
716682

717-
it('does not include aboveFold property on scrollable pages', () => {
718-
setup({ scroll: true });
719-
720-
(window as Record<string, unknown>).scrollY = 375;
721-
window.dispatchEvent(new Event('scroll'));
722-
flushRAF();
723-
724-
expect(enqueue.mock.calls[0][1]).toEqual({ depth: 25 });
725-
expect(enqueue.mock.calls[0][1]).not.toHaveProperty('aboveFold');
726-
});
727-
728683
it('does not fire at consent none', () => {
729684
consent = 'none';
730685
setup({ scroll: true });
@@ -763,88 +718,29 @@ describe('autocapture', () => {
763718
});
764719
});
765720

766-
describe('above-the-fold pages', () => {
721+
describe('non-scrollable pages', () => {
767722
beforeEach(() => {
768-
// 400px content in a 600px viewport → no scroll
723+
// 400px content in a 600px viewport → document does not scroll.
724+
// Same shape applies to SPAs / pages with internal scroll containers
725+
// where document.documentElement.scrollHeight equals window.innerHeight.
769726
setScrollGeometry(400, 600, 0);
770-
jest.useFakeTimers();
771-
});
772-
773-
afterEach(() => {
774-
jest.useRealTimers();
775-
});
776-
777-
it('fires scroll_depth 100 with aboveFold after dwell time', () => {
778-
setup({ scroll: true });
779-
780-
// Should NOT fire immediately
781-
expect(enqueue).not.toHaveBeenCalled();
782-
783-
// Advance past dwell time
784-
jest.advanceTimersByTime(2000);
785-
786-
expect(enqueue).toHaveBeenCalledWith('scroll_depth', {
787-
depth: 100,
788-
aboveFold: true,
789-
});
790-
expect(enqueue).toHaveBeenCalledTimes(1);
791-
});
792-
793-
it('does not fire before dwell time elapses', () => {
794-
setup({ scroll: true });
795-
796-
jest.advanceTimersByTime(1999);
797-
expect(enqueue).not.toHaveBeenCalled();
798727
});
799728

800-
it('does not fire if consent is none when dwell timer triggers', () => {
729+
it('does not fire any milestones on setup', () => {
801730
setup({ scroll: true });
802-
803-
consent = 'none';
804-
jest.advanceTimersByTime(2000);
805-
806731
expect(enqueue).not.toHaveBeenCalled();
807732
});
808733

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

812-
teardown();
813-
jest.advanceTimersByTime(2000);
814-
815-
expect(enqueue).not.toHaveBeenCalled();
816-
});
817-
818-
it('cancels dwell timer when page grows beyond viewport', () => {
819-
setup({ scroll: true });
820-
821-
// Sanity check: the dwell timer was actually scheduled (otherwise the
822-
// assertion below passes vacuously). Advance partway, no fire yet.
823-
jest.advanceTimersByTime(1000);
824-
expect(enqueue).not.toHaveBeenCalled();
825-
826-
// Simulate content loading and page growing.
827-
setScrollGeometry(2000, 600, 0);
828-
fireResizeObserver();
737+
// Even if a scroll event fires (e.g. iOS overscroll bounce), there is
738+
// nothing to scroll past, so no milestone should fire.
739+
window.dispatchEvent(new Event('scroll'));
740+
flushRAF();
829741

830-
// Advance well past dwell time — no above-fold event should fire.
831-
jest.advanceTimersByTime(5000);
832742
expect(enqueue).not.toHaveBeenCalled();
833743
});
834-
835-
it('does not throw or attach observer when ResizeObserver is unavailable', () => {
836-
const original = (global as Record<string, unknown>).ResizeObserver;
837-
delete (global as Record<string, unknown>).ResizeObserver;
838-
839-
try {
840-
expect(() => setup({ scroll: true })).not.toThrow();
841-
// No above-fold event ever fires (no observer to start the timer).
842-
jest.advanceTimersByTime(2000);
843-
expect(enqueue).not.toHaveBeenCalled();
844-
} finally {
845-
(global as Record<string, unknown>).ResizeObserver = original;
846-
}
847-
});
848744
});
849745

850746
describe('configuration', () => {

packages/audience/pixel/src/autocapture.ts

Lines changed: 12 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -60,41 +60,28 @@ function getFieldNames(form: HTMLFormElement): string[] {
6060
const SCROLL_MILESTONES = [25, 50, 75, 90, 100];
6161

6262
/**
63-
* Minimum dwell time (ms) before firing `scroll_depth: 100` on pages where
64-
* all content is visible without scrolling (above-the-fold). Filters out
65-
* immediate bounces while still capturing genuine engagement.
66-
*/
67-
const ABOVE_FOLD_DWELL_MS = 2000;
68-
69-
function getScrollPercent(): number {
70-
const { scrollHeight, clientHeight } = document.documentElement;
71-
if (scrollHeight <= clientHeight) return 100;
72-
const scrollable = scrollHeight - clientHeight;
73-
return Math.min(100, Math.round((window.scrollY / scrollable) * 100));
74-
}
75-
76-
/**
77-
* Setup scroll depth milestone tracking.
63+
* Fires `scroll_depth` once per milestone (25/50/75/90/100) as the user
64+
* scrolls. No milestone fires on pages where the document doesn't scroll —
65+
* short pages and SPAs with internal scroll containers behave the same way.
7866
*
79-
* - Scrollable pages: passive `scroll` listener, throttled via `requestAnimationFrame`,
80-
* fires `scroll_depth` once per milestone (25 / 50 / 75 / 90 / 100).
81-
* - Above-the-fold pages (no scroll possible): fires `scroll_depth` with
82-
* `depth: 100, above_fold: true` after a short dwell to filter bounces.
83-
* - Consent is checked at fire time, not at attach time.
67+
* Consent is checked at fire time, not at attach time.
8468
*/
8569
function setupScrollTracking(
8670
enqueue: EnqueueFn,
8771
getConsent: ConsentFn,
8872
): () => void {
8973
const fired = new Set<number>();
9074
let rafId = 0;
91-
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
92-
let ro: ResizeObserver | null = null;
9375

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

97-
const pct = getScrollPercent();
79+
const { scrollHeight, clientHeight } = document.documentElement;
80+
if (scrollHeight <= clientHeight) return; // Page is not scrollable.
81+
82+
const scrollable = scrollHeight - clientHeight;
83+
const pct = Math.min(100, Math.round((window.scrollY / scrollable) * 100));
84+
9885
for (let i = 0; i < SCROLL_MILESTONES.length; i++) {
9986
const milestone = SCROLL_MILESTONES[i];
10087
if (pct >= milestone && !fired.has(milestone)) {
@@ -104,55 +91,8 @@ function setupScrollTracking(
10491
}
10592
};
10693

107-
// ResizeObserver manages the above-fold dwell timer reactively, so the
108-
// "is this page above-fold?" decision is never locked in at init time.
109-
// It fires on initial observe() and again whenever the document height
110-
// changes (images load, JS renders content, fonts swap, etc.).
111-
// Feature-detected: ~3% of supported browsers lack ResizeObserver. On those
112-
// we skip the synthetic above-fold event entirely rather than throwing and
113-
// breaking the rest of autocapture (forms, clicks).
114-
if (typeof ResizeObserver !== 'undefined') {
115-
ro = new ResizeObserver(() => {
116-
const nowAboveFold = document.documentElement.scrollHeight <= window.innerHeight;
117-
if (nowAboveFold) {
118-
if (dwellTimer === null && !fired.has(100)) {
119-
// All content fits in the viewport — start the dwell timer.
120-
// We deliberately skip intermediate milestones: the user didn't scroll
121-
// to 25/50/75, the content was simply short enough to fit.
122-
dwellTimer = setTimeout(() => {
123-
dwellTimer = null;
124-
if (!canTrack(getConsent())) return;
125-
if (fired.has(100)) return;
126-
// Defense-in-depth: real ResizeObserver delivery is batched to a
127-
// microtask, so a height change racing with the timer firing is
128-
// theoretically possible. Re-check before emitting.
129-
if (document.documentElement.scrollHeight > window.innerHeight) return;
130-
fired.add(100);
131-
enqueue('scroll_depth', { depth: 100, aboveFold: true });
132-
// After the synthetic event fires we deliberately leave the scroll
133-
// listener attached. If the page later grows and the user scrolls,
134-
// intermediate milestones (25/50/75/90) may also fire — we treat
135-
// that as legitimate engagement signal rather than suppressing it.
136-
ro?.disconnect();
137-
ro = null;
138-
}, ABOVE_FOLD_DWELL_MS);
139-
}
140-
} else if (dwellTimer !== null) {
141-
// Page grew beyond the viewport — cancel the above-fold path and let
142-
// the scroll listener fire milestones naturally as the user scrolls.
143-
clearTimeout(dwellTimer);
144-
dwellTimer = null;
145-
}
146-
});
147-
148-
ro.observe(document.documentElement);
149-
}
150-
151-
// For scrollable pages: check if the user already scrolled past a milestone
152-
// before our listener attached (e.g. anchor links, restored scroll position).
153-
if (document.documentElement.scrollHeight > window.innerHeight) {
154-
checkAndFire();
155-
}
94+
// Check initial scroll position (e.g. anchor links, restored scroll).
95+
checkAndFire();
15696

15797
const onScroll = (): void => {
15898
if (rafId) return; // Already scheduled
@@ -167,11 +107,6 @@ function setupScrollTracking(
167107
return () => {
168108
window.removeEventListener('scroll', onScroll);
169109
if (rafId) cancelAnimationFrame(rafId);
170-
if (dwellTimer !== null) clearTimeout(dwellTimer);
171-
if (ro) {
172-
ro.disconnect();
173-
ro = null;
174-
}
175110
};
176111
}
177112

0 commit comments

Comments
 (0)