diff --git a/src/interactionMonitor.ts b/src/interactionMonitor.ts index f98f9f64..a14c5a33 100644 --- a/src/interactionMonitor.ts +++ b/src/interactionMonitor.ts @@ -1,6 +1,6 @@ import type {EventListener} from './eventManager'; import {SynchronousEventManager} from './eventManager'; -import type {UserClicked, UserScrolled, Point} from './trackingEvents'; +import type {UserClicked, UserScrolled, Point, Size} from './trackingEvents'; type InteractionEventMap = { userClicked: UserClicked, @@ -15,6 +15,7 @@ type Options = { type ScrollState = { start: Point, lastPosition: Point, + surfaceSize: Size, }; export class InteractionMonitor { @@ -38,6 +39,7 @@ export class InteractionMonitor { this.handleClick = this.handleClick.bind(this); this.handleScroll = this.handleScroll.bind(this); + this.handleResize = this.handleResize.bind(this); } public addListener( @@ -67,6 +69,7 @@ export class InteractionMonitor { window.addEventListener('click', this.handleClick, true); window.addEventListener('scroll', this.handleScroll, true); + window.addEventListener('resize', this.handleResize, true); } public disable(): void { @@ -78,6 +81,7 @@ export class InteractionMonitor { window.removeEventListener('click', this.handleClick, true); window.removeEventListener('scroll', this.handleScroll, true); + window.removeEventListener('resize', this.handleResize, true); this.flushPendingScroll(); } @@ -104,16 +108,48 @@ export class InteractionMonitor { }); } + private handleResize(): void { + if (this.scrollDebounceTimer !== undefined) { + window.clearTimeout(this.scrollDebounceTimer); + this.scrollDebounceTimer = undefined; + } + + const currentPosition: Point = { + x: Math.max(0, Math.round(window.scrollX)), + y: Math.max(0, Math.round(window.scrollY)), + }; + + const currentSurfaceSize: Size = { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }; + + this.scrollState = { + start: currentPosition, + lastPosition: currentPosition, + surfaceSize: currentSurfaceSize, + }; + } + private handleScroll(): void { const currentPosition: Point = { x: Math.max(0, Math.round(window.scrollX)), y: Math.max(0, Math.round(window.scrollY)), }; - if (this.scrollState === undefined) { + const currentSurfaceSize: Size = { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }; + + if ( + this.scrollState?.surfaceSize.width !== currentSurfaceSize.width + || this.scrollState.surfaceSize.height !== currentSurfaceSize.height + ) { this.scrollState = { start: currentPosition, lastPosition: currentPosition, + surfaceSize: currentSurfaceSize, }; } else if (this.hasDirectionChanged(this.scrollState, currentPosition)) { const turningPoint = this.scrollState.lastPosition; @@ -123,6 +159,7 @@ export class InteractionMonitor { this.scrollState = { start: turningPoint, lastPosition: currentPosition, + surfaceSize: currentSurfaceSize, }; } else { this.scrollState.lastPosition = currentPosition; @@ -190,14 +227,28 @@ export class InteractionMonitor { return; } - const {start} = this.scrollState; + const state = this.scrollState; + + this.scrollState = undefined; + + const currentSurfaceSize: Size = { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }; + + if ( + state.surfaceSize.width !== currentSurfaceSize.width + || state.surfaceSize.height !== currentSurfaceSize.height + ) { + return; + } + + const {start} = state; const destination = end ?? { x: Math.max(0, Math.round(window.scrollX)), y: Math.max(0, Math.round(window.scrollY)), }; - this.scrollState = undefined; - if (start.x === destination.x && start.y === destination.y) { return; } @@ -206,10 +257,7 @@ export class InteractionMonitor { type: 'userScrolled', start: start, end: destination, - surfaceSize: { - width: document.documentElement.scrollWidth, - height: document.documentElement.scrollHeight, - }, + surfaceSize: state.surfaceSize, // Uses clientWidth/clientHeight instead of innerWidth/innerHeight to get the // layout viewport size, which remains stable regardless of pinch-to-zoom level // on mobile devices. diff --git a/test/interactionMonitor.test.ts b/test/interactionMonitor.test.ts index f340d2f8..2e0fd832 100644 --- a/test/interactionMonitor.test.ts +++ b/test/interactionMonitor.test.ts @@ -4,13 +4,30 @@ import type {UserClicked, UserScrolled} from '../src/trackingEvents'; describe('An interaction monitor', () => { const tabEventEmulator = new TabEventEmulator(); + let originalScrollWidth: PropertyDescriptor | undefined; + let originalScrollHeight: PropertyDescriptor | undefined; beforeEach(() => { + originalScrollWidth = Object.getOwnPropertyDescriptor(document.documentElement, 'scrollWidth'); + originalScrollHeight = Object.getOwnPropertyDescriptor(document.documentElement, 'scrollHeight'); + jest.useFakeTimers(); tabEventEmulator.registerListeners(); }); afterEach(() => { + if (originalScrollWidth === undefined) { + Reflect.deleteProperty(document.documentElement, 'scrollWidth'); + } else { + Object.defineProperty(document.documentElement, 'scrollWidth', originalScrollWidth); + } + + if (originalScrollHeight === undefined) { + Reflect.deleteProperty(document.documentElement, 'scrollHeight'); + } else { + Object.defineProperty(document.documentElement, 'scrollHeight', originalScrollHeight); + } + jest.clearAllMocks(); jest.useRealTimers(); tabEventEmulator.reset(); @@ -35,6 +52,13 @@ describe('An interaction monitor', () => { tabEventEmulator.dispatchEvent(window, new Event('scroll', {bubbles: true})); } + function setDocumentDimensions(width: number, height: number): void { + Object.defineProperties(document.documentElement, { + scrollWidth: {configurable: true, value: width}, + scrollHeight: {configurable: true, value: height}, + }); + } + it('should track a click with correct position and surface size', () => { const listener = jest.fn(); const monitor = new InteractionMonitor(); @@ -205,6 +229,86 @@ describe('An interaction monitor', () => { monitor.disable(); }); + it('should discard a pending scroll after document dimensions change', () => { + const listener = jest.fn(); + const monitor = new InteractionMonitor({scrollDebounceInterval: 150}); + + setDocumentDimensions(1000, 5000); + monitor.addListener('userScrolled', listener); + monitor.enable(); + + scrollTo(0, 4000); + scrollTo(0, 4500); + + setDocumentDimensions(1000, 800); + scrollTo(0, 0); + scrollTo(0, 200); + + jest.advanceTimersByTime(150); + + expect(listener).toHaveBeenCalledTimes(1); + + const event = listener.mock.calls[0][0] as UserScrolled; + + expect(event.start).toEqual({x: 0, y: 0}); + expect(event.end).toEqual({x: 0, y: 200}); + expect(event.surfaceSize).toEqual({width: 1000, height: 800}); + + monitor.disable(); + }); + + it('should suppress a pending scroll when dimensions change before debounce flush', () => { + const listener = jest.fn(); + const monitor = new InteractionMonitor({scrollDebounceInterval: 150}); + + setDocumentDimensions(1000, 5000); + monitor.addListener('userScrolled', listener); + monitor.enable(); + + scrollTo(0, 4000); + scrollTo(0, 4500); + + setDocumentDimensions(1000, 800); + + jest.advanceTimersByTime(150); + + expect(listener).not.toHaveBeenCalled(); + + monitor.disable(); + }); + + it('should discard a pending scroll and establish a new baseline on resize', () => { + const listener = jest.fn(); + const monitor = new InteractionMonitor({scrollDebounceInterval: 150}); + + setDocumentDimensions(1000, 5000); + monitor.addListener('userScrolled', listener); + monitor.enable(); + + scrollTo(50, 800); + scrollTo(150, 1600); + + Object.defineProperties(window, { + scrollX: {configurable: true, value: -10.6}, + scrollY: {configurable: true, value: 200.6}, + }); + setDocumentDimensions(800, 1200); + tabEventEmulator.dispatchEvent(window, new Event('resize')); + + scrollTo(300.2, 400.8); + jest.advanceTimersByTime(150); + + expect(listener).toHaveBeenCalledTimes(1); + + const event = listener.mock.calls[0][0] as UserScrolled; + + expect(event.start).toEqual({x: 0, y: 201}); + expect(event.end).toEqual({x: 300, y: 401}); + expect(event.surfaceSize).toEqual({width: 800, height: 1200}); + + monitor.disable(); + }); + it('should collapse continuous scrolling into a single event', () => { const listener = jest.fn(); const monitor = new InteractionMonitor({scrollDebounceInterval: 150}); @@ -370,6 +474,35 @@ describe('An interaction monitor', () => { expect(event.end).toEqual({x: 150, y: 300}); }); + it('should not establish a scroll baseline from a resize after disable', () => { + const listener = jest.fn(); + const monitor = new InteractionMonitor({scrollDebounceInterval: 150}); + + tabEventEmulator.reset(); + monitor.addListener('userScrolled', listener); + monitor.enable(); + monitor.disable(); + + Object.defineProperties(window, { + scrollX: {configurable: true, value: 100}, + scrollY: {configurable: true, value: 200}, + }); + window.dispatchEvent(new Event('resize')); + + monitor.enable(); + + Object.defineProperties(window, { + scrollX: {configurable: true, value: 300}, + scrollY: {configurable: true, value: 400}, + }); + window.dispatchEvent(new Event('scroll')); + jest.advanceTimersByTime(150); + + expect(listener).not.toHaveBeenCalled(); + + monitor.disable(); + }); + it('should be idempotent on enable', () => { const listener = jest.fn(); const monitor = new InteractionMonitor();