Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/autocapture/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
1 change: 1 addition & 0 deletions packages/core/src/autocapture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export {
attachTimeOnPageTracker,
detachTimeOnPageTracker,
resetTimeOnPage,
flushTimeOnPage,
} from './time-on-page';
152 changes: 102 additions & 50 deletions packages/core/src/autocapture/page-view.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<() => void>>();
const location = {
Expand Down Expand Up @@ -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<void> {
await vi.runAllTimersAsync();
}

function expectPageLeave(
track: ReturnType<typeof vi.fn>,
nth: number,
navigationType: string,
): void {
expect(track).toHaveBeenNthCalledWith(nth, EventTypes.PAGE_LEAVE, {
navigationType,
});
}

function expectPageView(
track: ReturnType<typeof vi.fn>,
nth: number,
navigationType: string,
title = 'Initial title',
): void {
expect(track).toHaveBeenNthCalledWith(nth, EventTypes.PAGE_VIEW, {
navigationType,
title,
});
}

describe('autocapture/page-view', () => {
beforeEach(() => {
vi.useFakeTimers();
Expand All @@ -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);
Expand All @@ -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, {
Expand All @@ -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);
});
});
9 changes: 9 additions & 0 deletions packages/core/src/autocapture/page-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ let _debounceTimer: ReturnType<typeof setTimeout> | 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();
Expand All @@ -36,6 +38,7 @@ export function detachPageViewTracker(): void {
clearDebounce();

_track = null;
_timeOnPage = false;
_lastUrl = '';
}

Expand Down Expand Up @@ -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, {
Expand Down
45 changes: 30 additions & 15 deletions packages/core/src/autocapture/time-on-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
attachTimeOnPageTracker,
detachTimeOnPageTracker,
resetTimeOnPage,
flushTimeOnPage,
} from './time-on-page';

const mockTrack = vi.fn();
Expand Down Expand Up @@ -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'));
Expand All @@ -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();
});
});
26 changes: 21 additions & 5 deletions packages/core/src/autocapture/time-on-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading