From b9c345933b10dcfc6999e8307970a54810373701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Thu, 15 Oct 2026 12:44:01 -0300 Subject: [PATCH 1/5] fix: record the landing campaign when tracking is enabled after navigation --- README.md | 28 ++++--- scripts/test-compatibility.mjs | 49 +++++++++--- src/runtime/plugin.ts | 16 +++- .../components/LandingAttribution.vue | 1 + test/integration-rendering-modes.test.ts | 26 +++++- test/package-fixture/app/app.vue | 7 ++ .../app/pages/landing/[mode].vue | 1 + test/runtime-plugin.test.ts | 79 ++++++++++++++++++- 8 files changed, 174 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 0d22a75..7e07e1f 100644 --- a/README.md +++ b/README.md @@ -195,18 +195,18 @@ const payload = await utm.getAttribution() await utm.capture() ``` -| Member | Purpose | -| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `data` | Read-only reactive history, newest first. | -| `firstTouch`, `lastTouch` | Read-only reactive campaign entries, or `null`. | -| `trackingEnabled` | Read-only reactive tracking preference. | -| `storageAvailable` | Whether browser history can persist in localStorage. | -| `getAttribution()` | Prepare a detached snapshot after pending persistence completes. | -| `capture()` | Capture the current route manually, respecting tracking controls, hooks, and deduplication. | -| `enableTracking()` | Save the enabled preference and start capturing the current route. | -| `disableTracking()` | Save the disabled preference and cancel pending collection. | -| `clearData()` | Clear history and the session ID, and cancel pending collection. Keeps the tracking preference. | -| `onBeforeTrack(cb)`, `onBeforePersist(cb)`, `onTracked(cb)` | Register a hook; return a function that unregisters it. | +| Member | Purpose | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `data` | Read-only reactive history, newest first. | +| `firstTouch`, `lastTouch` | Read-only reactive campaign entries, or `null`. | +| `trackingEnabled` | Read-only reactive tracking preference. | +| `storageAvailable` | Whether browser history can persist in localStorage. | +| `getAttribution()` | Prepare a detached snapshot after pending persistence completes. | +| `capture()` | Capture the current route manually, respecting tracking controls, hooks, and deduplication. | +| `enableTracking()` | Save the enabled preference and capture the current route, after the landing route if the app started with tracking disabled. | +| `disableTracking()` | Save the disabled preference and cancel pending collection. | +| `clearData()` | Clear history, the session ID, and a held landing route, and cancel pending collection. Keeps the tracking preference. | +| `onBeforeTrack(cb)`, `onBeforePersist(cb)`, `onTracked(cb)` | Register a hook; return a function that unregisters it. | Access refs with `.value` in script, for example `utm.lastTouch.value?.utmParams.utm_source`. Data is empty during server rendering; collection occurs in the browser after mounting. The server also renders the configured `trackingEnabled` value, while the browser loads any saved tracking preference. Wrap displays of browser history or the tracking preference in Nuxt’s `` to avoid hydration mismatches for returning visitors. Call `getAttribution()` from a client action after mounting, such as the form handler above. @@ -268,7 +268,9 @@ export default defineNuxtPlugin((nuxtApp) => { }) ``` -Replace `~/utils/consent` with your consent tool's API. A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. `enableTracking()` captures the current page only; campaign parameters from pages visited before consent are not recovered. If your consent UI shows `trackingEnabled`, see the `` note in [Composable](#composable). +Replace `~/utils/consent` with your consent tool's API. A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent UI shows `trackingEnabled`, see the `` note in [Composable](#composable). + +Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. `enableTracking()` then records the landing page with the time of the visit, followed by the current page. The held route is discarded once recorded, by `clearData()`, and when the visitor reloads or leaves the site. Pages visited in between are not recovered, and nothing is held when tracking was already enabled at startup, as with a saved preference that outlived expired consent. To collect less: diff --git a/scripts/test-compatibility.mjs b/scripts/test-compatibility.mjs index 55d07da..b279053 100644 --- a/scripts/test-compatibility.mjs +++ b/scripts/test-compatibility.mjs @@ -143,12 +143,13 @@ export async function testCompatibility({ root, directory, tarball }) { mode, storedLandingPages: [landingPage], landingPagesOnMount: [landingPage], + storedAfterLateConsent: ['/contact?utm_source=newsletter', landingPage], errors: [], } }), ) console.log( - `Nuxt ${installed.version}: package types, build, generate, landing pages, navigation, reload, and form attribution passed`, + `Nuxt ${installed.version}: package types, build, generate, landing pages, late consent, navigation, reload, and form attribution passed`, ) } finally { await browser?.close() @@ -203,28 +204,52 @@ async function findFreePort() { } async function visitLandingPage(browser, origin, mode) { - const context = await browser.newContext() - try { - const page = await context.newPage() - page.setDefaultTimeout(15_000) - const errors = collectPageErrors(page) - await page.goto(`${origin}/landing/${mode}?utm_source=${mode}`) + const landingPage = `${origin}/landing/${mode}?utm_source=${mode}` + const { errors: trackedErrors, ...tracked } = await withNewPage(browser, async (page) => { + await page.goto(landingPage) const readOnMount = await page.getByTestId('on-mount').filter({ hasText: '[' }).textContent() await page.waitForLoadState('networkidle') - const stored = await page.evaluate(() => localStorage.getItem('nuxt-utm-data')) return { - mode, - storedLandingPages: JSON.parse(stored ?? '[]').map(({ additionalInfo }) => - relativeUrl(additionalInfo.landingPageUrl), + storedLandingPages: storedLandingPages( + await page.evaluate(() => localStorage.getItem('nuxt-utm-data')), ), landingPagesOnMount: JSON.parse(readOnMount).map(relativeUrl), - errors, } + }) + const { errors: lateConsentErrors, ...lateConsent } = await withNewPage(browser, async (page) => { + await page.addInitScript(() => localStorage.setItem('nuxt-utm-tracking-enabled', 'false')) + await page.goto(landingPage) + await page.getByTestId('on-mount').filter({ hasText: '[' }).waitFor() + await page.getByRole('link', { name: 'Newsletter contact link' }).click() + await page.waitForURL('**/contact?utm_source=newsletter') + await page.getByRole('button', { name: 'Accept tracking' }).click() + const stored = await page.waitForFunction(() => { + const stored = localStorage.getItem('nuxt-utm-data') + return stored?.includes('"newsletter"') && stored + }) + return { storedAfterLateConsent: storedLandingPages(await stored.jsonValue()) } + }) + return { mode, ...tracked, ...lateConsent, errors: [...trackedErrors, ...lateConsentErrors] } +} + +async function withNewPage(browser, visit) { + const context = await browser.newContext() + try { + const page = await context.newPage() + page.setDefaultTimeout(15_000) + const errors = collectPageErrors(page) + return { ...(await visit(page)), errors } } finally { await context.close() } } +function storedLandingPages(stored) { + return JSON.parse(stored ?? '[]').map(({ additionalInfo }) => + relativeUrl(additionalInfo.landingPageUrl), + ) +} + function relativeUrl(url) { const { pathname, search } = new URL(url) return pathname + search diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 424b3bd..f5cb229 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -50,6 +50,7 @@ export default defineNuxtPlugin((nuxtApp) => { storedPreference === null ? options.trackingEnabled : storedPreference === 'true', ) const touches = computed(() => getCampaignTouches(data.value)) + let untrackedLanding: { route: RouteLocationNormalized; timestamp: string } | undefined let generation = 0 let pending = Promise.resolve() let cancelPending!: () => void @@ -101,7 +102,7 @@ export default defineNuxtPlugin((nuxtApp) => { return nuxtApp.isHydrating && route.fullPath !== landingRoute.fullPath ? landingRoute : route } - function capture(route = visitedRoute()): Promise { + function capture(route = visitedRoute(), timestamp = new Date().toISOString()): Promise { if (!isClient || !trackingEnabled.value) return Promise.resolve() const query = Object.fromEntries( Object.entries(route.query).map(([key, value]) => [ @@ -111,7 +112,6 @@ export default defineNuxtPlugin((nuxtApp) => { ) const context: BeforeTrackContext = { route: { ...route, query }, query, skip: false } const landingPageUrl = new URL(route.fullPath, window.location.href).href - const timestamp = new Date().toISOString() const currentGeneration = generation const isActive = () => trackingEnabled.value && generation === currentGeneration @@ -159,7 +159,11 @@ export default defineNuxtPlugin((nuxtApp) => { trackingEnabled.value = true if (isClient) { local.setItem(TRACKING_ENABLED_KEY, 'true') - void capture() + const current = visitedRoute() + const landing = untrackedLanding + untrackedLanding = undefined + if (landing) void capture(landing.route, landing.timestamp) + if (landing?.route.fullPath !== current.fullPath) void capture(current) } } @@ -171,6 +175,7 @@ export default defineNuxtPlugin((nuxtApp) => { const clearData = () => { invalidatePending() + untrackedLanding = undefined data.value = [] if (isClient) { local.removeItem(LOCAL_STORAGE_KEY) @@ -201,6 +206,11 @@ export default defineNuxtPlugin((nuxtApp) => { void capture(to) }) } + // Consent often arrives after the visitor has left the landing page, so its campaign waits in + // memory until tracking is enabled + if (!trackingEnabled.value) { + untrackedLanding = { route: landing, timestamp: new Date().toISOString() } + } await capture(landing) }) diff --git a/test/fixtures/rendering-modes/components/LandingAttribution.vue b/test/fixtures/rendering-modes/components/LandingAttribution.vue index 601a7c8..b3592df 100644 --- a/test/fixtures/rendering-modes/components/LandingAttribution.vue +++ b/test/fixtures/rendering-modes/components/LandingAttribution.vue @@ -24,6 +24,7 @@ onMounted(async () => { diff --git a/test/runtime-plugin.test.ts b/test/runtime-plugin.test.ts index 4daea8d..c90f1a8 100644 --- a/test/runtime-plugin.test.ts +++ b/test/runtime-plugin.test.ts @@ -1,7 +1,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import { createHooks } from 'hookable' import type { NuxtApp, RuntimeNuxtHooks } from '#app' -import { computed, isReadonly, shallowRef } from 'vue' +import { computed, isReadonly, shallowRef, unref } from 'vue' import type { App, MaybeRef, Ref } from 'vue' import type { RouteLocationNormalized } from 'vue-router' import type { DataObject, AttributionSnapshot, ModuleOptions } from 'nuxt-utm' @@ -196,6 +196,83 @@ describe('tracking lifecycle', () => { }) }) +describe('tracking enabled after landing', () => { + const sources = (history: readonly DataObject[]) => + history.map(({ timestamp, utmParams }) => [utmParams.utm_source, timestamp]) + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + config.public.utm.trackingEnabled = false + }) + + it('records the landing page at its arrival time, then the current page', async () => { + const { controls, mount } = await setupTracker() + await mount() + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')) + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['newsletter', '2026-01-01T00:05:00.000Z'], + ['google', '2026-01-01T00:00:00.000Z'], + ]) + }) + + it('records the landing page once when tracking is enabled on it', async () => { + const { controls, mount } = await setupTracker() + await mount() + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')) + controls.utmEnableTracking() + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['google', '2026-01-01T00:00:00.000Z'], + ]) + }) + + it('records the landing page only the first time tracking is enabled', async () => { + const { controls, mount } = await setupTracker() + await mount() + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + await controls.utmGetAttribution() + controls.utmDisableTracking() + visit('/contact?utm_source=partner') + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['partner', 'newsletter', 'google']) + }) + + it('keeps recording every visit without consent controls when tracking starts enabled', async () => { + config.public.utm.trackingEnabled = true + config.public.utm.trackOnRouteChange = true + const { controls, mount } = await setupTracker() + await mount() + const [afterNavigation] = router.afterEach.mock.lastCall! + visit('/pricing?utm_source=newsletter') + afterNavigation(unref(router.currentRoute), routeTo('/?utm_source=google')) + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['newsletter', '2026-01-01T00:00:00.000Z'], + ['google', '2026-01-01T00:00:00.000Z'], + ]) + }) + + it('forgets the landing page when data is cleared before tracking is enabled', async () => { + const { controls, mount } = await setupTracker() + await mount() + controls.utmClearData() + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['newsletter']) + }) +}) + describe('reactive history', () => { it('updates history and touches after capture while keeping entries read-only', async () => { const { controls, mount } = await setupTracker() From 7c301956cb3e8ecfcedc7d8abc85c7d025960e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Thu, 15 Oct 2026 14:11:45 -0300 Subject: [PATCH 2/5] docs: explain how long the held landing route lasts and when its hooks run --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e07e1f..3ff86c8 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,9 @@ export default defineNuxtPlugin((nuxtApp) => { Replace `~/utils/consent` with your consent tool's API. A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent UI shows `trackingEnabled`, see the `` note in [Composable](#composable). -Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. `enableTracking()` then records the landing page with the time of the visit, followed by the current page. The held route is discarded once recorded, by `clearData()`, and when the visitor reloads or leaves the site. Pages visited in between are not recovered, and nothing is held when tracking was already enabled at startup, as with a saved preference that outlived expired consent. +Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. `enableTracking()` then records the landing page with the time of the visit, followed by the current page. Hooks for the landing page run at that point, so read the page from `context.route`, not `window.location`. Pages visited in between are not recovered, and nothing is held when tracking was already enabled at startup, as with a saved preference that outlived expired consent. + +The held route lives only as long as the page, so a reload or a new visit starts without it, and it is discarded once recorded. `disableTracking()` keeps it, so a visitor who declines and then accepts during the same visit still has the landing page recorded. `clearData()` discards it; if your consent tool reports a refusal before the visitor has chosen, call `clearData()` only once the visitor actually refuses. To collect less: From 1159e2be06622cc01f4e4e9bb3127e8e9802a3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Thu, 15 Oct 2026 14:48:42 -0300 Subject: [PATCH 3/5] feat: add captureLandingOnEnable to turn off landing recovery --- README.md | 29 ++++++++++++++--------------- src/module.ts | 1 + src/runtime/plugin.ts | 7 +++++-- src/runtime/types.ts | 1 + test/package-fixture/consumer.mts | 1 + test/runtime-plugin.test.ts | 14 ++++++++++++++ 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3ff86c8..2a0cf73 100644 --- a/README.md +++ b/README.md @@ -108,13 +108,12 @@ The [recipes](docs/RECIPES.md) show how to send the Google Analytics client ID a ## Configuration -All new collection and retention options are opt-in. Existing behavior is preserved by default. - | Option | Default | Behavior | | ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------- | | `trackingEnabled` | `true` | Initial tracking preference when no saved preference exists. | | `trackOnRouteChange` | `false` | Also capture successful client-side navigations whose full URL path changes. | | `captureWithoutCampaign` | `true` | Include visits without UTM, Google click, or configured click parameters. | +| `captureLandingOnEnable` | `true` | When tracking starts disabled, `enableTracking()` also records the landing page, held in memory until then. | | `maxAge` | Unset | Retain entries younger than this many seconds. Must be positive and finite. | | `maxEntries` | Unset | Retain at most this many entries. Must be a positive integer. | | `clickParams` | Unset | Extra campaign parameters to capture, such as other ad platforms' click IDs. Must not repeat UTM or Google names. | @@ -195,18 +194,18 @@ const payload = await utm.getAttribution() await utm.capture() ``` -| Member | Purpose | -| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `data` | Read-only reactive history, newest first. | -| `firstTouch`, `lastTouch` | Read-only reactive campaign entries, or `null`. | -| `trackingEnabled` | Read-only reactive tracking preference. | -| `storageAvailable` | Whether browser history can persist in localStorage. | -| `getAttribution()` | Prepare a detached snapshot after pending persistence completes. | -| `capture()` | Capture the current route manually, respecting tracking controls, hooks, and deduplication. | -| `enableTracking()` | Save the enabled preference and capture the current route, after the landing route if the app started with tracking disabled. | -| `disableTracking()` | Save the disabled preference and cancel pending collection. | -| `clearData()` | Clear history, the session ID, and a held landing route, and cancel pending collection. Keeps the tracking preference. | -| `onBeforeTrack(cb)`, `onBeforePersist(cb)`, `onTracked(cb)` | Register a hook; return a function that unregisters it. | +| Member | Purpose | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `data` | Read-only reactive history, newest first. | +| `firstTouch`, `lastTouch` | Read-only reactive campaign entries, or `null`. | +| `trackingEnabled` | Read-only reactive tracking preference. | +| `storageAvailable` | Whether browser history can persist in localStorage. | +| `getAttribution()` | Prepare a detached snapshot after pending persistence completes. | +| `capture()` | Capture the current route manually, respecting tracking controls, hooks, and deduplication. | +| `enableTracking()` | Save the enabled preference and capture the current route, after a held landing route. | +| `disableTracking()` | Save the disabled preference and cancel pending collection. | +| `clearData()` | Clear history, the session ID, and a held landing route, and cancel pending collection. Keeps the tracking preference. | +| `onBeforeTrack(cb)`, `onBeforePersist(cb)`, `onTracked(cb)` | Register a hook; return a function that unregisters it. | Access refs with `.value` in script, for example `utm.lastTouch.value?.utmParams.utm_source`. Data is empty during server rendering; collection occurs in the browser after mounting. The server also renders the configured `trackingEnabled` value, while the browser loads any saved tracking preference. Wrap displays of browser history or the tracking preference in Nuxt’s `` to avoid hydration mismatches for returning visitors. Call `getAttribution()` from a client action after mounting, such as the form handler above. @@ -272,7 +271,7 @@ Replace `~/utils/consent` with your consent tool's API. A saved preference outli Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. `enableTracking()` then records the landing page with the time of the visit, followed by the current page. Hooks for the landing page run at that point, so read the page from `context.route`, not `window.location`. Pages visited in between are not recovered, and nothing is held when tracking was already enabled at startup, as with a saved preference that outlived expired consent. -The held route lives only as long as the page, so a reload or a new visit starts without it, and it is discarded once recorded. `disableTracking()` keeps it, so a visitor who declines and then accepts during the same visit still has the landing page recorded. `clearData()` discards it; if your consent tool reports a refusal before the visitor has chosen, call `clearData()` only once the visitor actually refuses. +The held route lives only as long as the page, so a reload or a new visit starts without it, and it is discarded once recorded. `disableTracking()` keeps it, so a visitor who declines and then accepts during the same visit still has the landing page recorded. `clearData()` discards it; if your consent tool reports a refusal before the visitor has chosen, call `clearData()` only once the visitor actually refuses. Set `captureLandingOnEnable: false` to hold nothing, so `enableTracking()` records only the current page. To collect less: diff --git a/src/module.ts b/src/module.ts index c0de81f..4a7fea2 100644 --- a/src/module.ts +++ b/src/module.ts @@ -26,6 +26,7 @@ const defaults = { trackingEnabled: true, trackOnRouteChange: false, captureWithoutCampaign: true, + captureLandingOnEnable: true, } export default defineNuxtModule({ diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index f5cb229..048d0a6 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -31,7 +31,10 @@ const TRACKING_ENABLED_KEY = 'nuxt-utm-tracking-enabled' export default defineNuxtPlugin((nuxtApp) => { const options = useRuntimeConfig().public.utm as Required< - Pick + Pick< + ModuleOptions, + 'trackingEnabled' | 'trackOnRouteChange' | 'captureWithoutCampaign' | 'captureLandingOnEnable' + > > & ModuleOptions // A runtime config override skips the build time checks, so a malformed list must not stop tracking @@ -208,7 +211,7 @@ export default defineNuxtPlugin((nuxtApp) => { } // Consent often arrives after the visitor has left the landing page, so its campaign waits in // memory until tracking is enabled - if (!trackingEnabled.value) { + if (options.captureLandingOnEnable && !trackingEnabled.value) { untrackedLanding = { route: landing, timestamp: new Date().toISOString() } } await capture(landing) diff --git a/src/runtime/types.ts b/src/runtime/types.ts index ab9c52c..c9f59bc 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -53,6 +53,7 @@ export interface ModuleOptions { maxEntries?: number trackOnRouteChange?: boolean captureWithoutCampaign?: boolean + captureLandingOnEnable?: boolean // Intersecting string with {} keeps the known names as editor suggestions while accepting any name clickParams?: (AdClickParameter | (string & {}))[] } diff --git a/test/package-fixture/consumer.mts b/test/package-fixture/consumer.mts index 7c9410b..2a692b0 100644 --- a/test/package-fixture/consumer.mts +++ b/test/package-fixture/consumer.mts @@ -15,6 +15,7 @@ export const options: ModuleOptions = { maxEntries: 100, trackOnRouteChange: true, captureWithoutCampaign: false, + captureLandingOnEnable: false, clickParams: ['fbclid', 'msclkid', 'partner_click_id'], } // Known click IDs must stay literal members of the option type so editors suggest them. diff --git a/test/runtime-plugin.test.ts b/test/runtime-plugin.test.ts index c90f1a8..aa3b57d 100644 --- a/test/runtime-plugin.test.ts +++ b/test/runtime-plugin.test.ts @@ -79,6 +79,7 @@ beforeEach(() => { config.public.utm = { trackingEnabled: true, captureWithoutCampaign: true, + captureLandingOnEnable: true, trackOnRouteChange: false, } visit('/?utm_source=google') @@ -260,6 +261,19 @@ describe('tracking enabled after landing', () => { ]) }) + it('records only the current page when captureLandingOnEnable is off', async () => { + config.public.utm.captureLandingOnEnable = false + const { controls, mount } = await setupTracker() + await mount() + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')) + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['newsletter', '2026-01-01T00:05:00.000Z'], + ]) + }) + it('forgets the landing page when data is cleared before tracking is enabled', async () => { const { controls, mount } = await setupTracker() await mount() From be54b8589ac54ffbcfe95fa4533f260b70702f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Thu, 15 Oct 2026 14:48:42 -0300 Subject: [PATCH 4/5] docs: describe tracking regardless of consent, checking consent before use, and tracking after consent --- README.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a0cf73..e353f1d 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,23 @@ The module reads its localStorage keys and applies retention when the app starts Whether you need consent depends on where your visitors are and what you use the data for. In the EU, [Article 5(3) of the ePrivacy Directive](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:02002L0058-20091219) requires consent to store information on a visitor's device or read it, with narrow exceptions such as storage strictly necessary for a service the visitor explicitly requested. The EDPB's [Guidelines 2/2023](https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-22023-technical-scope-art-53-eprivacy-directive_en) explain that this covers more than cookies, and the UK ICO's [guidance on storage and access technologies](https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/) covers local storage under the UK rules. Data protection law such as the GDPR applies when the data is personal data. The GDPR's definition includes online identifiers, so stored click IDs can qualify before any form is submitted, and history submitted with a lead's contact details qualifies. This README is not legal advice. -To collect only after consent, start with tracking disabled as described in [Tracking preferences and storage](#tracking-preferences-and-storage), then connect the module to your consent tool: +Nuxt UTM never checks consent itself. Your application chooses how consent affects it: + +- **Track regardless of consent.** This is the default: keep `trackingEnabled: true` and don't call `disableTracking()`. +- **Track, and check consent before using the data.** Collection continues, and your application checks consent where the data leaves the browser. The history is stored on the visitor's device either way, and the rules above cover that storage. Call `clearData()` if a refusal should delete it. +- **Track only after consent.** Start with tracking disabled and enable it when the visitor accepts. + +To check consent before using the data, guard the call in the form handler from [Send attribution with a form](#send-attribution-with-a-form): + +```ts +import { hasConsent } from '~/utils/consent' + +const attribution = hasConsent() ? await utm.getAttribution() : null +``` + +Here and below, `~/utils/consent` stands for your consent tool's API. To tell later which entries were collected with consent, record it on each entry in a `utm:before-persist` hook with `entry.customParams = { ...entry.customParams, consent: hasConsent() }`. + +To track only after consent, start with tracking disabled as described in [Tracking preferences and storage](#tracking-preferences-and-storage), then connect the module to your consent tool: ```ts // plugins/utm-consent.client.ts @@ -267,7 +283,7 @@ export default defineNuxtPlugin((nuxtApp) => { }) ``` -Replace `~/utils/consent` with your consent tool's API. A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent UI shows `trackingEnabled`, see the `` note in [Composable](#composable). +A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent UI shows `trackingEnabled`, see the `` note in [Composable](#composable). Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. `enableTracking()` then records the landing page with the time of the visit, followed by the current page. Hooks for the landing page run at that point, so read the page from `context.route`, not `window.location`. Pages visited in between are not recovered, and nothing is held when tracking was already enabled at startup, as with a saved preference that outlived expired consent. From 58b655eb4ff0376ab6c191d03da420a252f7b24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Thu, 15 Oct 2026 17:46:52 -0300 Subject: [PATCH 5/5] fix: keep the held landing page across in-place route updates and cancelled captures --- README.md | 2 +- src/runtime/plugin.ts | 43 +++++++++++---- test/fixtures/route-change/app.vue | 1 + test/integration-route-change.test.ts | 41 +++++++++++---- test/runtime-plugin.test.ts | 75 ++++++++++++++++++++++++++- 5 files changed, 141 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e353f1d..e51e038 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ export default defineNuxtPlugin((nuxtApp) => { A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent UI shows `trackingEnabled`, see the `` note in [Composable](#composable). -Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. `enableTracking()` then records the landing page with the time of the visit, followed by the current page. Hooks for the landing page run at that point, so read the page from `context.route`, not `window.location`. Pages visited in between are not recovered, and nothing is held when tracking was already enabled at startup, as with a saved preference that outlived expired consent. +Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. `enableTracking()` then records the landing page with the time of the visit, followed by the current page. Hooks for the landing page run at that point, so read the page from `context.route`, not `window.location`. Pages visited in between are not recovered. When tracking is already enabled at startup, as with a saved preference that outlived expired consent, the landing page is recorded right away instead, so a `utm:before-track` consent check decides then; if `disableTracking()` cancels that capture, the landing page stays held. The held route lives only as long as the page, so a reload or a new visit starts without it, and it is discarded once recorded. `disableTracking()` keeps it, so a visitor who declines and then accepts during the same visit still has the landing page recorded. `clearData()` discards it; if your consent tool reports a refusal before the visitor has chosen, call `clearData()` only once the visitor actually refuses. Set `captureLandingOnEnable: false` to hold nothing, so `enableTracking()` records only the current page. diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 048d0a6..ccaef3d 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -29,6 +29,8 @@ const LOCAL_STORAGE_KEY = 'nuxt-utm-data' const SESSION_ID_KEY = 'nuxt-utm-session-id' const TRACKING_ENABLED_KEY = 'nuxt-utm-tracking-enabled' +type HeldRoute = { route: RouteLocationNormalized; timestamp: string } + export default defineNuxtPlugin((nuxtApp) => { const options = useRuntimeConfig().public.utm as Required< Pick< @@ -53,7 +55,8 @@ export default defineNuxtPlugin((nuxtApp) => { storedPreference === null ? options.trackingEnabled : storedPreference === 'true', ) const touches = computed(() => getCampaignTouches(data.value)) - let untrackedLanding: { route: RouteLocationNormalized; timestamp: string } | undefined + let untrackedLanding: HeldRoute | undefined + let capturingLanding: HeldRoute | undefined let generation = 0 let pending = Promise.resolve() let cancelPending!: () => void @@ -105,6 +108,29 @@ export default defineNuxtPlugin((nuxtApp) => { return nuxtApp.isHydrating && route.fullPath !== landingRoute.fullPath ? landingRoute : route } + // Consent often arrives after the visitor has left the landing page, so its campaign waits in + // memory until tracking is enabled. Without pages, Nuxt updates the route object in place on + // navigation, so the held route is a copy + function holdLanding(route: RouteLocationNormalized) { + untrackedLanding = { + route: { ...route, query: { ...route.query } }, + timestamp: new Date().toISOString(), + } + } + + // The held route is released once its capture completes; disableTracking() hands a cancelled + // capture back so the next enableTracking() still records it + function captureHeldLanding(): Promise { + const landing = untrackedLanding + if (!landing) return Promise.resolve() + untrackedLanding = undefined + capturingLanding = landing + const startedIn = generation + return capture(landing.route, landing.timestamp).then(() => { + if (generation === startedIn) capturingLanding = undefined + }) + } + function capture(route = visitedRoute(), timestamp = new Date().toISOString()): Promise { if (!isClient || !trackingEnabled.value) return Promise.resolve() const query = Object.fromEntries( @@ -164,8 +190,7 @@ export default defineNuxtPlugin((nuxtApp) => { local.setItem(TRACKING_ENABLED_KEY, 'true') const current = visitedRoute() const landing = untrackedLanding - untrackedLanding = undefined - if (landing) void capture(landing.route, landing.timestamp) + void captureHeldLanding() if (landing?.route.fullPath !== current.fullPath) void capture(current) } } @@ -173,12 +198,15 @@ export default defineNuxtPlugin((nuxtApp) => { const disableTracking = () => { invalidatePending() trackingEnabled.value = false + untrackedLanding ??= capturingLanding + capturingLanding = undefined if (isClient) local.setItem(TRACKING_ENABLED_KEY, 'false') } const clearData = () => { invalidatePending() untrackedLanding = undefined + capturingLanding = undefined data.value = [] if (isClient) { local.removeItem(LOCAL_STORAGE_KEY) @@ -209,12 +237,9 @@ export default defineNuxtPlugin((nuxtApp) => { void capture(to) }) } - // Consent often arrives after the visitor has left the landing page, so its campaign waits in - // memory until tracking is enabled - if (options.captureLandingOnEnable && !trackingEnabled.value) { - untrackedLanding = { route: landing, timestamp: new Date().toISOString() } - } - await capture(landing) + if (!options.captureLandingOnEnable) return capture(landing) + holdLanding(landing) + if (trackingEnabled.value) await captureHeldLanding() }) return { diff --git a/test/fixtures/route-change/app.vue b/test/fixtures/route-change/app.vue index 9522935..0de47fb 100644 --- a/test/fixtures/route-change/app.vue +++ b/test/fixtures/route-change/app.vue @@ -14,5 +14,6 @@ async function openNewsletter() { diff --git a/test/integration-route-change.test.ts b/test/integration-route-change.test.ts index adeb6d6..68d42e2 100644 --- a/test/integration-route-change.test.ts +++ b/test/integration-route-change.test.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { describe, it, expect } from 'vitest' -import { setup, createPage } from '@nuxt/test-utils' +import { setup, createPage, url } from '@nuxt/test-utils' describe('Route change tracking without pages', async () => { await setup({ @@ -9,6 +9,18 @@ describe('Route change tracking without pages', async () => { browser: true, }) + type Page = Awaited> + + const storedHistory = (page: Page) => + 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], + ), + ) + 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')) @@ -18,15 +30,24 @@ describe('Route change tracking without pages', async () => { 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([ + expect(await storedHistory(page)).toEqual([ + ['newsletter', '?utm_source=newsletter'], + ['landing', '?utm_source=landing'], + ]) + + await page.close() + }) + + it('records the landing campaign when tracking is enabled after a client-side navigation', async () => { + const page = await createPage() + await page.addInitScript(() => localStorage.setItem('nuxt-utm-tracking-enabled', 'false')) + await page.goto(url('/?utm_source=landing')) + await page.getByRole('button', { name: 'Newsletter' }).click() + await page.getByText('Last touch: none').waitFor() + await page.getByRole('button', { name: 'Accept tracking' }).click() + await page.waitForFunction(() => localStorage.getItem('nuxt-utm-data')?.includes('"landing"')) + + expect(await storedHistory(page)).toEqual([ ['newsletter', '?utm_source=newsletter'], ['landing', '?utm_source=landing'], ]) diff --git a/test/runtime-plugin.test.ts b/test/runtime-plugin.test.ts index aa3b57d..a8231b6 100644 --- a/test/runtime-plugin.test.ts +++ b/test/runtime-plugin.test.ts @@ -1,7 +1,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import { createHooks } from 'hookable' import type { NuxtApp, RuntimeNuxtHooks } from '#app' -import { computed, isReadonly, shallowRef, unref } from 'vue' +import { computed, isReadonly, reactive, shallowRef, unref } from 'vue' import type { App, MaybeRef, Ref } from 'vue' import type { RouteLocationNormalized } from 'vue-router' import type { DataObject, AttributionSnapshot, ModuleOptions } from 'nuxt-utm' @@ -246,6 +246,79 @@ describe('tracking enabled after landing', () => { ).toEqual(['partner', 'newsletter', 'google']) }) + it('records the landing page in an app without pages, whose router updates the route in place', async () => { + const route = reactive(routeTo('/?utm_source=google')) + router.currentRoute = computed(() => route) + const { controls, mount } = await setupTracker() + await mount() + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')) + Object.assign(route, routeTo('/pricing?utm_source=newsletter')) + controls.utmEnableTracking() + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['newsletter', '2026-01-01T00:05:00.000Z'], + ['google', '2026-01-01T00:00:00.000Z'], + ]) + }) + + it('keeps the landing page when tracking is disabled before it is recorded', async () => { + const { controls, mount } = await setupTracker() + await mount() + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + controls.utmDisableTracking() + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['newsletter', 'google']) + }) + + it('keeps the landing page when tracking is disabled while its hook is pending', async () => { + const { hooks, controls, mount } = await setupTracker() + const entered = deferred() + const resume = deferred() + hooks.hook('utm:before-track', async ({ route }) => { + if (route.path !== '/') return + entered.resolve() + await resume.promise + }) + await mount() + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + await entered.promise + controls.utmDisableTracking() + resume.resolve() + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['newsletter', 'google']) + }) + + it('keeps the landing page when tracking starts enabled and is disabled while recording it', async () => { + config.public.utm.trackingEnabled = true + const { hooks, controls, mount } = await setupTracker() + const entered = deferred() + const resume = deferred() + hooks.hook('utm:before-track', async ({ route }) => { + if (route.path !== '/') return + entered.resolve() + await resume.promise + }) + const mounted = mount() + await entered.promise + controls.utmDisableTracking() + resume.resolve() + await mounted + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['newsletter', 'google']) + }) + it('keeps recording every visit without consent controls when tracking starts enabled', async () => { config.public.utm.trackingEnabled = true config.public.utm.trackOnRouteChange = true