Skip to content

Commit 191003c

Browse files
bkboothclaude
andcommitted
fix(pixel): drop synthetic above-fold scroll_depth event (SDK-275)
The previous fix (v0.1.1) used a ResizeObserver to detect when the document grew beyond the viewport, but the synthetic above-fold event still produced false positives on SPAs and pages with internal scroll containers — where document.documentElement.scrollHeight is permanently equal to window.innerHeight regardless of actual content depth. Confirmed in the wild on godsunchained.com, which uses an internal .website-content scroll container with overflow: auto. Drop the synthetic event entirely and align with PostHog / Segment / GTM behaviour: only fire scroll_depth on real scroll events. On pages where the document doesn't scroll, no milestone fires. This trades the bounce-filter signal on legitimately short pages for eliminating an entire class of false positives. Net bundle impact: -181 bytes gzip vs v0.1.1, -89 bytes vs pre-SDK-275. Follow-up SDK-XXX will look at proper scroll tracking on pages with internal scroll containers (capture-phase scroll listening). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 51a4793 commit 191003c

3 files changed

Lines changed: 27 additions & 180 deletions

File tree

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 & 103 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() {
@@ -763,88 +729,29 @@ describe('autocapture', () => {
763729
});
764730
});
765731

766-
describe('above-the-fold pages', () => {
732+
describe('non-scrollable pages', () => {
767733
beforeEach(() => {
768-
// 400px content in a 600px viewport → no scroll
734+
// 400px content in a 600px viewport → document does not scroll.
735+
// Same shape applies to SPAs / pages with internal scroll containers
736+
// where document.documentElement.scrollHeight equals window.innerHeight.
769737
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();
798738
});
799739

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

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

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();
748+
// Even if a scroll event fires (e.g. iOS overscroll bounce), there is
749+
// nothing to scroll past, so no milestone should fire.
750+
window.dispatchEvent(new Event('scroll'));
751+
flushRAF();
829752

830-
// Advance well past dwell time — no above-fold event should fire.
831-
jest.advanceTimersByTime(5000);
832753
expect(enqueue).not.toHaveBeenCalled();
833754
});
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-
});
848755
});
849756

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

packages/audience/pixel/src/autocapture.ts

Lines changed: 16 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -59,42 +59,34 @@ function getFieldNames(form: HTMLFormElement): string[] {
5959
/** Milestones that fire exactly once per page load. */
6060
const SCROLL_MILESTONES = [25, 50, 75, 90, 100];
6161

62-
/**
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-
7662
/**
7763
* Setup scroll depth milestone tracking.
7864
*
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.
65+
* Listens for scroll events on `window` and fires `scroll_depth` once per
66+
* milestone (25 / 50 / 75 / 90 / 100). On pages where the document does not
67+
* scroll — short pages, but also SPAs and pages with internal scroll
68+
* containers — no milestones fire. This matches PostHog / Segment / GTM
69+
* behaviour and avoids the false positives we previously emitted via the
70+
* `aboveFold` synthetic event (see SDK-275).
71+
*
72+
* Consent is checked at fire time, not at attach time.
8473
*/
8574
function setupScrollTracking(
8675
enqueue: EnqueueFn,
8776
getConsent: ConsentFn,
8877
): () => void {
8978
const fired = new Set<number>();
9079
let rafId = 0;
91-
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
92-
let ro: ResizeObserver | null = null;
9380

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

97-
const pct = getScrollPercent();
84+
const { scrollHeight, clientHeight } = document.documentElement;
85+
if (scrollHeight <= clientHeight) return; // Page is not scrollable.
86+
87+
const scrollable = scrollHeight - clientHeight;
88+
const pct = Math.min(100, Math.round((window.scrollY / scrollable) * 100));
89+
9890
for (let i = 0; i < SCROLL_MILESTONES.length; i++) {
9991
const milestone = SCROLL_MILESTONES[i];
10092
if (pct >= milestone && !fired.has(milestone)) {
@@ -104,55 +96,8 @@ function setupScrollTracking(
10496
}
10597
};
10698

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-
}
99+
// Check initial scroll position (e.g. anchor links, restored scroll).
100+
checkAndFire();
156101

157102
const onScroll = (): void => {
158103
if (rafId) return; // Already scheduled
@@ -167,11 +112,6 @@ function setupScrollTracking(
167112
return () => {
168113
window.removeEventListener('scroll', onScroll);
169114
if (rafId) cancelAnimationFrame(rafId);
170-
if (dwellTimer !== null) clearTimeout(dwellTimer);
171-
if (ro) {
172-
ro.disconnect();
173-
ro = null;
174-
}
175115
};
176116
}
177117

0 commit comments

Comments
 (0)