Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/swift-bottles-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': minor
---

Includes a option to capture pageleave events on history navigation
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`config snapshot for PostHogConfig 1`] = `
"{
Expand Down Expand Up @@ -108,7 +108,8 @@ exports[`config snapshot for PostHogConfig 1`] = `
"capture_pageleave": [
"false",
"true",
"\\"if_capture_pageview\\""
"\\"if_capture_pageview\\"",
"\\"on_navigation\\""
],
"cookie_expiration": "number",
"upgrade": [
Expand Down
166 changes: 166 additions & 0 deletions packages/browser/src/__tests__/extensions/history-autocapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ describe('HistoryAutocapture', () => {
},
pageViewManager: {
doPageView: pageViewManagerDoPageView,
doPageLeave: jest.fn().mockReturnValue({ $pageview_id: 'prev-id' }),
},
scrollManager: {
resetContext: scrollManagerResetContext,
},
_shouldCapturePageleaveOnNavigation: jest.fn().mockReturnValue(false),
}

historyAutocapture = new HistoryAutocapture(posthog)
Expand Down Expand Up @@ -429,4 +431,168 @@ describe('HistoryAutocapture', () => {
removeEventListenerSpy.mockRestore()
})
})

describe('Pageleave on navigation (capture_pageleave: "on_navigation")', () => {
beforeEach(() => {
historyAutocapture.stop()

// Reset history methods to allow re-patching
window.history.pushState = originalPushState
window.history.replaceState = originalReplaceState

posthog.config.capture_pageleave = 'on_navigation'
posthog._shouldCapturePageleaveOnNavigation = jest.fn().mockReturnValue(true)

// Update doPageLeave mock to simulate real behavior - track if there's a previous page
let hasNavigated = false
posthog.pageViewManager.doPageLeave = jest.fn().mockImplementation(() => {
if (!hasNavigated) {
hasNavigated = true
return { $pageview_id: 'prev-id' }
}
return {
$pageview_id: 'prev-id',
$prev_pageview_id: 'previous-page-id',
$prev_pageview_pathname: '/previous-path',
}
})

historyAutocapture = new HistoryAutocapture(posthog)
historyAutocapture.startIfEnabled()
capture.mockClear()
})

it('should capture pageleave before pageview on pushState navigation', () => {
// Navigate to first page
mockLocation.pathname = '/page-a'
window.history.pushState({ page: 1 }, 'Page A', '/page-a')

capture.mockClear()

// Navigate to second page - should capture pageleave for /page-a first
mockLocation.pathname = '/page-b'
window.history.pushState({ page: 2 }, 'Page B', '/page-b')

expect(capture).toHaveBeenCalledTimes(2)
expect(capture).toHaveBeenNthCalledWith(
1,
'$pageleave',
expect.objectContaining({ navigation_type: 'pushState' })
)
expect(capture).toHaveBeenNthCalledWith(2, '$pageview', { navigation_type: 'pushState' })
})

it('should capture pageleave before pageview on replaceState navigation', () => {
// Navigate to first page
mockLocation.pathname = '/page-a'
window.history.replaceState({ page: 1 }, 'Page A', '/page-a')

capture.mockClear()

// Navigate to second page - should capture pageleave for /page-a first
mockLocation.pathname = '/page-b'
window.history.replaceState({ page: 2 }, 'Page B', '/page-b')

expect(capture).toHaveBeenCalledTimes(2)
expect(capture).toHaveBeenNthCalledWith(
1,
'$pageleave',
expect.objectContaining({ navigation_type: 'replaceState' })
)
expect(capture).toHaveBeenNthCalledWith(2, '$pageview', { navigation_type: 'replaceState' })
})

it('should capture pageleave before pageview on popstate navigation', () => {
// Navigate to first page
mockLocation.pathname = '/page-a'
window.history.pushState({ page: 1 }, 'Page A', '/page-a')

// Navigate to second page
mockLocation.pathname = '/page-b'
window.history.pushState({ page: 2 }, 'Page B', '/page-b')

capture.mockClear()

// Simulate back navigation - pathname changes back to /page-a
mockLocation.pathname = '/page-a'
window.dispatchEvent(new PopStateEvent('popstate', { state: { page: 1 } }))

expect(capture).toHaveBeenCalledTimes(2)
expect(capture).toHaveBeenNthCalledWith(
1,
'$pageleave',
expect.objectContaining({ navigation_type: 'popstate' })
)
expect(capture).toHaveBeenNthCalledWith(2, '$pageview', { navigation_type: 'popstate' })
})

it('should capture full navigation sequence: A -> B -> C', () => {
// Visit page A (no pageleave on first visit)
mockLocation.pathname = '/page-a'
window.history.pushState({ page: 1 }, 'Page A', '/page-a')

expect(capture).toHaveBeenCalledTimes(1)
expect(capture).toHaveBeenCalledWith('$pageview', { navigation_type: 'pushState' })

capture.mockClear()

// Visit page B (pageleave A + pageview B)
mockLocation.pathname = '/page-b'
window.history.pushState({ page: 2 }, 'Page B', '/page-b')

expect(capture).toHaveBeenCalledTimes(2)
expect(capture).toHaveBeenNthCalledWith(
1,
'$pageleave',
expect.objectContaining({ navigation_type: 'pushState' })
)
expect(capture).toHaveBeenNthCalledWith(2, '$pageview', { navigation_type: 'pushState' })

capture.mockClear()

// Visit page C (pageleave B + pageview C)
mockLocation.pathname = '/page-c'
window.history.pushState({ page: 3 }, 'Page C', '/page-c')

expect(capture).toHaveBeenCalledTimes(2)
expect(capture).toHaveBeenNthCalledWith(
1,
'$pageleave',
expect.objectContaining({ navigation_type: 'pushState' })
)
expect(capture).toHaveBeenNthCalledWith(2, '$pageview', { navigation_type: 'pushState' })
})

it('should NOT capture pageleave when option is not set', () => {
historyAutocapture.stop()
posthog.config.capture_pageleave = 'if_capture_pageview'
posthog._shouldCapturePageleaveOnNavigation = jest.fn().mockReturnValue(false)
historyAutocapture = new HistoryAutocapture(posthog)
historyAutocapture.startIfEnabled()
capture.mockClear()

// Navigate to first page
mockLocation.pathname = '/page-a'
window.history.pushState({ page: 1 }, 'Page A', '/page-a')

capture.mockClear()

// Navigate to second page - should NOT capture pageleave
mockLocation.pathname = '/page-b'
window.history.pushState({ page: 2 }, 'Page B', '/page-b')

expect(capture).toHaveBeenCalledTimes(1)
expect(capture).toHaveBeenCalledWith('$pageview', { navigation_type: 'pushState' })
})

it('should not capture pageleave on first pageview (no previous page)', () => {
// First navigation - no previous page to leave
mockLocation.pathname = '/page-a'
window.history.pushState({ page: 1 }, 'Page A', '/page-a')

expect(capture).toHaveBeenCalledTimes(1)
expect(capture).toHaveBeenCalledWith('$pageview', { navigation_type: 'pushState' })
expect(capture).not.toHaveBeenCalledWith('$pageleave', expect.anything())
})
})
})
10 changes: 10 additions & 0 deletions packages/browser/src/extensions/history-autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ export class HistoryAutocapture {

// Only capture pageview if the pathname has changed and the feature is enabled
if (currentPathname !== this._lastPathname && this.isEnabled) {
// Capture pageleave for the previous page before capturing the new pageview
// PageViewManager will handle checking if there's a previous pageview to leave
if (this._instance._shouldCapturePageleaveOnNavigation()) {
const pageleaveProps = this._instance.pageViewManager.doPageLeave(new Date())
// Only capture pageleave if there was a previous page ($prev_pageview_id will be present)
if (pageleaveProps.$prev_pageview_id) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When could this not be the case? (prev_pageview_id is not present)

this._instance.capture('$pageleave', { ...pageleaveProps, navigation_type: navigationType })
}
}

this._instance.capture('$pageview', { navigation_type: navigationType })
}

Expand Down
5 changes: 5 additions & 0 deletions packages/browser/src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2784,11 +2784,16 @@ export class PostHog {
_shouldCapturePageleave(): boolean {
return (
this.config.capture_pageleave === true ||
this.config.capture_pageleave === 'on_navigation' ||
(this.config.capture_pageleave === 'if_capture_pageview' &&
(this.config.capture_pageview === true || this.config.capture_pageview === 'history_change'))
)
}

_shouldCapturePageleaveOnNavigation(): boolean {
return this.config.capture_pageleave === 'on_navigation'
}

/**
* Creates a person profile for the current user, if they don't already have one and config.person_profiles is set
* to 'identified_only'. Produces a warning and does not create a profile if config.person_profiles is set to
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,10 +433,11 @@ export interface PostHogConfig {
* Determines whether PostHog should capture pageleave events.
* If set to `true`, it will capture pageleave events for all pages.
* If set to `'if_capture_pageview'`, it will only capture pageleave events if `capture_pageview` is also set to `true` or `'history_change'`.
* If set to `'on_navigation'`, it will capture pageleave events on every browser history navigation (pushState, replaceState, popstate) as well as window unload.
*
* @default 'if_capture_pageview'
*/
capture_pageleave: boolean | 'if_capture_pageview'
capture_pageleave: boolean | 'if_capture_pageview' | 'on_navigation'

/**
* Determines the number of days to store cookies for.
Expand Down
1 change: 1 addition & 0 deletions packages/browser/terser-mangled-names.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@
"_setupPopstateListener",
"_setupSiteApps",
"_shouldCapturePageleave",
"_shouldCapturePageleaveOnNavigation",
"_shouldDisableFlags",
"_shouldIncludeEvaluationEnvironments",
"_showPreviewWebExperiment",
Expand Down
Loading