Skip to content

Commit aa051dd

Browse files
bkboothclaude
andauthored
feat(pixel): add scroll depth auto-capture (SDK-136) (#2862)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6c1a12e commit aa051dd

File tree

3 files changed

+383
-4
lines changed

3 files changed

+383
-4
lines changed

packages/audience/pixel/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +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. |
8889

8990
### Disabling specific auto-capture
9091

@@ -96,7 +97,7 @@ w[i]=w[i]||[];
9697
w[i].push(["init",{
9798
"key":"YOUR_KEY",
9899
"consent":"anonymous",
99-
"autocapture":{"forms":false,"clicks":true}
100+
"autocapture":{"forms":false}
100101
}]);
101102
var s=document.createElement("script");s.async=1;
102103
s.src="https://cdn.immutable.com/pixel/v1/imtbl.js";

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

Lines changed: 286 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ describe('autocapture', () => {
4747
teardown?.();
4848
});
4949

50-
function setup(options = {}) {
50+
function setup(options: Record<string, unknown> = {}) {
5151
teardown = setupAutocapture(
52-
{ forms: true, clicks: true, ...options },
52+
{
53+
forms: true, clicks: true, scroll: false, ...options,
54+
},
5355
enqueue,
5456
() => consent,
5557
);
@@ -543,6 +545,288 @@ describe('autocapture', () => {
543545
});
544546
});
545547

548+
// ---------- Scroll depth tracking ----------
549+
550+
describe('scroll depth tracking', () => {
551+
let rafCallbacks: Array<() => void>;
552+
let originalRAF: typeof requestAnimationFrame;
553+
let originalCAF: typeof cancelAnimationFrame;
554+
555+
beforeEach(() => {
556+
rafCallbacks = [];
557+
originalRAF = window.requestAnimationFrame;
558+
originalCAF = window.cancelAnimationFrame;
559+
560+
// Mock rAF: collect callbacks, flush manually
561+
let nextId = 1;
562+
window.requestAnimationFrame = jest.fn((cb: FrameRequestCallback) => {
563+
const id = nextId++;
564+
rafCallbacks.push(() => cb(0));
565+
return id;
566+
});
567+
window.cancelAnimationFrame = jest.fn();
568+
});
569+
570+
afterEach(() => {
571+
window.requestAnimationFrame = originalRAF;
572+
window.cancelAnimationFrame = originalCAF;
573+
});
574+
575+
function flushRAF() {
576+
const cbs = [...rafCallbacks];
577+
rafCallbacks = [];
578+
cbs.forEach((cb) => cb());
579+
}
580+
581+
/**
582+
* Configure jsdom's document dimensions and scroll position.
583+
* jsdom doesn't support layout, so we stub the relevant properties.
584+
*/
585+
function setScrollGeometry(
586+
scrollHeight: number,
587+
clientHeight: number,
588+
scrollY: number,
589+
) {
590+
Object.defineProperty(document.documentElement, 'scrollHeight', {
591+
value: scrollHeight, configurable: true,
592+
});
593+
Object.defineProperty(document.documentElement, 'clientHeight', {
594+
value: clientHeight, configurable: true,
595+
});
596+
Object.defineProperty(window, 'innerHeight', {
597+
value: clientHeight, configurable: true,
598+
});
599+
Object.defineProperty(window, 'scrollY', {
600+
value: scrollY, configurable: true, writable: true,
601+
});
602+
}
603+
604+
describe('scrollable pages', () => {
605+
beforeEach(() => {
606+
// 2000px tall page in a 500px viewport → scrollable
607+
setScrollGeometry(2000, 500, 0);
608+
});
609+
610+
it('fires scroll_depth at each milestone exactly once', () => {
611+
setup({ scroll: true });
612+
613+
// Scroll to 25% → scrollY = (2000-500) * 0.25 = 375
614+
(window as Record<string, unknown>).scrollY = 375;
615+
window.dispatchEvent(new Event('scroll'));
616+
flushRAF();
617+
618+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
619+
expect(enqueue).toHaveBeenCalledTimes(1);
620+
621+
// Scroll to 55% → should fire 50 (25 already fired)
622+
(window as Record<string, unknown>).scrollY = 825;
623+
window.dispatchEvent(new Event('scroll'));
624+
flushRAF();
625+
626+
expect(enqueue).toHaveBeenCalledTimes(2);
627+
expect(enqueue).toHaveBeenLastCalledWith('scroll_depth', { depth: 50 });
628+
});
629+
630+
it('fires multiple milestones in a single scroll if jumped past', () => {
631+
setup({ scroll: true });
632+
633+
// Jump straight to 80% → should fire 25, 50, 75
634+
(window as Record<string, unknown>).scrollY = 1200;
635+
window.dispatchEvent(new Event('scroll'));
636+
flushRAF();
637+
638+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
639+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 50 });
640+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 75 });
641+
expect(enqueue).toHaveBeenCalledTimes(3);
642+
});
643+
644+
it('does not re-fire milestones already reached', () => {
645+
setup({ scroll: true });
646+
647+
// Scroll to 60%
648+
(window as Record<string, unknown>).scrollY = 900;
649+
window.dispatchEvent(new Event('scroll'));
650+
flushRAF();
651+
652+
const countAfterFirst = enqueue.mock.calls.length;
653+
654+
// Scroll back up to 30%, then to 60% again
655+
(window as Record<string, unknown>).scrollY = 450;
656+
window.dispatchEvent(new Event('scroll'));
657+
flushRAF();
658+
659+
(window as Record<string, unknown>).scrollY = 900;
660+
window.dispatchEvent(new Event('scroll'));
661+
flushRAF();
662+
663+
// No new milestones should have fired
664+
expect(enqueue).toHaveBeenCalledTimes(countAfterFirst);
665+
});
666+
667+
it('fires 90 and 100 milestones', () => {
668+
setup({ scroll: true });
669+
670+
// Scroll to 100%
671+
(window as Record<string, unknown>).scrollY = 1500;
672+
window.dispatchEvent(new Event('scroll'));
673+
flushRAF();
674+
675+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
676+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 50 });
677+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 75 });
678+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 90 });
679+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 100 });
680+
expect(enqueue).toHaveBeenCalledTimes(5);
681+
});
682+
683+
it('does not include aboveFold property on scrollable pages', () => {
684+
setup({ scroll: true });
685+
686+
(window as Record<string, unknown>).scrollY = 375;
687+
window.dispatchEvent(new Event('scroll'));
688+
flushRAF();
689+
690+
expect(enqueue.mock.calls[0][1]).toEqual({ depth: 25 });
691+
expect(enqueue.mock.calls[0][1]).not.toHaveProperty('aboveFold');
692+
});
693+
694+
it('does not fire at consent none', () => {
695+
consent = 'none';
696+
setup({ scroll: true });
697+
698+
(window as Record<string, unknown>).scrollY = 1500;
699+
window.dispatchEvent(new Event('scroll'));
700+
flushRAF();
701+
702+
expect(enqueue).not.toHaveBeenCalled();
703+
});
704+
705+
it('throttles via requestAnimationFrame', () => {
706+
setup({ scroll: true });
707+
708+
// Fire multiple scroll events without flushing rAF
709+
(window as Record<string, unknown>).scrollY = 375;
710+
window.dispatchEvent(new Event('scroll'));
711+
window.dispatchEvent(new Event('scroll'));
712+
window.dispatchEvent(new Event('scroll'));
713+
714+
// Only one rAF should have been scheduled
715+
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
716+
717+
flushRAF();
718+
719+
expect(enqueue).toHaveBeenCalledTimes(1);
720+
});
721+
722+
it('checks initial scroll position on setup', () => {
723+
// Page already scrolled to 30% before setup
724+
(window as Record<string, unknown>).scrollY = 450;
725+
setup({ scroll: true });
726+
727+
// Should fire 25% immediately (no scroll event needed)
728+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
729+
});
730+
});
731+
732+
describe('above-the-fold pages', () => {
733+
beforeEach(() => {
734+
// 400px content in a 600px viewport → no scroll
735+
setScrollGeometry(400, 600, 0);
736+
jest.useFakeTimers();
737+
});
738+
739+
afterEach(() => {
740+
jest.useRealTimers();
741+
});
742+
743+
it('fires scroll_depth 100 with aboveFold after dwell time', () => {
744+
setup({ scroll: true });
745+
746+
// Should NOT fire immediately
747+
expect(enqueue).not.toHaveBeenCalled();
748+
749+
// Advance past dwell time
750+
jest.advanceTimersByTime(2000);
751+
752+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', {
753+
depth: 100,
754+
aboveFold: true,
755+
});
756+
expect(enqueue).toHaveBeenCalledTimes(1);
757+
});
758+
759+
it('does not fire before dwell time elapses', () => {
760+
setup({ scroll: true });
761+
762+
jest.advanceTimersByTime(1999);
763+
expect(enqueue).not.toHaveBeenCalled();
764+
});
765+
766+
it('does not fire if consent is none when dwell timer triggers', () => {
767+
setup({ scroll: true });
768+
769+
consent = 'none';
770+
jest.advanceTimersByTime(2000);
771+
772+
expect(enqueue).not.toHaveBeenCalled();
773+
});
774+
775+
it('cancels dwell timer on teardown', () => {
776+
setup({ scroll: true });
777+
778+
teardown();
779+
jest.advanceTimersByTime(2000);
780+
781+
expect(enqueue).not.toHaveBeenCalled();
782+
});
783+
});
784+
785+
describe('configuration', () => {
786+
beforeEach(() => {
787+
setScrollGeometry(2000, 500, 0);
788+
});
789+
790+
it('does not track scroll when scroll option is false', () => {
791+
setup({ scroll: false });
792+
793+
(window as Record<string, unknown>).scrollY = 1500;
794+
window.dispatchEvent(new Event('scroll'));
795+
flushRAF();
796+
797+
expect(enqueue).not.toHaveBeenCalled();
798+
});
799+
800+
it('enables scroll tracking by default', () => {
801+
// Call setupAutocapture directly to verify production defaults
802+
teardown = setupAutocapture({}, enqueue, () => consent);
803+
804+
(window as Record<string, unknown>).scrollY = 375;
805+
window.dispatchEvent(new Event('scroll'));
806+
flushRAF();
807+
808+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
809+
});
810+
});
811+
812+
describe('teardown', () => {
813+
beforeEach(() => {
814+
setScrollGeometry(2000, 500, 0);
815+
});
816+
817+
it('removes scroll listener on teardown', () => {
818+
setup({ scroll: true });
819+
teardown();
820+
821+
(window as Record<string, unknown>).scrollY = 1500;
822+
window.dispatchEvent(new Event('scroll'));
823+
flushRAF();
824+
825+
expect(enqueue).not.toHaveBeenCalled();
826+
});
827+
});
828+
});
829+
546830
// ---------- Email hashing ----------
547831

548832
describe('email hashing', () => {

0 commit comments

Comments
 (0)