From 25cd2d60e31ad22f815f372d87227ccf48a24f5e Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Fri, 1 May 2026 16:08:39 -0400 Subject: [PATCH 1/4] feat(google-tag-manager): per-region consent defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow `defaultConsent` to accept an array of GCMv2 states; each entry fires its own `['consent','default', …]` push onto the dataLayer before `gtm.js`, matching Google's documented multi-call pattern for region- specific defaults. - Widen `GoogleTagManagerOptions.defaultConsent` from a loose `record(string, string|number)` to the canonical `union(gcmConsentState, array(gcmConsentState))`. Drive-by tightening: unknown keys now fail dev-mode schema validation. - Augment shared `ConsentState` with the already-runtime-supported `region: string[]` and `wait_for_update: number` fields. - Add three tests: array ordering vs. `gtm.js`, single-entry-array == bare-object equivalence, and empty-array no-op. Backwards compatible: existing single-object callers behave identically. Refs: https://developers.google.com/tag-platform/security/guides/consent --- .../runtime/registry/google-tag-manager.ts | 9 ++- .../script/src/runtime/registry/schemas.ts | 7 ++- packages/script/src/runtime/types.ts | 4 ++ .../nuxt-runtime/consent-default.nuxt.test.ts | 59 +++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/script/src/runtime/registry/google-tag-manager.ts b/packages/script/src/runtime/registry/google-tag-manager.ts index f299269b..7fe13af4 100644 --- a/packages/script/src/runtime/registry/google-tag-manager.ts +++ b/packages/script/src/runtime/registry/google-tag-manager.ts @@ -144,8 +144,13 @@ export function useScriptGoogleTagManager( // Allow custom initialization options?.onBeforeGtmStart?.(gtag) - if (opts.defaultConsent) - gtag('consent', 'default', opts.defaultConsent) + if (opts.defaultConsent) { + const entries = Array.isArray(opts.defaultConsent) + ? opts.defaultConsent + : [opts.defaultConsent] + for (const entry of entries) + gtag('consent', 'default', entry) + } // Push the standard GTM initialization event ;(window as any)[dataLayerName].push({ diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 796c6ef4..1abc0bb8 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -476,10 +476,13 @@ export const GoogleTagManagerOptions = object({ authReferrerPolicy: optional(string()), /** - * Default consent settings for GTM + * Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer + * before the `gtm.js` event. Pass an array to fire multiple defaults — for example, + * different defaults per `region`. See: * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis + * @see https://developers.google.com/tag-platform/security/guides/consent#regional-behavior */ - defaultConsent: optional(record(string(), union([string(), number()]))), + defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])), }) export const HotjarOptions = object({ diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 4d01f6a8..b451b088 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -70,6 +70,10 @@ export interface ConsentState { functionality_storage?: ConsentCategoryValue personalization_storage?: ConsentCategoryValue security_storage?: ConsentCategoryValue + /** Region/subdivision codes (ISO 3166-1 alpha-2 or `XX-YY`) this default applies to. */ + region?: string[] + /** Milliseconds to wait for `consent.update()` before firing queued tags. */ + wait_for_update?: number } export type UseScriptContext, C = unknown> = VueScriptInstance & { diff --git a/test/nuxt-runtime/consent-default.nuxt.test.ts b/test/nuxt-runtime/consent-default.nuxt.test.ts index 4620bb24..29aa01e6 100644 --- a/test/nuxt-runtime/consent-default.nuxt.test.ts +++ b/test/nuxt-runtime/consent-default.nuxt.test.ts @@ -81,6 +81,65 @@ describe('consent defaults — clientInit ordering', () => { expect(dl[consentIdx][2]).toMatchObject({ analytics_storage: 'denied', ad_storage: 'denied' }) }) + it('gtm: array form fires multiple ["consent","default",…] entries in input order, all before gtm.js', async () => { + const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager') + const result: any = useScriptGoogleTagManager({ + id: 'GTM-XXXX', + defaultConsent: [ + { analytics_storage: 'denied', region: ['ES', 'US-AK'], wait_for_update: 500 }, + { ad_storage: 'denied' }, + ], + }) + + result._opts.clientInit() + + const dl = (window as any).dataLayer as any[] + expect(Array.isArray(dl)).toBe(true) + + const consentEntries = dl + .map((e, i) => ({ e, i })) + .filter(({ e }) => Array.isArray(e) && e[0] === 'consent' && e[1] === 'default') + const startIdx = dl.findIndex(e => e && typeof e === 'object' && !Array.isArray(e) && e.event === 'gtm.js') + + expect(consentEntries).toHaveLength(2) + expect(startIdx).toBeGreaterThanOrEqual(0) + for (const { i } of consentEntries) expect(i).toBeLessThan(startIdx) + expect(consentEntries[0].e[2]).toMatchObject({ analytics_storage: 'denied', region: ['ES', 'US-AK'], wait_for_update: 500 }) + expect(consentEntries[1].e[2]).toMatchObject({ ad_storage: 'denied' }) + }) + + it('gtm: single-entry array is observationally equivalent to a bare object', async () => { + const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager') + + const runWith = (defaultConsent: any) => { + delete (window as any).dataLayer + const result: any = useScriptGoogleTagManager({ id: 'GTM-XXXX', defaultConsent }) + result._opts.clientInit() + const dl = (window as any).dataLayer as any[] + const startIdx = dl.findIndex(e => e && typeof e === 'object' && !Array.isArray(e) && e.event === 'gtm.js') + return dl.slice(0, startIdx + 1) + } + + const fromObject = runWith({ ad_storage: 'denied' }) + const fromArray = runWith([{ ad_storage: 'denied' }]) + + expect(fromArray).toEqual(fromObject) + }) + + it('gtm: empty defaultConsent array is a no-op (no consent default entries)', async () => { + const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager') + const result: any = useScriptGoogleTagManager({ id: 'GTM-XXXX', defaultConsent: [] }) + + result._opts.clientInit() + + const dl = (window as any).dataLayer as any[] + const consentEntries = dl.filter(e => Array.isArray(e) && e[0] === 'consent' && e[1] === 'default') + const startIdx = dl.findIndex(e => e && typeof e === 'object' && !Array.isArray(e) && e.event === 'gtm.js') + + expect(consentEntries).toHaveLength(0) + expect(startIdx).toBeGreaterThanOrEqual(0) + }) + it('matomo: "required" pushes requireConsent before setSiteId', async () => { ;(window as any)._paq = [] const { useScriptMatomoAnalytics } = await import('../../packages/script/src/runtime/registry/matomo-analytics') From ef33ab61b2e8b12c8d7a9d40f69da85896aa59f6 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Fri, 1 May 2026 16:33:26 -0400 Subject: [PATCH 2/4] feat(google-analytics): per-region consent defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow `defaultConsent` on `useScriptGoogleAnalytics` to accept an array of GCMv2 states; each entry fires its own `gtag('consent','default', …)` before `gtag('js', …)`, matching Google's documented multi-call pattern for region-specific defaults. - Widen `GoogleAnalyticsOptions.defaultConsent` to `union(gcmConsentState, array(gcmConsentState))`. - Add three tests mirroring the GTM suite: array ordering vs. `gtag('js', …)`, single-entry-array == bare-object equivalence, and empty-array no-op. Also closes a pre-existing gap where GA had no consent-ordering test at all. Backwards compatible: existing single-object callers behave identically. Refs: https://developers.google.com/tag-platform/security/guides/consent --- .../src/runtime/registry/google-analytics.ts | 9 ++- .../script/src/runtime/registry/schemas.ts | 10 +-- .../nuxt-runtime/consent-default.nuxt.test.ts | 62 +++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/script/src/runtime/registry/google-analytics.ts b/packages/script/src/runtime/registry/google-analytics.ts index b510a258..c27c707c 100644 --- a/packages/script/src/runtime/registry/google-analytics.ts +++ b/packages/script/src/runtime/registry/google-analytics.ts @@ -141,8 +141,13 @@ export function useScriptGoogleAnalytics(_options? // eslint-disable-next-line prefer-rest-params w[dataLayerName].push(arguments) } - if (options?.defaultConsent) - w.gtag('consent', 'default', options.defaultConsent) + if (options?.defaultConsent) { + const entries = Array.isArray(options.defaultConsent) + ? options.defaultConsent + : [options.defaultConsent] + for (const entry of entries) + w.gtag('consent', 'default', entry) + } // eslint-disable-next-line ts/ban-ts-comment // @ts-ignore _options?.onBeforeGtagStart?.(w.gtag) diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 1abc0bb8..046f293f 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -306,10 +306,12 @@ export const GoogleAnalyticsOptions = object({ */ l: optional(string()), /** - * Default GCMv2 consent state fired as `gtag('consent', 'default', ...)` before `gtag('js', ...)`. - * @see https://developers.google.com/tag-platform/security/guides/consent + * Default GCMv2 consent state(s) fired as `gtag('consent', 'default', state)` before + * `gtag('js', ...)`. Pass an array to fire multiple defaults — for example, different + * defaults per `region` (more specific regions override broader ones at runtime). + * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior */ - defaultConsent: optional(gcmConsentState), + defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])), }) export const GoogleMapsOptions = object({ @@ -480,7 +482,7 @@ export const GoogleTagManagerOptions = object({ * before the `gtm.js` event. Pass an array to fire multiple defaults — for example, * different defaults per `region`. See: * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis - * @see https://developers.google.com/tag-platform/security/guides/consent#regional-behavior + * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior */ defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])), }) diff --git a/test/nuxt-runtime/consent-default.nuxt.test.ts b/test/nuxt-runtime/consent-default.nuxt.test.ts index 29aa01e6..34804a23 100644 --- a/test/nuxt-runtime/consent-default.nuxt.test.ts +++ b/test/nuxt-runtime/consent-default.nuxt.test.ts @@ -223,6 +223,68 @@ describe('consent defaults — clientInit ordering', () => { // reliably resolve inside happy-dom's module-mocked environment. We verify the // behaviour end-to-end in the playground instead; unit coverage for posthog // stays on the per-script consent object below. + + it('ga: array form fires multiple gtag("consent","default",…) calls before gtag("js",…)', async () => { + const { useScriptGoogleAnalytics } = await import('../../packages/script/src/runtime/registry/google-analytics') + const result: any = useScriptGoogleAnalytics({ + id: 'G-XXXXXXXX', + defaultConsent: [ + { analytics_storage: 'denied', region: ['ES', 'US-AK'], wait_for_update: 500 }, + { ad_storage: 'denied' }, + ], + }) + + result._opts.clientInit() + + // gtag pushes its `arguments` object onto window.dataLayer. + // Convert each pushed `arguments` to a real array for inspection. + const dl = ((window as any).dataLayer as any[]).map(e => Array.from(e)) + + const consentEntries = dl + .map((e, i) => ({ e, i })) + .filter(({ e }) => e[0] === 'consent' && e[1] === 'default') + const jsIdx = dl.findIndex(e => e[0] === 'js') + + expect(consentEntries).toHaveLength(2) + expect(jsIdx).toBeGreaterThanOrEqual(0) + for (const { i } of consentEntries) expect(i).toBeLessThan(jsIdx) + expect(consentEntries[0].e[2]).toMatchObject({ analytics_storage: 'denied', region: ['ES', 'US-AK'], wait_for_update: 500 }) + expect(consentEntries[1].e[2]).toMatchObject({ ad_storage: 'denied' }) + }) + + it('ga: single-entry array is observationally equivalent to a bare object', async () => { + const { useScriptGoogleAnalytics } = await import('../../packages/script/src/runtime/registry/google-analytics') + + const runWith = (defaultConsent: any) => { + delete (window as any).dataLayer + delete (window as any).gtag + const result: any = useScriptGoogleAnalytics({ id: 'G-XXXXXXXX', defaultConsent }) + result._opts.clientInit() + const dl = ((window as any).dataLayer as any[]).map(e => Array.from(e)) + const jsIdx = dl.findIndex(e => e[0] === 'js') + // Slice up to and including the `js` call — anything after is ordering-irrelevant. + return dl.slice(0, jsIdx + 1) + } + + const fromObject = runWith({ ad_storage: 'denied' }) + const fromArray = runWith([{ ad_storage: 'denied' }]) + + expect(fromArray).toEqual(fromObject) + }) + + it('ga: empty defaultConsent array is a no-op (no consent default calls)', async () => { + const { useScriptGoogleAnalytics } = await import('../../packages/script/src/runtime/registry/google-analytics') + const result: any = useScriptGoogleAnalytics({ id: 'G-XXXXXXXX', defaultConsent: [] }) + + result._opts.clientInit() + + const dl = ((window as any).dataLayer as any[]).map(e => Array.from(e)) + const consentEntries = dl.filter(e => e[0] === 'consent' && e[1] === 'default') + const jsIdx = dl.findIndex(e => e[0] === 'js') + + expect(consentEntries).toHaveLength(0) + expect(jsIdx).toBeGreaterThanOrEqual(0) // gtag('js', …) still fires + }) }) describe('per-script consent object', () => { From aa9d27b95a810e7d3e896237c6b50088816e05f3 Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Fri, 1 May 2026 18:15:05 -0400 Subject: [PATCH 3/4] docs(google-analytics, google-tag-manager): document per-region consent defaults - Document the new array form of `defaultConsent` on both `useScriptGoogleAnalytics` and `useScriptGoogleTagManager` pages, including a per-region worked example using `region` and `wait_for_update`. - Add `examples/regional-consent` (mirrors `examples/cookie-consent`) showing an EEA-denied + global-granted setup. - Link the new example from both doc pages alongside the existing StackBlitz callouts. --- docs/content/scripts/google-analytics.md | 42 +++++++++++++-- docs/content/scripts/google-tag-manager.md | 38 +++++++++++-- examples/regional-consent/app.vue | 54 +++++++++++++++++++ examples/regional-consent/assets/css/main.css | 2 + examples/regional-consent/nuxt.config.ts | 7 +++ examples/regional-consent/package.json | 17 ++++++ examples/regional-consent/tsconfig.json | 3 ++ packages/script/src/registry-types.json | 12 ++--- 8 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 examples/regional-consent/app.vue create mode 100644 examples/regional-consent/assets/css/main.css create mode 100644 examples/regional-consent/nuxt.config.ts create mode 100644 examples/regional-consent/package.json create mode 100644 examples/regional-consent/tsconfig.json diff --git a/docs/content/scripts/google-analytics.md b/docs/content/scripts/google-analytics.md index 5dbcc5ee..4568a5d2 100644 --- a/docs/content/scripts/google-analytics.md +++ b/docs/content/scripts/google-analytics.md @@ -30,9 +30,13 @@ proxy.gtag('event', 'page_view') The proxy exposes the `gtag` and `dataLayer` properties, and you should use them following Google Analytics best practices. -### Consent Mode +## Consent Mode -Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', ...)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime: +Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Set the default with `defaultConsent` (fires `gtag('consent', 'default', state)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}) and call `consent.update()`{lang="ts"} at runtime to flip categories. + +::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent" target="_blank"} +Try the live [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com). +:: ```vue +``` + +The module forwards each entry verbatim, in input order. Precedence between region-scoped and unscoped defaults is enforced by gtag at runtime, not by ordering. + +## Customer/Consumer ID Tracking For e-commerce or multi-tenant applications where you need to track customer-specific analytics alongside your main tracking: diff --git a/docs/content/scripts/google-tag-manager.md b/docs/content/scripts/google-tag-manager.md index a227e5fb..c84b483c 100644 --- a/docs/content/scripts/google-tag-manager.md +++ b/docs/content/scripts/google-tag-manager.md @@ -44,10 +44,10 @@ useScriptEventPage(({ title, path }) => { ## Consent Mode -Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime. +Google Tag Manager natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic). Set the default with `defaultConsent` (pushes `['consent','default', state]` onto the dataLayer before the `gtm.js` event) and call `consent.update()`{lang="ts"} at runtime. Pass an **array** to `defaultConsent` to fire multiple defaults, for example [region-specific defaults](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior) where each entry targets different countries via `region`. ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"} -Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent) or [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent) on [StackBlitz](https://stackblitz.com). +Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent), [Granular Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/granular-consent), or [Regional Consent Example](https://stackblitz.com/github/nuxt/scripts/tree/main/examples/regional-consent) on [StackBlitz](https://stackblitz.com). :: ### Consent Mode v2 Signals @@ -97,7 +97,39 @@ useScriptEventPage(({ title, path }) => { ``` -`onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`). +### Per-region defaults + +Pass an array to `defaultConsent` to fire one `['consent','default', state]` push per entry, in order. This matches Google's [region-specific consent pattern](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior): more specific regions (e.g. `US-CA`) override broader ones (`US`); an entry with no `region` is the unscoped global fallback. + +```vue + +``` + +The module forwards each entry verbatim, in input order. Precedence between region-scoped and unscoped defaults is enforced by gtag at runtime, not by ordering. + +`consent.update()`{lang="ts"} accepts any `Partial`{lang="ts"}; missing categories stay at their current value. `onBeforeGtmStart` remains available as a general escape hatch for any other pre-`gtm.start` setup (only when the GTM ID is passed directly to the composable, not via `nuxt.config`). ::script-types :: diff --git a/examples/regional-consent/app.vue b/examples/regional-consent/app.vue new file mode 100644 index 00000000..336198c2 --- /dev/null +++ b/examples/regional-consent/app.vue @@ -0,0 +1,54 @@ + + + diff --git a/examples/regional-consent/assets/css/main.css b/examples/regional-consent/assets/css/main.css new file mode 100644 index 00000000..7c95c6f3 --- /dev/null +++ b/examples/regional-consent/assets/css/main.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "@nuxt/ui"; diff --git a/examples/regional-consent/nuxt.config.ts b/examples/regional-consent/nuxt.config.ts new file mode 100644 index 00000000..cad233ba --- /dev/null +++ b/examples/regional-consent/nuxt.config.ts @@ -0,0 +1,7 @@ +export default defineNuxtConfig({ + modules: ['@nuxt/scripts', '@nuxt/ui'], + + devtools: { enabled: true }, + css: ['~/assets/css/main.css'], + compatibilityDate: '2025-01-01', +}) diff --git a/examples/regional-consent/package.json b/examples/regional-consent/package.json new file mode 100644 index 00000000..f93e8e06 --- /dev/null +++ b/examples/regional-consent/package.json @@ -0,0 +1,17 @@ +{ + "name": "nuxt-scripts-regional-consent-example", + "type": "module", + "private": true, + "scripts": { + "dev": "nuxt dev", + "build": "nuxt build", + "preview": "nuxt preview" + }, + "dependencies": { + "@nuxt/scripts": "latest", + "@nuxt/ui": "^4.7.1", + "nuxt": "^4.4.4", + "tailwindcss": "^4.2.4", + "vue": "^3.5.33" + } +} diff --git a/examples/regional-consent/tsconfig.json b/examples/regional-consent/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/examples/regional-consent/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 81e2e78e..80f6bf38 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -260,7 +260,7 @@ { "name": "GoogleAnalyticsOptions", "kind": "const", - "code": "export const GoogleAnalyticsOptions = object({\n /**\n * The GA4 measurement ID.\n * @example 'G-XXXXXXXX'\n * @see https://developers.google.com/analytics/devguides/collection/gtagjs\n */\n id: optional(string()),\n /**\n * Global name for the dataLayer variable.\n * @default 'dataLayer'\n * @see https://developers.google.com/analytics/devguides/collection/gtagjs/setting-up-gtag#rename_the_data_layer\n */\n l: optional(string()),\n /**\n * Default GCMv2 consent state fired as `gtag('consent', 'default', ...)` before `gtag('js', ...)`.\n * @see https://developers.google.com/tag-platform/security/guides/consent\n */\n defaultConsent: optional(gcmConsentState),\n})" + "code": "export const GoogleAnalyticsOptions = object({\n /**\n * The GA4 measurement ID.\n * @example 'G-XXXXXXXX'\n * @see https://developers.google.com/analytics/devguides/collection/gtagjs\n */\n id: optional(string()),\n /**\n * Global name for the dataLayer variable.\n * @default 'dataLayer'\n * @see https://developers.google.com/analytics/devguides/collection/gtagjs/setting-up-gtag#rename_the_data_layer\n */\n l: optional(string()),\n /**\n * Default GCMv2 consent state(s) fired as `gtag('consent', 'default', state)` before\n * `gtag('js', ...)`. Pass an array to fire multiple defaults — for example, different\n * defaults per `region` (more specific regions override broader ones at runtime).\n * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior\n */\n defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])),\n})" }, { "name": "GoogleAnalyticsConsent", @@ -503,7 +503,7 @@ { "name": "GoogleTagManagerOptions", "kind": "const", - "code": "export const GoogleTagManagerOptions = object({\n /**\n * GTM container ID (format: GTM-XXXXXX)\n * @see https://developers.google.com/tag-platform/tag-manager/web#install-the-container\n */\n id: string(),\n\n /**\n * Optional dataLayer variable name\n * @default 'dataLayer'\n * @see https://developers.google.com/tag-platform/tag-manager/web/datalayer#rename_the_data_layer\n */\n l: optional(string()),\n\n /**\n * Authentication token for environment-specific container versions\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n auth: optional(string()),\n\n /**\n * Preview environment name\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n preview: optional(string()),\n\n /** Forces GTM cookies to take precedence when true */\n cookiesWin: optional(union([boolean(), literal('x')])),\n\n /**\n * Enables debug mode when true\n * @see https://support.google.com/tagmanager/answer/6107056\n */\n debug: optional(union([boolean(), literal('x')])),\n\n /**\n * No Personal Advertising - disables advertising features when true\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n npa: optional(union([boolean(), literal('1')])),\n\n /** Custom dataLayer name (alternative to \"l\" property) */\n dataLayer: optional(string()),\n\n /**\n * Environment name for environment-specific container\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n envName: optional(string()),\n\n /** Referrer policy for analytics requests */\n authReferrerPolicy: optional(string()),\n\n /**\n * Default consent settings for GTM\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n defaultConsent: optional(record(string(), union([string(), number()]))),\n})" + "code": "export const GoogleTagManagerOptions = object({\n /**\n * GTM container ID (format: GTM-XXXXXX)\n * @see https://developers.google.com/tag-platform/tag-manager/web#install-the-container\n */\n id: string(),\n\n /**\n * Optional dataLayer variable name\n * @default 'dataLayer'\n * @see https://developers.google.com/tag-platform/tag-manager/web/datalayer#rename_the_data_layer\n */\n l: optional(string()),\n\n /**\n * Authentication token for environment-specific container versions\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n auth: optional(string()),\n\n /**\n * Preview environment name\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n preview: optional(string()),\n\n /** Forces GTM cookies to take precedence when true */\n cookiesWin: optional(union([boolean(), literal('x')])),\n\n /**\n * Enables debug mode when true\n * @see https://support.google.com/tagmanager/answer/6107056\n */\n debug: optional(union([boolean(), literal('x')])),\n\n /**\n * No Personal Advertising - disables advertising features when true\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n npa: optional(union([boolean(), literal('1')])),\n\n /** Custom dataLayer name (alternative to \"l\" property) */\n dataLayer: optional(string()),\n\n /**\n * Environment name for environment-specific container\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n envName: optional(string()),\n\n /** Referrer policy for analytics requests */\n authReferrerPolicy: optional(string()),\n\n /**\n * Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer\n * before the `gtm.js` event. Pass an array to fire multiple defaults — for example,\n * different defaults per `region`. See:\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior\n */\n defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])),\n})" }, { "name": "GoogleTagManagerConsent", @@ -1443,9 +1443,9 @@ }, { "name": "defaultConsent", - "type": "unknown", + "type": "unknown | unknown[]", "required": false, - "description": "Default GCMv2 consent state fired as `gtag('consent', 'default', ...)` before `gtag('js', ...)`." + "description": "Default GCMv2 consent state(s) fired as `gtag('consent', 'default', state)` before `gtag('js', ...)`. Pass an array to fire multiple defaults — for example, different defaults per `region` (more specific regions override broader ones at runtime)." } ], "GoogleMapsOptions": [ @@ -1632,9 +1632,9 @@ }, { "name": "defaultConsent", - "type": "Record", + "type": "unknown | unknown[]", "required": false, - "description": "Default consent settings for GTM" + "description": "Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer before the `gtm.js` event. Pass an array to fire multiple defaults — for example, different defaults per `region`. See:" } ], "HotjarOptions": [ From a8bef9a18d21200e637ff921a49fb3ea1d5354ab Mon Sep 17 00:00:00 2001 From: Kuba Serafinowski Date: Sat, 2 May 2026 06:59:56 -0400 Subject: [PATCH 4/4] fix(google-analytics, google-tag-manager): address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GTM `clientInit`: apply `defaultConsent` BEFORE `onBeforeGtmStart` so custom events pushed by user callbacks honour the configured consent state. Brings GTM in line with GA's existing ordering. Pre-existing inconsistency surfaced during code review. - Tests: drop the `+ 1` from the equivalence-test slices so the `gtm.js` / `gtag('js', new Date())` timestamps are no longer compared. The Date.now() / new Date() values can diverge across the two back-to-back runWith calls, which would intermittently flake `toEqual`. Ordering relative to those entries is locked by the sibling tests already. - GTM schema JSDoc: remove the trailing "See:" that introduced the `@see` links — JSDoc parsers strip the @see lines from the description and surface the dangling "See:" in `registry-types.json`. - Regenerate `registry-types.json` to pick up the cleaned description. Refs CodeRabbit review on PR #739. --- packages/script/src/registry-types.json | 4 ++-- .../script/src/runtime/registry/google-tag-manager.ts | 8 +++++--- packages/script/src/runtime/registry/schemas.ts | 2 +- test/nuxt-runtime/consent-default.nuxt.test.ts | 9 ++++++--- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 80f6bf38..2d7cd29d 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -503,7 +503,7 @@ { "name": "GoogleTagManagerOptions", "kind": "const", - "code": "export const GoogleTagManagerOptions = object({\n /**\n * GTM container ID (format: GTM-XXXXXX)\n * @see https://developers.google.com/tag-platform/tag-manager/web#install-the-container\n */\n id: string(),\n\n /**\n * Optional dataLayer variable name\n * @default 'dataLayer'\n * @see https://developers.google.com/tag-platform/tag-manager/web/datalayer#rename_the_data_layer\n */\n l: optional(string()),\n\n /**\n * Authentication token for environment-specific container versions\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n auth: optional(string()),\n\n /**\n * Preview environment name\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n preview: optional(string()),\n\n /** Forces GTM cookies to take precedence when true */\n cookiesWin: optional(union([boolean(), literal('x')])),\n\n /**\n * Enables debug mode when true\n * @see https://support.google.com/tagmanager/answer/6107056\n */\n debug: optional(union([boolean(), literal('x')])),\n\n /**\n * No Personal Advertising - disables advertising features when true\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n npa: optional(union([boolean(), literal('1')])),\n\n /** Custom dataLayer name (alternative to \"l\" property) */\n dataLayer: optional(string()),\n\n /**\n * Environment name for environment-specific container\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n envName: optional(string()),\n\n /** Referrer policy for analytics requests */\n authReferrerPolicy: optional(string()),\n\n /**\n * Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer\n * before the `gtm.js` event. Pass an array to fire multiple defaults — for example,\n * different defaults per `region`. See:\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior\n */\n defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])),\n})" + "code": "export const GoogleTagManagerOptions = object({\n /**\n * GTM container ID (format: GTM-XXXXXX)\n * @see https://developers.google.com/tag-platform/tag-manager/web#install-the-container\n */\n id: string(),\n\n /**\n * Optional dataLayer variable name\n * @default 'dataLayer'\n * @see https://developers.google.com/tag-platform/tag-manager/web/datalayer#rename_the_data_layer\n */\n l: optional(string()),\n\n /**\n * Authentication token for environment-specific container versions\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n auth: optional(string()),\n\n /**\n * Preview environment name\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n preview: optional(string()),\n\n /** Forces GTM cookies to take precedence when true */\n cookiesWin: optional(union([boolean(), literal('x')])),\n\n /**\n * Enables debug mode when true\n * @see https://support.google.com/tagmanager/answer/6107056\n */\n debug: optional(union([boolean(), literal('x')])),\n\n /**\n * No Personal Advertising - disables advertising features when true\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n */\n npa: optional(union([boolean(), literal('1')])),\n\n /** Custom dataLayer name (alternative to \"l\" property) */\n dataLayer: optional(string()),\n\n /**\n * Environment name for environment-specific container\n * @see https://support.google.com/tagmanager/answer/6328337\n */\n envName: optional(string()),\n\n /** Referrer policy for analytics requests */\n authReferrerPolicy: optional(string()),\n\n /**\n * Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer\n * before the `gtm.js` event. Pass an array to fire multiple defaults — for example,\n * different defaults per `region` (more specific regions override broader ones at runtime).\n * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis\n * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior\n */\n defaultConsent: optional(union([gcmConsentState, array(gcmConsentState)])),\n})" }, { "name": "GoogleTagManagerConsent", @@ -1634,7 +1634,7 @@ "name": "defaultConsent", "type": "unknown | unknown[]", "required": false, - "description": "Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer before the `gtm.js` event. Pass an array to fire multiple defaults — for example, different defaults per `region`. See:" + "description": "Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer before the `gtm.js` event. Pass an array to fire multiple defaults — for example, different defaults per `region` (more specific regions override broader ones at runtime)." } ], "HotjarOptions": [ diff --git a/packages/script/src/runtime/registry/google-tag-manager.ts b/packages/script/src/runtime/registry/google-tag-manager.ts index 7fe13af4..a799275b 100644 --- a/packages/script/src/runtime/registry/google-tag-manager.ts +++ b/packages/script/src/runtime/registry/google-tag-manager.ts @@ -141,9 +141,8 @@ export function useScriptGoogleTagManager( // Assign gtag to window for global access (window as any).gtag = gtag - // Allow custom initialization - options?.onBeforeGtmStart?.(gtag) - + // Set consent defaults before any user-callback gtag/dataLayer pushes, + // so custom events in onBeforeGtmStart honor the configured consent state. if (opts.defaultConsent) { const entries = Array.isArray(opts.defaultConsent) ? opts.defaultConsent @@ -152,6 +151,9 @@ export function useScriptGoogleTagManager( gtag('consent', 'default', entry) } + // Allow custom initialization + options?.onBeforeGtmStart?.(gtag) + // Push the standard GTM initialization event ;(window as any)[dataLayerName].push({ 'gtm.start': Date.now(), diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 046f293f..cb0306a3 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -480,7 +480,7 @@ export const GoogleTagManagerOptions = object({ /** * Default GCMv2 consent state(s) fired as `['consent','default', state]` onto the dataLayer * before the `gtm.js` event. Pass an array to fire multiple defaults — for example, - * different defaults per `region`. See: + * different defaults per `region` (more specific regions override broader ones at runtime). * @see https://developers.google.com/tag-platform/tag-manager/templates/consent-apis * @see https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced#region-specific-behavior */ diff --git a/test/nuxt-runtime/consent-default.nuxt.test.ts b/test/nuxt-runtime/consent-default.nuxt.test.ts index 34804a23..0cc6374b 100644 --- a/test/nuxt-runtime/consent-default.nuxt.test.ts +++ b/test/nuxt-runtime/consent-default.nuxt.test.ts @@ -117,7 +117,9 @@ describe('consent defaults — clientInit ordering', () => { result._opts.clientInit() const dl = (window as any).dataLayer as any[] const startIdx = dl.findIndex(e => e && typeof e === 'object' && !Array.isArray(e) && e.event === 'gtm.js') - return dl.slice(0, startIdx + 1) + // Slice up to (but not including) gtm.js — the entry carries a Date.now() timestamp + // that differs between calls. Ordering relative to gtm.js is locked by the sibling test. + return dl.slice(0, startIdx) } const fromObject = runWith({ ad_storage: 'denied' }) @@ -262,8 +264,9 @@ describe('consent defaults — clientInit ordering', () => { result._opts.clientInit() const dl = ((window as any).dataLayer as any[]).map(e => Array.from(e)) const jsIdx = dl.findIndex(e => e[0] === 'js') - // Slice up to and including the `js` call — anything after is ordering-irrelevant. - return dl.slice(0, jsIdx + 1) + // Slice up to (but not including) gtag('js', new Date()) — the Date differs between + // calls. Ordering relative to gtag('js', …) is locked by the sibling test. + return dl.slice(0, jsIdx) } const fromObject = runWith({ ad_storage: 'denied' })