From f87cad9383d62c0984f53c62da4a8efea5737895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Wed, 14 Oct 2026 22:23:31 -0300 Subject: [PATCH 1/2] fix: record client navigations in apps without pages --- src/runtime/plugin.ts | 12 +++++-- test/fixtures/route-change-pages/app.vue | 3 ++ .../route-change-pages/nuxt.config.ts | 11 ++++++ test/fixtures/route-change-pages/package.json | 5 +++ .../route-change-pages/pages/next.vue | 7 ++++ .../route-change-pages/pages/static.vue | 14 ++++++++ .../plugins/tracked-routes.client.ts | 9 +++++ test/fixtures/route-change/app.vue | 18 ++++++++++ test/fixtures/route-change/nuxt.config.ts | 8 +++++ test/fixtures/route-change/package.json | 5 +++ test/integration-route-change-pages.test.ts | 27 ++++++++++++++ test/integration-route-change.test.ts | 36 +++++++++++++++++++ 12 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/route-change-pages/app.vue create mode 100644 test/fixtures/route-change-pages/nuxt.config.ts create mode 100644 test/fixtures/route-change-pages/package.json create mode 100644 test/fixtures/route-change-pages/pages/next.vue create mode 100644 test/fixtures/route-change-pages/pages/static.vue create mode 100644 test/fixtures/route-change-pages/plugins/tracked-routes.client.ts create mode 100644 test/fixtures/route-change/app.vue create mode 100644 test/fixtures/route-change/nuxt.config.ts create mode 100644 test/fixtures/route-change/package.json create mode 100644 test/integration-route-change-pages.test.ts create mode 100644 test/integration-route-change.test.ts diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 49c7194..424b3bd 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -190,12 +190,18 @@ export default defineNuxtPlugin((nuxtApp) => { refreshHistory() nuxtApp.hook('app:mounted', async () => { + const landing = visitedRoute() if (options.trackOnRouteChange) { - router.afterEach((to, from, failure) => { - if (!failure && to.fullPath !== from.fullPath) void capture(to) + // Without pages, Nuxt updates the route object `from` refers to before afterEach runs, + // so `from` already matches `to` + let lastFullPath = landing.fullPath + router.afterEach((to, _from, failure) => { + if (failure || to.fullPath === lastFullPath) return + lastFullPath = to.fullPath + void capture(to) }) } - await capture() + await capture(landing) }) return { diff --git a/test/fixtures/route-change-pages/app.vue b/test/fixtures/route-change-pages/app.vue new file mode 100644 index 0000000..8f62b8b --- /dev/null +++ b/test/fixtures/route-change-pages/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/route-change-pages/nuxt.config.ts b/test/fixtures/route-change-pages/nuxt.config.ts new file mode 100644 index 0000000..5e248e8 --- /dev/null +++ b/test/fixtures/route-change-pages/nuxt.config.ts @@ -0,0 +1,11 @@ +import UtmModule from '../../../src/module' + +export default defineNuxtConfig({ + modules: [UtmModule], + routeRules: { + '/static': { prerender: true }, + }, + utm: { + trackOnRouteChange: true, + }, +}) diff --git a/test/fixtures/route-change-pages/package.json b/test/fixtures/route-change-pages/package.json new file mode 100644 index 0000000..8d1fa99 --- /dev/null +++ b/test/fixtures/route-change-pages/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "route-change-pages", + "type": "module" +} diff --git a/test/fixtures/route-change-pages/pages/next.vue b/test/fixtures/route-change-pages/pages/next.vue new file mode 100644 index 0000000..6d1c60e --- /dev/null +++ b/test/fixtures/route-change-pages/pages/next.vue @@ -0,0 +1,7 @@ + + + diff --git a/test/fixtures/route-change-pages/pages/static.vue b/test/fixtures/route-change-pages/pages/static.vue new file mode 100644 index 0000000..4afae61 --- /dev/null +++ b/test/fixtures/route-change-pages/pages/static.vue @@ -0,0 +1,14 @@ + + + diff --git a/test/fixtures/route-change-pages/plugins/tracked-routes.client.ts b/test/fixtures/route-change-pages/plugins/tracked-routes.client.ts new file mode 100644 index 0000000..134a853 --- /dev/null +++ b/test/fixtures/route-change-pages/plugins/tracked-routes.client.ts @@ -0,0 +1,9 @@ +import { defineNuxtPlugin } from '#imports' + +export default defineNuxtPlugin((nuxtApp) => { + const trackedRoutes: string[] = [] + Object.assign(window, { trackedRoutes }) + nuxtApp.hook('utm:before-track', ({ route }) => { + trackedRoutes.push(route.fullPath) + }) +}) diff --git a/test/fixtures/route-change/app.vue b/test/fixtures/route-change/app.vue new file mode 100644 index 0000000..9522935 --- /dev/null +++ b/test/fixtures/route-change/app.vue @@ -0,0 +1,18 @@ + + + diff --git a/test/fixtures/route-change/nuxt.config.ts b/test/fixtures/route-change/nuxt.config.ts new file mode 100644 index 0000000..b4e2a29 --- /dev/null +++ b/test/fixtures/route-change/nuxt.config.ts @@ -0,0 +1,8 @@ +import UtmModule from '../../../src/module' + +export default defineNuxtConfig({ + modules: [UtmModule], + utm: { + trackOnRouteChange: true, + }, +}) diff --git a/test/fixtures/route-change/package.json b/test/fixtures/route-change/package.json new file mode 100644 index 0000000..6895968 --- /dev/null +++ b/test/fixtures/route-change/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "route-change", + "type": "module" +} diff --git a/test/integration-route-change-pages.test.ts b/test/integration-route-change-pages.test.ts new file mode 100644 index 0000000..d4594a2 --- /dev/null +++ b/test/integration-route-change-pages.test.ts @@ -0,0 +1,27 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, createPage } from '@nuxt/test-utils' + +type TrackingWindow = Window & { trackedRoutes: string[] } + +describe('Route change tracking with pages', async () => { + await setup({ + rootDir: fileURLToPath(new URL('./fixtures/route-change-pages', import.meta.url)), + server: true, + browser: true, + }) + + it('tracks a prerendered landing once, then the route navigated to', async () => { + const page = await createPage('/static?utm_source=static') + await page.getByRole('link', { name: 'Next campaign' }).click() + await page.waitForFunction(() => + (window as TrackingWindow).trackedRoutes.includes('/next?utm_source=next'), + ) + + expect(await page.evaluate(() => (window as TrackingWindow).trackedRoutes)).toEqual([ + '/static?utm_source=static', + '/next?utm_source=next', + ]) + await page.close() + }) +}) diff --git a/test/integration-route-change.test.ts b/test/integration-route-change.test.ts new file mode 100644 index 0000000..adeb6d6 --- /dev/null +++ b/test/integration-route-change.test.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, createPage } from '@nuxt/test-utils' + +describe('Route change tracking without pages', async () => { + await setup({ + rootDir: fileURLToPath(new URL('./fixtures/route-change', import.meta.url)), + server: true, + browser: true, + }) + + it('captures a client-side navigation to new campaign parameters', async () => { + const page = await createPage('/?utm_source=landing') + await page.waitForFunction(() => window.localStorage.getItem('nuxt-utm-data')) + await page.getByRole('button', { name: 'Newsletter' }).click() + const lastTouch = page.getByText(/^Last touch:/) + await lastTouch.waitFor() + + expect(new URL(page.url()).search).toBe('?utm_source=newsletter') + expect(await lastTouch.textContent()).toBe('Last touch: newsletter') + const history = await page.evaluate(() => + JSON.parse(window.localStorage.getItem('nuxt-utm-data') ?? '[]').map( + (entry: { + utmParams: { utm_source?: string } + additionalInfo: { landingPageUrl: string } + }) => [entry.utmParams.utm_source, new URL(entry.additionalInfo.landingPageUrl).search], + ), + ) + expect(history).toEqual([ + ['newsletter', '?utm_source=newsletter'], + ['landing', '?utm_source=landing'], + ]) + + await page.close() + }) +}) From 91392dcf8a599f4db8ef123e434f1aacba8fc76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Wed, 14 Oct 2026 22:41:13 -0300 Subject: [PATCH 2/2] test: read tracked routes through an unknown window cast --- test/integration-route-change-pages.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration-route-change-pages.test.ts b/test/integration-route-change-pages.test.ts index d4594a2..55072ae 100644 --- a/test/integration-route-change-pages.test.ts +++ b/test/integration-route-change-pages.test.ts @@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url' import { describe, it, expect } from 'vitest' import { setup, createPage } from '@nuxt/test-utils' -type TrackingWindow = Window & { trackedRoutes: string[] } +type TrackingWindow = { trackedRoutes: string[] } describe('Route change tracking with pages', async () => { await setup({ @@ -15,10 +15,10 @@ describe('Route change tracking with pages', async () => { const page = await createPage('/static?utm_source=static') await page.getByRole('link', { name: 'Next campaign' }).click() await page.waitForFunction(() => - (window as TrackingWindow).trackedRoutes.includes('/next?utm_source=next'), + (window as unknown as TrackingWindow).trackedRoutes.includes('/next?utm_source=next'), ) - expect(await page.evaluate(() => (window as TrackingWindow).trackedRoutes)).toEqual([ + expect(await page.evaluate(() => (window as unknown as TrackingWindow).trackedRoutes)).toEqual([ '/static?utm_source=static', '/next?utm_source=next', ])