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/8] 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/8] 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/8] 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/8] 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 6f0bd00fba2a0027545e4a160f8dde6dea8328b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Thu, 15 Oct 2026 14:52:53 -0300 Subject: [PATCH 5/8] feat: add persistTrackingPreference to let a consent tool own the tracking choice --- README.md | 39 +++++++++++++++------------- src/module.ts | 1 + src/runtime/plugin.ts | 23 ++++++++++++++--- src/runtime/types.ts | 1 + test/package-fixture/consumer.mts | 1 + test/runtime-plugin.test.ts | 43 +++++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e353f1d..70e6255 100644 --- a/README.md +++ b/README.md @@ -108,15 +108,16 @@ The [recipes](docs/RECIPES.md) show how to send the Google Analytics client ID a ## Configuration -| 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. | +| 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. | +| `persistTrackingPreference` | `true` | Save the preference set by `enableTracking()` and `disableTracking()`, which then takes precedence over `trackingEnabled` on later visits. | +| `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. | For example, to retain up to 100 campaign visits for 30 days and capture client-side navigation: @@ -202,8 +203,8 @@ await utm.capture() | `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. | +| `enableTracking()` | Turn tracking on and capture the current route, after a held landing route. | +| `disableTracking()` | Turn tracking off 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. | @@ -211,7 +212,9 @@ Access refs with `.value` in script, for example `utm.lastTouch.value?.utmParams ## Tracking preferences and storage -To start with collection disabled, configure `utm: { trackingEnabled: false }`. Connect `enableTracking()` and `disableTracking()` to your application's preference controls. A previously saved browser preference takes precedence over the configured initial value. Disabling does not delete existing history; also call `clearData()` when deletion is intended. +To start with collection disabled, configure `utm: { trackingEnabled: false }`. Connect `enableTracking()` and `disableTracking()` to your application's preference controls. Both save the preference in localStorage, and on later visits a saved preference takes precedence over the configured initial value. Disabling does not delete existing history; also call `clearData()` when deletion is intended. + +When another tool already remembers the visitor's choice, such as a consent manager, set `persistTrackingPreference: false` so that tool is the only record. The module then starts every page load from `trackingEnabled`, saves no preference, and removes one saved earlier, so your application calls `enableTracking()` on each page load that should be tracked. Disabling tracking or clearing data cancels pending collection, including collection waiting on an asynchronous hook. Already completed application side effects cannot be undone. @@ -225,11 +228,11 @@ If visitors often take longer than a week to convert, send the first touch to yo Nuxt UTM stores data only in the visitor's browser, for your site's origin. It sets no cookies, makes no network requests, and shares nothing across devices or unrelated domains. Data leaves the browser only when your application sends it. -| Storage | Key | Contents | -| -------------- | --------------------------- | -------------------------------------------------------------------------------------- | -| localStorage | `nuxt-utm-data` | The history, one entry per captured visit. | -| sessionStorage | `nuxt-utm-session-id` | A random ID, created when the tab's first visit is captured. | -| localStorage | `nuxt-utm-tracking-enabled` | `true` or `false`, saved only when you call `enableTracking()` or `disableTracking()`. | +| Storage | Key | Contents | +| -------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| localStorage | `nuxt-utm-data` | The history, one entry per captured visit. | +| sessionStorage | `nuxt-utm-session-id` | A random ID, created when the tab's first visit is captured. | +| localStorage | `nuxt-utm-tracking-enabled` | `true` or `false`, saved when you call `enableTracking()` or `disableTracking()` unless `persistTrackingPreference` is `false`. | Each history entry holds: @@ -283,7 +286,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). +A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent tool reports the stored choice on every page load, you can instead set `persistTrackingPreference: false`: tracking then starts disabled on each load until the tool reports consent, so expired consent can't leave it on. 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. diff --git a/src/module.ts b/src/module.ts index 4a7fea2..ca0b40e 100644 --- a/src/module.ts +++ b/src/module.ts @@ -27,6 +27,7 @@ const defaults = { trackOnRouteChange: false, captureWithoutCampaign: true, captureLandingOnEnable: true, + persistTrackingPreference: true, } export default defineNuxtModule({ diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 048d0a6..98a42f7 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -33,7 +33,11 @@ export default defineNuxtPlugin((nuxtApp) => { const options = useRuntimeConfig().public.utm as Required< Pick< ModuleOptions, - 'trackingEnabled' | 'trackOnRouteChange' | 'captureWithoutCampaign' | 'captureLandingOnEnable' + | 'trackingEnabled' + | 'trackOnRouteChange' + | 'captureWithoutCampaign' + | 'captureLandingOnEnable' + | 'persistTrackingPreference' > > & ModuleOptions @@ -48,7 +52,7 @@ export default defineNuxtPlugin((nuxtApp) => { const session = createStorage(() => sessionStorage) // History is always replaced whole, so deep reactivity would only slow down serialization const data = shallowRef([]) - const storedPreference = isClient ? local.getItem(TRACKING_ENABLED_KEY) : null + const storedPreference = isClient ? loadPreference() : null const trackingEnabled = ref( storedPreference === null ? options.trackingEnabled : storedPreference === 'true', ) @@ -61,6 +65,17 @@ export default defineNuxtPlugin((nuxtApp) => { cancelPending = resolve }) + function loadPreference() { + if (options.persistTrackingPreference) return local.getItem(TRACKING_ENABLED_KEY) + // A preference saved under an earlier configuration would otherwise stay on the device unused + local.removeItem(TRACKING_ENABLED_KEY) + return null + } + + function savePreference(enabled: boolean) { + if (options.persistTrackingPreference) local.setItem(TRACKING_ENABLED_KEY, String(enabled)) + } + function saveHistory(entries: DataObject[]) { if (entries.length) local.setItem(LOCAL_STORAGE_KEY, JSON.stringify(entries)) else local.removeItem(LOCAL_STORAGE_KEY) @@ -161,7 +176,7 @@ export default defineNuxtPlugin((nuxtApp) => { const enableTracking = () => { trackingEnabled.value = true if (isClient) { - local.setItem(TRACKING_ENABLED_KEY, 'true') + savePreference(true) const current = visitedRoute() const landing = untrackedLanding untrackedLanding = undefined @@ -173,7 +188,7 @@ export default defineNuxtPlugin((nuxtApp) => { const disableTracking = () => { invalidatePending() trackingEnabled.value = false - if (isClient) local.setItem(TRACKING_ENABLED_KEY, 'false') + if (isClient) savePreference(false) } const clearData = () => { diff --git a/src/runtime/types.ts b/src/runtime/types.ts index c9f59bc..6234d6f 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -54,6 +54,7 @@ export interface ModuleOptions { trackOnRouteChange?: boolean captureWithoutCampaign?: boolean captureLandingOnEnable?: boolean + persistTrackingPreference?: 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 2a692b0..76a30ff 100644 --- a/test/package-fixture/consumer.mts +++ b/test/package-fixture/consumer.mts @@ -16,6 +16,7 @@ export const options: ModuleOptions = { trackOnRouteChange: true, captureWithoutCampaign: false, captureLandingOnEnable: false, + persistTrackingPreference: 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 aa3b57d..7bd856e 100644 --- a/test/runtime-plugin.test.ts +++ b/test/runtime-plugin.test.ts @@ -66,6 +66,7 @@ async function setupTracker() { utm: Ref utmFirstTouch: Ref utmLastTouch: Ref + utmTrackingEnabled: Ref utmCapture: () => Promise utmGetAttribution: () => Promise utmDisableTracking: () => void @@ -80,6 +81,7 @@ beforeEach(() => { trackingEnabled: true, captureWithoutCampaign: true, captureLandingOnEnable: true, + persistTrackingPreference: true, trackOnRouteChange: false, } visit('/?utm_source=google') @@ -287,6 +289,47 @@ describe('tracking enabled after landing', () => { }) }) +describe('tracking preference', () => { + const PREFERENCE_KEY = 'nuxt-utm-tracking-enabled' + + it('saves the preference and prefers it over the configured value on later visits', async () => { + config.public.utm.trackingEnabled = false + const { controls } = await setupTracker() + controls.utmEnableTracking() + + expect(localStorage.getItem(PREFERENCE_KEY)).toBe('true') + expect((await setupTracker()).controls.utmTrackingEnabled.value).toBe(true) + }) + + describe('when persistTrackingPreference is off', () => { + beforeEach(() => { + config.public.utm.persistTrackingPreference = false + }) + + it('starts every visit from the configured value', async () => { + localStorage.setItem(PREFERENCE_KEY, 'false') + const { controls, mount } = await setupTracker() + await mount() + + expect(controls.utmTrackingEnabled.value).toBe(true) + expect(controls.utm.value.map(({ utmParams }) => utmParams.utm_source)).toEqual(['google']) + }) + + it('saves no preference and removes one saved earlier', async () => { + localStorage.setItem(PREFERENCE_KEY, 'true') + config.public.utm.trackingEnabled = false + const { controls } = await setupTracker() + expect(localStorage.getItem(PREFERENCE_KEY)).toBeNull() + + controls.utmEnableTracking() + controls.utmDisableTracking() + expect(localStorage.getItem(PREFERENCE_KEY)).toBeNull() + expect(controls.utmTrackingEnabled.value).toBe(false) + expect((await setupTracker()).controls.utmTrackingEnabled.value).toBe(false) + }) + }) +}) + describe('reactive history', () => { it('updates history and touches after capture while keeping entries read-only', async () => { const { controls, mount } = await setupTracker() From ef34493fdd0d43aa17808b059ee988046ccae921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Thu, 15 Oct 2026 15:38:42 -0300 Subject: [PATCH 6/8] docs: recommend starting disabled when a consent tool owns the tracking preference --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 70e6255..d509dd0 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ The [recipes](docs/RECIPES.md) show how to send the Google Analytics client ID a | Option | Default | Behavior | | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `trackingEnabled` | `true` | Initial tracking preference when no saved preference exists. | +| `trackingEnabled` | `true` | Tracking preference at the start of each page load, unless a saved preference applies. | | `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. | @@ -214,7 +214,7 @@ Access refs with `.value` in script, for example `utm.lastTouch.value?.utmParams To start with collection disabled, configure `utm: { trackingEnabled: false }`. Connect `enableTracking()` and `disableTracking()` to your application's preference controls. Both save the preference in localStorage, and on later visits a saved preference takes precedence over the configured initial value. Disabling does not delete existing history; also call `clearData()` when deletion is intended. -When another tool already remembers the visitor's choice, such as a consent manager, set `persistTrackingPreference: false` so that tool is the only record. The module then starts every page load from `trackingEnabled`, saves no preference, and removes one saved earlier, so your application calls `enableTracking()` on each page load that should be tracked. +When another tool already remembers the visitor's choice, such as a consent manager, set `persistTrackingPreference: false` so that tool is the only record. The module then saves no preference and starts every page load from `trackingEnabled`. Pair this option with `trackingEnabled: false`, and call `enableTracking()` on each page load the tool allows, whether your visitors opt in or opt out; by default the landing page waits in memory until then, as described in [Privacy and consent](#privacy-and-consent). With `trackingEnabled: true`, the module records the landing page as the app mounts, usually before the tool reports the visitor's choice, and `disableTracking()` lasts only until the next page load, so the landing page of every page load is recorded for a visitor who opted out. Turning the option off also removes a preference saved earlier, including an opt-out, so make sure your consent tool already holds those choices. Disabling tracking or clearing data cancels pending collection, including collection waiting on an asynchronous hook. Already completed application side effects cannot be undone. 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 7/8] 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 From 0ec0f7a029aaf46532969ef0cce9ad7674a7934a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sadjow=20Le=C3=A3o?= Date: Thu, 15 Oct 2026 18:01:05 -0300 Subject: [PATCH 8/8] docs: keep the Node.js and TypeScript support notes alongside persistTrackingPreference --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 25fbd2c..0ca25b9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ export default defineNuxtConfig({ The module captures the initial page after the app mounts and saves its UTM parameters, Google click parameters, configured click parameters, and visit context in localStorage. The same behavior works after `nuxt generate` with ordinary static hosting. While the app hydrates, the module records the URL the visitor opened, even when Nuxt still shows the prerendered route without the query or a client middleware has already redirected. +Nuxt UTM supports the Node.js release lines the Node.js project still maintains, currently 22, 24, and 26, and CI tests each of them. The package adds no Node.js version range of its own, so other versions your Nuxt release accepts aren't blocked, but they are untested. For Nuxt, CI tests the latest Nuxt 3 and Nuxt 4 releases and Nuxt 5 nightly builds; earlier minor releases aren't blocked either, but they are untested. + ## Send attribution with a form A static site can send to an independently hosted API. Configure your actual endpoint; Nuxt UTM does not create one: @@ -371,6 +373,8 @@ const attribution: AttributionSnapshot = { Supported UTM fields are the manual campaign parameters Google Analytics 4 documents: `utm_id`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_source_platform`, `utm_term`, `utm_content`, `utm_creative_format`, and `utm_marketing_tactic`. Google click fields are `gclid`, `gad_source`, `gad_campaignid`, `gbraid`, `wbraid`, and `dclid`; Google Ads sends `gbraid` or `wbraid` alongside or instead of `gclid` for some iOS traffic. A nonempty value in any of these fields makes a visit a campaign visit. When a parameter repeats in the URL, each field, including configured click parameters, keeps its first nonempty value: `?utm_source=&utm_source=google&utm_source=newsletter` saves `google`. `clickParams` holds the values of the names listed in the `clickParams` option. `gclidParams`, `clickParams`, and `customParams` are optional. The package also exports `ModuleOptions`, `UTMParams`, `GCLIDParams`, `ClickParams`, `AdditionalInfo`, `BeforeTrackContext`, `NuxtUTMHooks`, and `UseNuxtUTMReturn`. +CI compiles the published declarations with TypeScript 5.9 and with the latest TypeScript 6 and 7 releases. Older TypeScript releases are untested. + ## Development Use the pnpm version pinned in `package.json`. The commands below assume Corepack is installed; see [pnpm installation](https://pnpm.io/installation) if needed. @@ -390,7 +394,9 @@ NUXT_VERSION=3 pnpm test:compatibility pnpm dev:build ``` -`test:package` checks the actual npm tarball from an isolated TypeScript consumer. `test:compatibility` also installs it in an independent Nuxt app, checks types, builds and generates static pages, and exercises campaign navigation, reload persistence, and form submission in Chromium. It tests the latest Nuxt 4 release by default; set `NUXT_VERSION=3` for Nuxt 3 or `NUXT_VERSION=npm:nuxt-nightly@5x` for the Nuxt 5 nightly. CI runs all three separately from the Node 22, 24, and 26 checks, and a nightly failure does not fail the workflow. These are tested configurations, not runtime version restrictions. +`test:package` checks the actual npm tarball from an isolated TypeScript consumer. `test:compatibility` also installs it in an independent Nuxt app, checks types, builds and generates static pages, and exercises campaign navigation, reload persistence, and form submission in Chromium. It tests the latest Nuxt 4 release by default; set `NUXT_VERSION=3` for Nuxt 3 or `NUXT_VERSION=npm:nuxt-nightly@5x` for the Nuxt 5 nightly. CI runs all three separately from the checks on each supported Node.js version, and a nightly failure does not fail the workflow. These are tested configurations, not runtime version restrictions. + +Set `TYPESCRIPT_VERSION` to check the tarball with another TypeScript release, for example `TYPESCRIPT_VERSION=7 pnpm test:package`. Applications compile the published declarations with their own TypeScript, so CI also runs this check with each release listed in [Data and TypeScript](#data-and-typescript). Use `pnpm dev` for the playground. See the [release documentation](docs/RELEASING.md) for preparing and publishing a version.