@@ -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