From 0be6d6f96a77b048ce05df73962b2b348b08d9f7 Mon Sep 17 00:00:00 2001 From: Lucas Ricoy Date: Tue, 14 Oct 2025 13:37:22 -0300 Subject: [PATCH 1/2] feat: add capture_pageleave 'on_navigation' option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new 'on_navigation' option for capture_pageleave config that captures pageleave events on every SPA navigation (pushState, replaceState, popstate) in addition to window unload. This enables the expected behavior pattern for pure client-side navigation: - pageView A → (navigate) → pageLeave A + pageView B → (navigate) → pageLeave B + pageView C --- .../extensions/history-autocapture.test.ts | 166 ++++++++++++++++++ .../src/extensions/history-autocapture.ts | 10 ++ packages/browser/src/posthog-core.ts | 5 + packages/browser/src/types.ts | 3 +- 4 files changed, 183 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/__tests__/extensions/history-autocapture.test.ts b/packages/browser/src/__tests__/extensions/history-autocapture.test.ts index 4e407050e8..0e1b469c84 100644 --- a/packages/browser/src/__tests__/extensions/history-autocapture.test.ts +++ b/packages/browser/src/__tests__/extensions/history-autocapture.test.ts @@ -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) @@ -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()) + }) + }) }) diff --git a/packages/browser/src/extensions/history-autocapture.ts b/packages/browser/src/extensions/history-autocapture.ts index 2dfb6a1f42..a9bce8e8fd 100644 --- a/packages/browser/src/extensions/history-autocapture.ts +++ b/packages/browser/src/extensions/history-autocapture.ts @@ -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) { + this._instance.capture('$pageleave', { ...pageleaveProps, navigation_type: navigationType }) + } + } + this._instance.capture('$pageview', { navigation_type: navigationType }) } diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index fa48b30506..6765d2acb3 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -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 diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 13c551fd77..05646f9f5f 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -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. From 80170046fa5ad1b06b614d3126d48bd7fe7c1f2e Mon Sep 17 00:00:00 2001 From: Lucas Ricoy Date: Tue, 14 Oct 2025 16:15:17 -0300 Subject: [PATCH 2/2] mangled and snapshot fixes --- .changeset/swift-bottles-design.md | 5 +++++ .../src/__tests__/__snapshots__/config-snapshot.test.ts.snap | 5 +++-- packages/browser/terser-mangled-names.json | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/swift-bottles-design.md diff --git a/.changeset/swift-bottles-design.md b/.changeset/swift-bottles-design.md new file mode 100644 index 0000000000..fd74ff673c --- /dev/null +++ b/.changeset/swift-bottles-design.md @@ -0,0 +1,5 @@ +--- +'posthog-js': minor +--- + +Includes a option to capture pageleave events on history navigation diff --git a/packages/browser/src/__tests__/__snapshots__/config-snapshot.test.ts.snap b/packages/browser/src/__tests__/__snapshots__/config-snapshot.test.ts.snap index a8da7d6b62..1afd457627 100644 --- a/packages/browser/src/__tests__/__snapshots__/config-snapshot.test.ts.snap +++ b/packages/browser/src/__tests__/__snapshots__/config-snapshot.test.ts.snap @@ -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`] = ` "{ @@ -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": [ diff --git a/packages/browser/terser-mangled-names.json b/packages/browser/terser-mangled-names.json index 241ceb2a9c..586fea4c99 100644 --- a/packages/browser/terser-mangled-names.json +++ b/packages/browser/terser-mangled-names.json @@ -283,6 +283,7 @@ "_setupPopstateListener", "_setupSiteApps", "_shouldCapturePageleave", + "_shouldCapturePageleaveOnNavigation", "_shouldDisableFlags", "_shouldIncludeEvaluationEnvironments", "_showPreviewWebExperiment",