From c39983d25dce21e78644fab34a8afe29dd39942c Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Tue, 21 Oct 2025 21:12:49 -0700 Subject: [PATCH] fix: previous page location set to current page on first load in SPAs When a user directly visits a page in an SPA (e.g., Next.js), the plugin's saveURLInfo() function was being triggered by SPA hydration events (history.replaceState) before the first event was tracked. This caused the storage to be initialized with both current and previous page set to the same URL. The fix updates saveURLInfo() to check if storage already exists: - If storage exists: move current page to previous (normal navigation) - If storage is empty: use document.referrer as previous page (first load) This ensures that on direct visits with no referrer, Previous Page Type is set to "direct" and Previous Page Location is empty, rather than being incorrectly set to the current page URL. Fixes #1359 --- .../src/page-url-enrichment.ts | 11 +- .../test/page-url-enrichment.test.ts | 108 ++++++++++++++++++ yarn.lock | 10 +- 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/packages/plugin-page-url-enrichment-browser/src/page-url-enrichment.ts b/packages/plugin-page-url-enrichment-browser/src/page-url-enrichment.ts index 48626b175..50eb2bd81 100644 --- a/packages/plugin-page-url-enrichment-browser/src/page-url-enrichment.ts +++ b/packages/plugin-page-url-enrichment-browser/src/page-url-enrichment.ts @@ -84,8 +84,17 @@ export const pageUrlEnrichmentPlugin = (): EnrichmentPlugin => { const saveURLInfo = async () => { if (sessionStorage && isStorageEnabled) { const URLInfo = await sessionStorage.get(URL_INFO_STORAGE_KEY); - const previousURL = URLInfo?.[CURRENT_PAGE_STORAGE_KEY] || ''; const currentURL = getDecodeURI((typeof location !== 'undefined' && location.href) || ''); + const storedCurrentURL = URLInfo?.[CURRENT_PAGE_STORAGE_KEY] || ''; + + let previousURL; + if (currentURL === storedCurrentURL) { + previousURL = URLInfo?.[PREVIOUS_PAGE_STORAGE_KEY] || ''; + } else if (storedCurrentURL) { + previousURL = storedCurrentURL; + } else { + previousURL = document.referrer || ''; + } await sessionStorage.set(URL_INFO_STORAGE_KEY, { [CURRENT_PAGE_STORAGE_KEY]: currentURL, diff --git a/packages/plugin-page-url-enrichment-browser/test/page-url-enrichment.test.ts b/packages/plugin-page-url-enrichment-browser/test/page-url-enrichment.test.ts index de8285563..4040f8a9d 100644 --- a/packages/plugin-page-url-enrichment-browser/test/page-url-enrichment.test.ts +++ b/packages/plugin-page-url-enrichment-browser/test/page-url-enrichment.test.ts @@ -491,6 +491,114 @@ describe('pageUrlEnrichmentPlugin', () => { }); }); + describe('first page load with document.referrer', () => { + test('should set Previous Page Type to "direct" when no referrer exists', async () => { + sessionStorage.clear(); + + Object.defineProperty(document, 'referrer', { + value: '', + configurable: true, + }); + + const newPlugin = pageUrlEnrichmentPlugin(); + await newPlugin.setup?.(mockConfig, mockAmplitude); + + const firstUrl = new URL('https://www.example.com/'); + mockWindowLocationFromURL(firstUrl); + mockDocumentTitle('Home - Example'); + + window.history.replaceState(undefined, ''); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const event = await newPlugin.execute?.({ + event_type: 'Page View', + }); + + expect(event?.event_properties).toMatchObject({ + '[Amplitude] Page Domain': 'www.example.com', + '[Amplitude] Page Location': 'https://www.example.com/', + '[Amplitude] Previous Page Location': '', + '[Amplitude] Previous Page Type': 'direct', + }); + + const urlInfoStr = sessionStorage?.getItem(URL_INFO_STORAGE_KEY) || ''; + const urlInfo = JSON.parse(urlInfoStr); + expect(urlInfo[CURRENT_PAGE_STORAGE_KEY]).toBe('https://www.example.com/'); + expect(urlInfo[PREVIOUS_PAGE_STORAGE_KEY]).toBe(''); + + await newPlugin.teardown?.(); + }); + + test('should preserve external referrer on first page load', async () => { + sessionStorage.clear(); + + Object.defineProperty(document, 'referrer', { + value: 'https://google.com/search', + configurable: true, + }); + + const newPlugin = pageUrlEnrichmentPlugin(); + await newPlugin.setup?.(mockConfig, mockAmplitude); + + const firstUrl = new URL('https://www.example.com/'); + mockWindowLocationFromURL(firstUrl); + mockDocumentTitle('Home - Example'); + + window.history.replaceState(undefined, ''); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const event = await newPlugin.execute?.({ + event_type: 'Page View', + }); + + expect(event?.event_properties).toMatchObject({ + '[Amplitude] Page Domain': 'www.example.com', + '[Amplitude] Page Location': 'https://www.example.com/', + '[Amplitude] Previous Page Location': 'https://google.com/search', + '[Amplitude] Previous Page Type': 'external', + }); + + const urlInfoStr = sessionStorage?.getItem(URL_INFO_STORAGE_KEY) || ''; + const urlInfo = JSON.parse(urlInfoStr); + expect(urlInfo[CURRENT_PAGE_STORAGE_KEY]).toBe('https://www.example.com/'); + expect(urlInfo[PREVIOUS_PAGE_STORAGE_KEY]).toBe('https://google.com/search'); + + await newPlugin.teardown?.(); + }); + + test('should handle history events before first event is tracked', async () => { + sessionStorage.clear(); + + Object.defineProperty(document, 'referrer', { + value: '', + configurable: true, + }); + + const newPlugin = pageUrlEnrichmentPlugin(); + await newPlugin.setup?.(mockConfig, mockAmplitude); + + const firstUrl = new URL('https://www.example.com/home'); + mockWindowLocationFromURL(firstUrl); + + window.history.pushState(undefined, ''); + await new Promise((resolve) => setTimeout(resolve, 0)); + + window.history.replaceState(undefined, ''); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const event = await newPlugin.execute?.({ + event_type: 'Page View', + }); + + expect(event?.event_properties).toMatchObject({ + '[Amplitude] Previous Page Location': '', + '[Amplitude] Previous Page Type': 'direct', + }); + + await newPlugin.teardown?.(); + }); + }); + describe('others', () => { test('should handle when globalScope is not defined', async () => { jest.spyOn(Core, 'getGlobalScope').mockReturnValue(undefined); diff --git a/yarn.lock b/yarn.lock index 59ec84d3b..ca14d2bc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,15 +7,7 @@ resolved "https://registry.yarnpkg.com/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz#8a811ff5c8ee46bdfea0e8f61c7578769b5778ed" integrity sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q== -"@amplitude/analytics-core@>=1 <2": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@amplitude/analytics-core/-/analytics-core-1.2.8.tgz#eb454effa04d144458035c0db5290d0e13e49b83" - integrity sha512-Krxpr5uvS3HmmjvpYqPfbMbs2kcZZu09L+6KwQnPiofWRzoXWIM217fRfy6aSD/QrAoPGbZjvtVitw9cB7Cx+A== - dependencies: - "@amplitude/analytics-types" "^1.4.0" - tslib "^2.4.1" - -"@amplitude/analytics-types@>=1 <2", "@amplitude/analytics-types@^1.0.0", "@amplitude/analytics-types@^1.3.4", "@amplitude/analytics-types@^1.4.0": +"@amplitude/analytics-types@^1.0.0", "@amplitude/analytics-types@^1.3.4": version "1.4.0" resolved "https://registry.yarnpkg.com/@amplitude/analytics-types/-/analytics-types-1.4.0.tgz#63f84e5ea5e26beeb06745732063e3787194f0d2" integrity sha512-RiMPHBqdrJ8ktTqG+Wzj2htnN/PCG9jGZG0SXtTFnWwVvcAJYbYm55/nrP1TTyrx1OlLhvF2VG3lVUP/xGAU8w==