diff --git a/packages/core/src/autocapture/index.test.ts b/packages/core/src/autocapture/index.test.ts index e283746..9e27af9 100644 --- a/packages/core/src/autocapture/index.test.ts +++ b/packages/core/src/autocapture/index.test.ts @@ -16,5 +16,6 @@ describe('autocapture/index exports', () => { expect(typeof autocapture.attachTimeOnPageTracker).toBe('function'); expect(typeof autocapture.detachTimeOnPageTracker).toBe('function'); expect(typeof autocapture.resetTimeOnPage).toBe('function'); + expect(typeof autocapture.flushTimeOnPage).toBe('function'); }); }); diff --git a/packages/core/src/autocapture/index.ts b/packages/core/src/autocapture/index.ts index 202cd91..a9942db 100644 --- a/packages/core/src/autocapture/index.ts +++ b/packages/core/src/autocapture/index.ts @@ -10,4 +10,5 @@ export { attachTimeOnPageTracker, detachTimeOnPageTracker, resetTimeOnPage, + flushTimeOnPage, } from './time-on-page'; diff --git a/packages/core/src/autocapture/page-view.test.ts b/packages/core/src/autocapture/page-view.test.ts index 00d9140..b229501 100644 --- a/packages/core/src/autocapture/page-view.test.ts +++ b/packages/core/src/autocapture/page-view.test.ts @@ -1,8 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ResolvedConfig } from '../config/types'; import { EventTypes } from '../events/event-types'; import { attachPageViewTracker, detachPageViewTracker } from './page-view'; +const configWithTimeOnPage = { + autocapture: { timeOnPage: true }, +} as ResolvedConfig; + +const configWithoutTimeOnPage = { + autocapture: { timeOnPage: false }, +} as ResolvedConfig; + function createBrowserHarness(initialHref = 'https://example.com/start') { const listeners = new Map void>>(); const location = { @@ -67,6 +76,42 @@ function createBrowserHarness(initialHref = 'https://example.com/start') { }; } +function setupTracker( + config: ResolvedConfig, + initialHref = 'https://example.com/start', +) { + const harness = createBrowserHarness(initialHref); + const track = vi.fn(); + attachPageViewTracker({ track, config }); + return { ...harness, track }; +} + +async function flushTimers(): Promise { + await vi.runAllTimersAsync(); +} + +function expectPageLeave( + track: ReturnType, + nth: number, + navigationType: string, +): void { + expect(track).toHaveBeenNthCalledWith(nth, EventTypes.PAGE_LEAVE, { + navigationType, + }); +} + +function expectPageView( + track: ReturnType, + nth: number, + navigationType: string, + title = 'Initial title', +): void { + expect(track).toHaveBeenNthCalledWith(nth, EventTypes.PAGE_VIEW, { + navigationType, + title, + }); +} + describe('autocapture/page-view', () => { beforeEach(() => { vi.useFakeTimers(); @@ -86,7 +131,7 @@ describe('autocapture/page-view', () => { const originalPush = historyObj.pushState; const originalReplace = historyObj.replaceState; - attachPageViewTracker({ track, config: {} as never }); + attachPageViewTracker({ track, config: configWithoutTimeOnPage }); expect(historyObj.pushState).not.toBe(originalPush); expect(historyObj.replaceState).not.toBe(originalReplace); @@ -112,14 +157,28 @@ describe('autocapture/page-view', () => { expect(track).not.toHaveBeenCalled(); }); - it('fires page_view on pushState URL changes', async () => { - const { historyObj } = createBrowserHarness('https://example.com/start'); - const track = vi.fn(); + it('fires page_leave and page_view on pushState URL changes when timeOnPage is enabled', async () => { + const { historyObj, track } = setupTracker( + configWithTimeOnPage, + 'https://example.com/start', + ); - attachPageViewTracker({ track, config: {} as never }); + historyObj.pushState({}, '', '/next'); + await flushTimers(); + + expect(track).toHaveBeenCalledTimes(2); + expectPageLeave(track, 1, 'push'); + expectPageView(track, 2, 'push'); + }); + + it('fires only page_view on pushState when timeOnPage is disabled', async () => { + const { historyObj, track } = setupTracker( + configWithoutTimeOnPage, + 'https://example.com/start', + ); historyObj.pushState({}, '', '/next'); - await vi.runAllTimersAsync(); + await flushTimers(); expect(track).toHaveBeenCalledTimes(1); expect(track).toHaveBeenCalledWith(EventTypes.PAGE_VIEW, { @@ -129,100 +188,93 @@ describe('autocapture/page-view', () => { }); it('does not fire when URL does not change', async () => { - const { historyObj } = createBrowserHarness('https://example.com/same'); - const track = vi.fn(); - - attachPageViewTracker({ track, config: {} as never }); + const { historyObj, track } = setupTracker( + configWithoutTimeOnPage, + 'https://example.com/same', + ); historyObj.pushState({}, '', 'https://example.com/same'); - await vi.runAllTimersAsync(); + await flushTimers(); expect(track).not.toHaveBeenCalled(); }); it('does not fire on hash-only URL changes', async () => { - const { historyObj } = createBrowserHarness( + const { historyObj, track } = setupTracker( + configWithoutTimeOnPage, 'https://example.com/page?a=1#old', ); - const track = vi.fn(); - - attachPageViewTracker({ track, config: {} as never }); historyObj.pushState({}, '', 'https://example.com/page?a=1#new'); - await vi.runAllTimersAsync(); + await flushTimers(); expect(track).not.toHaveBeenCalled(); }); it('suppresses replaceState when pathname does not change', async () => { - const { historyObj } = createBrowserHarness('https://example.com/page?a=1'); - const track = vi.fn(); - - attachPageViewTracker({ track, config: {} as never }); + const { historyObj, track } = setupTracker( + configWithTimeOnPage, + 'https://example.com/page?a=1', + ); historyObj.replaceState({}, '', 'https://example.com/page?a=2'); - await vi.runAllTimersAsync(); + await flushTimers(); expect(track).not.toHaveBeenCalled(); historyObj.replaceState({}, '', 'https://example.com/other?a=2'); - await vi.runAllTimersAsync(); + await flushTimers(); - expect(track).toHaveBeenCalledTimes(1); - expect(track).toHaveBeenLastCalledWith(EventTypes.PAGE_VIEW, { - navigationType: 'replace', - title: 'Initial title', - }); + expect(track).toHaveBeenCalledTimes(2); + expectPageLeave(track, 1, 'replace'); + expectPageView(track, 2, 'replace'); }); it('fires on popstate after URL changes', async () => { - const { dispatchPopState, setHref } = createBrowserHarness( + const { dispatchPopState, setHref, track } = setupTracker( + configWithTimeOnPage, 'https://example.com/a', ); - const track = vi.fn(); - - attachPageViewTracker({ track, config: {} as never }); setHref('https://example.com/b'); dispatchPopState(); - await vi.runAllTimersAsync(); + await flushTimers(); - expect(track).toHaveBeenCalledTimes(1); - expect(track).toHaveBeenCalledWith(EventTypes.PAGE_VIEW, { - navigationType: 'pop', - title: 'Initial title', - }); + expect(track).toHaveBeenCalledTimes(2); + expectPageLeave(track, 1, 'pop'); + expectPageView(track, 2, 'pop'); }); it('clears pending debounce and prevents calls after detach', async () => { - const { historyObj } = createBrowserHarness('https://example.com/start'); - const track = vi.fn(); - - attachPageViewTracker({ track, config: {} as never }); + const { historyObj, track } = setupTracker( + configWithoutTimeOnPage, + 'https://example.com/start', + ); historyObj.pushState({}, '', '/queued'); detachPageViewTracker(); - await vi.runAllTimersAsync(); + await flushTimers(); expect(track).not.toHaveBeenCalled(); }); it('is safe to attach and detach multiple times', async () => { - const { historyObj } = createBrowserHarness('https://example.com/one'); - const track = vi.fn(); + const { historyObj, track } = setupTracker( + configWithTimeOnPage, + 'https://example.com/one', + ); - attachPageViewTracker({ track, config: {} as never }); - attachPageViewTracker({ track, config: {} as never }); + attachPageViewTracker({ track, config: configWithTimeOnPage }); historyObj.pushState({}, '', '/two'); - await vi.runAllTimersAsync(); - expect(track).toHaveBeenCalledTimes(1); + await flushTimers(); + expect(track).toHaveBeenCalledTimes(2); detachPageViewTracker(); detachPageViewTracker(); historyObj.pushState({}, '', '/three'); - await vi.runAllTimersAsync(); - expect(track).toHaveBeenCalledTimes(1); + await flushTimers(); + expect(track).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/core/src/autocapture/page-view.ts b/packages/core/src/autocapture/page-view.ts index 68b53c9..4d05fd6 100644 --- a/packages/core/src/autocapture/page-view.ts +++ b/packages/core/src/autocapture/page-view.ts @@ -21,9 +21,11 @@ let _debounceTimer: ReturnType | null = null; let _onPopState: (() => void) | null = null; let _track: TrackFn | null = null; +let _timeOnPage = false; export function attachPageViewTracker(opts: PageViewTrackerOptions): void { _track = opts.track; + _timeOnPage = opts.config.autocapture.timeOnPage; _lastUrl = globalThis.location.href; patchHistory(); @@ -36,6 +38,7 @@ export function detachPageViewTracker(): void { clearDebounce(); _track = null; + _timeOnPage = false; _lastUrl = ''; } @@ -113,6 +116,12 @@ function fireIfUrlChanged(type: NavigationType): void { _lastUrl = currentUrl; + if (_track !== null && _timeOnPage) { + _track(EventTypes.PAGE_LEAVE, { + navigationType: type, + }); + } + setTimeout(() => { if (_track === null) return; _track(EventTypes.PAGE_VIEW, { diff --git a/packages/core/src/autocapture/time-on-page.test.ts b/packages/core/src/autocapture/time-on-page.test.ts index 6350346..6d4980d 100644 --- a/packages/core/src/autocapture/time-on-page.test.ts +++ b/packages/core/src/autocapture/time-on-page.test.ts @@ -4,6 +4,7 @@ import { attachTimeOnPageTracker, detachTimeOnPageTracker, resetTimeOnPage, + flushTimeOnPage, } from './time-on-page'; const mockTrack = vi.fn(); @@ -67,25 +68,16 @@ describe('Time on page tracking', () => { expect(mockTrack).not.toHaveBeenCalled(); }); - it('resets timer on SPA navigation', () => { - // Advance time while page is visible + it('resets timer on SPA navigation without firing page_leave', () => { vi.advanceTimersByTime(5000); - // Fire pagehide to accumulate the 5000ms (calls accumulateActiveTime + flushActiveTime) - // Note: flushActiveTime will fire the event and reset _activeTime back to 0 - globalThis.dispatchEvent(new Event('pagehide')); - - // Should have fired for the previous page - expect(mockTrack).toHaveBeenCalledTimes(1); - const firstEventProps = mockTrack.mock.calls[0][1]; - expect(firstEventProps.duration).toBeLessThan(6000); - expect(firstEventProps.duration).toBeGreaterThan(4000); - - mockTrack.mockClear(); - - // Now call resetTimeOnPage to simulate SPA navigation reset + // resetTimeOnPage simulates SPA navigation: it resets timers + // but no longer fires PAGE_LEAVE — that is the page-view tracker's job resetTimeOnPage(); + // No page_leave event should have been fired + expect(mockTrack).not.toHaveBeenCalled(); + // Fresh timer for new page vi.advanceTimersByTime(3000); globalThis.dispatchEvent(new Event('pagehide')); @@ -94,4 +86,27 @@ describe('Time on page tracking', () => { expect(props.duration).toBeLessThan(4000); expect(props.duration).toBeGreaterThan(2000); }); + + it('flushTimeOnPage returns duration and resets accumulator', () => { + vi.advanceTimersByTime(5000); + + const duration = flushTimeOnPage(); + + expect(duration).toBe(5000); + + // Accumulator should be reset — further flushes return null if no time passes + const secondFlush = flushTimeOnPage(); + expect(secondFlush).toBeNull(); + + // No event should have been fired — flushTimeOnPage does not track + expect(mockTrack).not.toHaveBeenCalled(); + }); + + it('flushTimeOnPage returns null for durations under 1 second', () => { + vi.advanceTimersByTime(500); + + const duration = flushTimeOnPage(); + + expect(duration).toBeNull(); + }); }); diff --git a/packages/core/src/autocapture/time-on-page.ts b/packages/core/src/autocapture/time-on-page.ts index 5929bb5..dedf669 100644 --- a/packages/core/src/autocapture/time-on-page.ts +++ b/packages/core/src/autocapture/time-on-page.ts @@ -54,15 +54,31 @@ export function detachTimeOnPageTracker(): void { _visibleSince = null; } +/** + * Accumulates active time and returns the duration spent on the current page. + * Called by the tracker on SPA navigation to enrich the PAGE_LEAVE event + * with duration data. Does NOT fire a PAGE_LEAVE event — the caller is + * responsible for that. + * + * Returns null when the accumulated time is below the 1-second minimum + * threshold, indicating the visit was too short to be meaningful. + */ +export function flushTimeOnPage(): number | null { + accumulateActiveTime(); + + const duration = _activeTime; + _activeTime = 0; + + return duration >= 1000 ? duration : null; +} + /** * Called by the page view tracker when a new SPA navigation occurs. - * Fires the accumulated time for the previous page and resets for the new one. + * Resets the timer for the new page without firing a PAGE_LEAVE event. + * (PAGE_LEAVE is now fired by the page-view tracker; time-on-page only + * contributes duration data via flushTimeOnPage.) */ export function resetTimeOnPage(): void { - // Fire accumulated time for the page we are leaving - flushActiveTime(); - - // Reset for the new page _activeTime = 0; _visibleSince = document.visibilityState === 'visible' ? Date.now() : null; } diff --git a/packages/core/src/tracker.ts b/packages/core/src/tracker.ts index 2bc6d46..81837b1 100644 --- a/packages/core/src/tracker.ts +++ b/packages/core/src/tracker.ts @@ -30,6 +30,7 @@ import { detachTimeOnPageTracker, resetScrollDepth, resetTimeOnPage, + flushTimeOnPage, } from './autocapture'; import { EventTypes } from './events/event-types'; import { VERSION } from './version'; @@ -198,7 +199,19 @@ export class TanilyticsTracker { if (pageViews) { attachPageViewTracker({ track: (eventType, properties) => { - this.trackInternal(eventType as InternalEventType, properties); + let enrichedProperties = properties; + + if (eventType === EventTypes.PAGE_LEAVE && timeOnPage) { + const duration = flushTimeOnPage(); + if (duration !== null) { + enrichedProperties = { ...properties, duration }; + } + } + + this.trackInternal( + eventType as InternalEventType, + enrichedProperties, + ); // Reset per-page autocapture state after SPA page view events. if (eventType === EventTypes.PAGE_VIEW) {