Skip to content

Commit 1d0e63f

Browse files
bkboothclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 9c7eb9f commit 1d0e63f

5 files changed

Lines changed: 236 additions & 46 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). No event fires on pages where the document does not scroll. |
88+
| `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. |
8989

9090
### Disabling specific auto-capture
9191

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

Lines changed: 161 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,14 @@ describe('autocapture', () => {
4848
});
4949

5050
function setup(options: Record<string, unknown> = {}) {
51-
teardown = setupAutocapture(
51+
const result = setupAutocapture(
5252
{
5353
forms: true, clicks: true, scroll: false, ...options,
5454
},
5555
enqueue,
5656
() => consent,
5757
);
58+
teardown = result.teardown;
5859
}
5960

6061
// ---------- Form submissions ----------
@@ -494,7 +495,7 @@ describe('autocapture', () => {
494495

495496
describe('config defaults', () => {
496497
it('enables both listeners when no options specified', () => {
497-
teardown = setupAutocapture({}, enqueue, () => consent);
498+
teardown = setupAutocapture({}, enqueue, () => consent).teardown;
498499

499500
const form = document.createElement('form');
500501
form.action = '/signup';
@@ -612,15 +613,15 @@ describe('autocapture', () => {
612613

613614
// Scroll to 25% → scrollY = (2000-500) * 0.25 = 375
614615
(window as Record<string, unknown>).scrollY = 375;
615-
window.dispatchEvent(new Event('scroll'));
616+
document.dispatchEvent(new Event('scroll'));
616617
flushRAF();
617618

618619
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
619620
expect(enqueue).toHaveBeenCalledTimes(1);
620621

621622
// Scroll to 55% → should fire 50 (25 already fired)
622623
(window as Record<string, unknown>).scrollY = 825;
623-
window.dispatchEvent(new Event('scroll'));
624+
document.dispatchEvent(new Event('scroll'));
624625
flushRAF();
625626

626627
expect(enqueue).toHaveBeenCalledTimes(2);
@@ -632,7 +633,7 @@ describe('autocapture', () => {
632633

633634
// Jump straight to 80% → should fire 25, 50, 75
634635
(window as Record<string, unknown>).scrollY = 1200;
635-
window.dispatchEvent(new Event('scroll'));
636+
document.dispatchEvent(new Event('scroll'));
636637
flushRAF();
637638

638639
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
@@ -646,18 +647,18 @@ describe('autocapture', () => {
646647

647648
// Scroll to 60%
648649
(window as Record<string, unknown>).scrollY = 900;
649-
window.dispatchEvent(new Event('scroll'));
650+
document.dispatchEvent(new Event('scroll'));
650651
flushRAF();
651652

652653
const countAfterFirst = enqueue.mock.calls.length;
653654

654655
// Scroll back up to 30%, then to 60% again
655656
(window as Record<string, unknown>).scrollY = 450;
656-
window.dispatchEvent(new Event('scroll'));
657+
document.dispatchEvent(new Event('scroll'));
657658
flushRAF();
658659

659660
(window as Record<string, unknown>).scrollY = 900;
660-
window.dispatchEvent(new Event('scroll'));
661+
document.dispatchEvent(new Event('scroll'));
661662
flushRAF();
662663

663664
// No new milestones should have fired
@@ -669,7 +670,7 @@ describe('autocapture', () => {
669670

670671
// Scroll to 100%
671672
(window as Record<string, unknown>).scrollY = 1500;
672-
window.dispatchEvent(new Event('scroll'));
673+
document.dispatchEvent(new Event('scroll'));
673674
flushRAF();
674675

675676
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
@@ -685,7 +686,7 @@ describe('autocapture', () => {
685686
setup({ scroll: true });
686687

687688
(window as Record<string, unknown>).scrollY = 1500;
688-
window.dispatchEvent(new Event('scroll'));
689+
document.dispatchEvent(new Event('scroll'));
689690
flushRAF();
690691

691692
expect(enqueue).not.toHaveBeenCalled();
@@ -696,9 +697,9 @@ describe('autocapture', () => {
696697

697698
// Fire multiple scroll events without flushing rAF
698699
(window as Record<string, unknown>).scrollY = 375;
699-
window.dispatchEvent(new Event('scroll'));
700-
window.dispatchEvent(new Event('scroll'));
701-
window.dispatchEvent(new Event('scroll'));
700+
document.dispatchEvent(new Event('scroll'));
701+
document.dispatchEvent(new Event('scroll'));
702+
document.dispatchEvent(new Event('scroll'));
702703

703704
// Only one rAF should have been scheduled
704705
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
@@ -736,7 +737,7 @@ describe('autocapture', () => {
736737

737738
// Even if a scroll event fires (e.g. iOS overscroll bounce), there is
738739
// nothing to scroll past, so no milestone should fire.
739-
window.dispatchEvent(new Event('scroll'));
740+
document.dispatchEvent(new Event('scroll'));
740741
flushRAF();
741742

742743
expect(enqueue).not.toHaveBeenCalled();
@@ -752,18 +753,18 @@ describe('autocapture', () => {
752753
setup({ scroll: false });
753754

754755
(window as Record<string, unknown>).scrollY = 1500;
755-
window.dispatchEvent(new Event('scroll'));
756+
document.dispatchEvent(new Event('scroll'));
756757
flushRAF();
757758

758759
expect(enqueue).not.toHaveBeenCalled();
759760
});
760761

761762
it('enables scroll tracking by default', () => {
762763
// Call setupAutocapture directly to verify production defaults
763-
teardown = setupAutocapture({}, enqueue, () => consent);
764+
teardown = setupAutocapture({}, enqueue, () => consent).teardown;
764765

765766
(window as Record<string, unknown>).scrollY = 375;
766-
window.dispatchEvent(new Event('scroll'));
767+
document.dispatchEvent(new Event('scroll'));
767768
flushRAF();
768769

769770
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
@@ -780,12 +781,154 @@ describe('autocapture', () => {
780781
teardown();
781782

782783
(window as Record<string, unknown>).scrollY = 1500;
783-
window.dispatchEvent(new Event('scroll'));
784+
document.dispatchEvent(new Event('scroll'));
784785
flushRAF();
785786

786787
expect(enqueue).not.toHaveBeenCalled();
787788
});
788789
});
790+
791+
describe('SPA internal scroll containers', () => {
792+
/**
793+
* Stub an internal element's scroll geometry. The element must be
794+
* appended to document.body so the capture-phase listener on `document`
795+
* receives events dispatched on it.
796+
*/
797+
function setContainerGeometry(
798+
el: HTMLElement,
799+
scrollHeight: number,
800+
clientHeight: number,
801+
scrollTop: number,
802+
) {
803+
Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true });
804+
Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true });
805+
Object.defineProperty(el, 'scrollTop', { value: scrollTop, configurable: true, writable: true });
806+
}
807+
808+
beforeEach(() => {
809+
// Document itself does not scroll (SPA pattern).
810+
setScrollGeometry(600, 600, 0);
811+
});
812+
813+
it('fires milestones when an internal container scrolls', () => {
814+
setup({ scroll: true });
815+
816+
const container = document.createElement('div');
817+
// 500px container in a 600px viewport → clientHeight/innerHeight = 83% → passes filter
818+
setContainerGeometry(container, 2000, 500, 0);
819+
document.body.appendChild(container);
820+
821+
// Scroll to 26% → scrollTop = (2000-500)*0.26 = 390
822+
(container as Record<string, unknown>).scrollTop = 390;
823+
container.dispatchEvent(new Event('scroll'));
824+
flushRAF();
825+
826+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
827+
expect(enqueue).toHaveBeenCalledTimes(1);
828+
});
829+
830+
it('fires all five milestones when container is scrolled to 100%', () => {
831+
setup({ scroll: true });
832+
833+
const container = document.createElement('div');
834+
setContainerGeometry(container, 2000, 500, 0);
835+
document.body.appendChild(container);
836+
837+
// 100% → scrollTop = 2000-500 = 1500
838+
(container as Record<string, unknown>).scrollTop = 1500;
839+
container.dispatchEvent(new Event('scroll'));
840+
flushRAF();
841+
842+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
843+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 50 });
844+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 75 });
845+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 90 });
846+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 100 });
847+
expect(enqueue).toHaveBeenCalledTimes(5);
848+
});
849+
850+
it('ignores containers smaller than 50% of viewport height', () => {
851+
setup({ scroll: true });
852+
853+
const small = document.createElement('div');
854+
// clientHeight = 200 px, innerHeight = 600 → 200 ≤ 300 → filtered out
855+
setContainerGeometry(small, 2000, 200, 0);
856+
document.body.appendChild(small);
857+
858+
(small as Record<string, unknown>).scrollTop = 1500;
859+
small.dispatchEvent(new Event('scroll'));
860+
flushRAF();
861+
862+
expect(enqueue).not.toHaveBeenCalled();
863+
});
864+
865+
it('fires each milestone only once across multiple large containers (global dedup)', () => {
866+
setup({ scroll: true });
867+
868+
const main = document.createElement('div');
869+
const sidebar = document.createElement('div');
870+
setContainerGeometry(main, 2000, 500, 0);
871+
setContainerGeometry(sidebar, 1000, 500, 0);
872+
document.body.appendChild(main);
873+
document.body.appendChild(sidebar);
874+
875+
// Scroll main to 30% → fires 25
876+
(main as Record<string, unknown>).scrollTop = 450;
877+
main.dispatchEvent(new Event('scroll'));
878+
flushRAF();
879+
expect(enqueue).toHaveBeenCalledTimes(1);
880+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
881+
882+
// Scroll sidebar to 60% → 25 already fired, should only fire 50
883+
(sidebar as Record<string, unknown>).scrollTop = 300;
884+
sidebar.dispatchEvent(new Event('scroll'));
885+
flushRAF();
886+
expect(enqueue).toHaveBeenCalledTimes(2);
887+
expect(enqueue).toHaveBeenLastCalledWith('scroll_depth', { depth: 50 });
888+
});
889+
890+
it('does not fire for a detached element not in the document', () => {
891+
setup({ scroll: true });
892+
893+
const detached = document.createElement('div');
894+
setContainerGeometry(detached, 2000, 500, 0);
895+
// Not appended to body — capture phase won't reach document.
896+
(detached as Record<string, unknown>).scrollTop = 1500;
897+
detached.dispatchEvent(new Event('scroll'));
898+
flushRAF();
899+
900+
expect(enqueue).not.toHaveBeenCalled();
901+
});
902+
});
903+
904+
describe('reset', () => {
905+
beforeEach(() => {
906+
setScrollGeometry(2000, 500, 0);
907+
});
908+
909+
it('allows milestones to re-fire after resetScroll() (SPA route change)', () => {
910+
const result = setupAutocapture({ scroll: true }, enqueue, () => consent);
911+
teardown = result.teardown;
912+
913+
// Fire 25 milestone.
914+
(window as Record<string, unknown>).scrollY = 375;
915+
document.dispatchEvent(new Event('scroll'));
916+
flushRAF();
917+
expect(enqueue).toHaveBeenCalledWith('scroll_depth', { depth: 25 });
918+
expect(enqueue).toHaveBeenCalledTimes(1);
919+
920+
// Simulate SPA route change: scroll back to top, then call resetScroll.
921+
(window as Record<string, unknown>).scrollY = 0;
922+
result.resetScroll();
923+
924+
// Scrolling to 25% again should re-fire the milestone.
925+
(window as Record<string, unknown>).scrollY = 375;
926+
document.dispatchEvent(new Event('scroll'));
927+
flushRAF();
928+
expect(enqueue).toHaveBeenCalledTimes(2);
929+
expect(enqueue).toHaveBeenLastCalledWith('scroll_depth', { depth: 25 });
930+
});
931+
});
789932
});
790933

791934
// ---------- Email hashing ----------

0 commit comments

Comments
 (0)