From c60a00d6e8a7ea0f8fe3ed4351d9a75f91e4cc2e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 14 Apr 2026 23:11:15 +1000 Subject: [PATCH 01/13] feat(registry): add defaultConsent + consentAdapter to consent-aware scripts Unify per-script native consent APIs under a single defaultConsent option (fired inside clientInit before init/track) and expose a consentAdapter on each registry entry for consumption by useScriptConsent (Scope B). Scripts covered: tiktokPixel (#711), metaPixel, googleAnalytics, bingUet, clarity. Adds shared ConsentState/ConsentAdapter types. GCMv2 is the canonical schema; non-GCM vendors (Meta, TikTok) project lossy from ad_storage. Clarity projects from analytics_storage. Bing's onBeforeUetStart and GA's onBeforeGtagStart are kept as escape hatches. TikTok now exposes ttq.grantConsent/revokeConsent/holdConsent stubs on the pre-load queue. --- docs/content/scripts/bing-uet.md | 10 +- docs/content/scripts/clarity.md | 34 +++ docs/content/scripts/google-analytics.md | 36 +++ docs/content/scripts/meta-pixel.md | 29 +++ docs/content/scripts/tiktok-pixel.md | 29 +++ packages/script/src/registry.ts | 10 + .../script/src/runtime/registry/bing-uet.ts | 22 +- .../script/src/runtime/registry/clarity.ts | 24 +- .../src/runtime/registry/google-analytics.ts | 17 +- .../script/src/runtime/registry/meta-pixel.ts | 22 +- .../script/src/runtime/registry/schemas.ts | 47 ++++ .../src/runtime/registry/tiktok-pixel.ts | 41 ++- packages/script/src/runtime/types.ts | 38 +++ test/unit/default-consent.test.ts | 237 ++++++++++++++++++ 14 files changed, 585 insertions(+), 11 deletions(-) create mode 100644 test/unit/default-consent.test.ts diff --git a/docs/content/scripts/bing-uet.md b/docs/content/scripts/bing-uet.md index da016ea1..23999d33 100644 --- a/docs/content/scripts/bing-uet.md +++ b/docs/content/scripts/bing-uet.md @@ -57,16 +57,12 @@ function trackSignup() { ### Consent Mode -Bing UET supports [advanced consent mode](https://help.ads.microsoft.com/#apex/ads/en/60119/1-500). Use `onBeforeUetStart` to set the default consent state before the script loads. If consent is denied, UET only sends anonymous data. +Bing UET supports [advanced consent mode](https://help.ads.microsoft.com/#apex/ads/en/60119/1-500). Use `defaultConsent` to set the default state before the script loads. If consent is denied, UET only sends anonymous data. ```vue ``` + +You can still use `onBeforeUetStart` for any other pre-load setup. diff --git a/docs/content/scripts/clarity.md b/docs/content/scripts/clarity.md index bd9f3004..2322f2d1 100644 --- a/docs/content/scripts/clarity.md +++ b/docs/content/scripts/clarity.md @@ -18,3 +18,37 @@ links: ::script-types :: + +## Consent Mode + +Clarity supports a cookie consent toggle (boolean) or an advanced consent vector (record). Use `defaultConsent` to set the state before Clarity starts: + +```ts +useScriptClarity({ + id: 'YOUR_PROJECT_ID', + defaultConsent: false, // disable cookies until user opts in +}) + +// or advanced vector +useScriptClarity({ + id: 'YOUR_PROJECT_ID', + defaultConsent: { + ad_storage: 'denied', + analytics_storage: 'granted', + }, +}) +``` + +To update consent at runtime: + +```vue + +``` + +See [Clarity cookie consent](https://learn.microsoft.com/en-us/clarity/setup-and-installation/cookie-consent) for details. diff --git a/docs/content/scripts/google-analytics.md b/docs/content/scripts/google-analytics.md index 858539c8..46ba0865 100644 --- a/docs/content/scripts/google-analytics.md +++ b/docs/content/scripts/google-analytics.md @@ -30,6 +30,42 @@ 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 + +Google Analytics natively consumes [GCMv2 consent state](https://developers.google.com/tag-platform/security/guides/consent). Use `defaultConsent` to fire `gtag('consent', 'default', ...)`{lang="ts"} before `gtag('js', ...)`{lang="ts"}: + +```ts +useScriptGoogleAnalytics({ + id: 'G-XXXXXXXX', + defaultConsent: { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: 'denied', + wait_for_update: 500, + }, +}) +``` + +To update consent at runtime: + +```vue + +``` + +For anything beyond the consent default, `onBeforeGtagStart` remains available as a general escape hatch for pre-`gtag('js')`{lang="ts"} setup. + ### 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/meta-pixel.md b/docs/content/scripts/meta-pixel.md index 22d42463..8539775a 100644 --- a/docs/content/scripts/meta-pixel.md +++ b/docs/content/scripts/meta-pixel.md @@ -20,3 +20,32 @@ Nuxt Scripts provides a registry script composable [`useScriptMetaPixel()`{lang= ::script-types :: + +## Consent Mode + +Meta Pixel exposes a binary consent toggle. Use `defaultConsent` to set the state before `fbq('init', id)`{lang="ts"}: + +```ts +useScriptMetaPixel({ + id: 'YOUR_PIXEL_ID', + defaultConsent: 'denied', // 'granted' | 'denied' +}) +``` + +To grant or revoke at runtime: + +```vue + +``` + +See [Meta's consent docs](https://www.facebook.com/business/help/1151321516677370) for details. diff --git a/docs/content/scripts/tiktok-pixel.md b/docs/content/scripts/tiktok-pixel.md index f2787e24..d0297f64 100644 --- a/docs/content/scripts/tiktok-pixel.md +++ b/docs/content/scripts/tiktok-pixel.md @@ -48,5 +48,34 @@ export default defineNuxtConfig({ }) ``` +## Consent Mode + +TikTok Pixel exposes a binary consent toggle. Use `defaultConsent` to set the state before init: + +```ts +useScriptTikTokPixel({ + id: 'YOUR_PIXEL_ID', + defaultConsent: 'denied', // 'granted' | 'denied' +}) +``` + +To grant or revoke at runtime: + +```vue + +``` + +See the [TikTok cookie consent docs](https://business-api.tiktok.com/portal/docs?id=1739585600931842) for the full behaviour. + ::script-types :: diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 0f935281..57eef826 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -12,6 +12,10 @@ import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' import type { ProxyAutoInject, ProxyCapability, ProxyConfig, RegistryScript, RegistryScriptKey, RegistryScriptServerHandler, ResolvedProxyAutoInject, ScriptCapabilities } from './runtime/types' import { joinURL, withBase, withQuery } from 'ufo' import { LOGOS } from './registry-logos' +import { bingUetConsentAdapter } from './runtime/registry/bing-uet' +import { clarityConsentAdapter } from './runtime/registry/clarity' +import { googleAnalyticsConsentAdapter } from './runtime/registry/google-analytics' +import { metaPixelConsentAdapter } from './runtime/registry/meta-pixel' import { BingUetOptions, BlueskyEmbedOptions, @@ -46,6 +50,7 @@ import { XEmbedOptions, XPixelOptions, } from './runtime/registry/schemas' +import { tiktokPixelConsentAdapter } from './runtime/registry/tiktok-pixel' export type { ScriptCapabilities } from './runtime/types' @@ -424,6 +429,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro envDefaults: { id: '' }, bundle: true, partytown: { forwards: ['uetq.push'] }, + consentAdapter: bingUetConsentAdapter, }), def('metaPixel', { schema: MetaPixelOptions, @@ -437,6 +443,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro privacy: PRIVACY_FULL, }, partytown: { forwards: ['fbq'] }, + consentAdapter: metaPixelConsentAdapter, }), def('xPixel', { schema: XPixelOptions, @@ -469,6 +476,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro privacy: PRIVACY_FULL, }, partytown: { forwards: ['ttq.track', 'ttq.page', 'ttq.identify'] }, + consentAdapter: tiktokPixelConsentAdapter, }), def('snapchatPixel', { schema: SnapTrPixelOptions, @@ -571,6 +579,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro privacy: PRIVACY_HEATMAP, }, partytown: { forwards: [] }, + consentAdapter: clarityConsentAdapter, }), // payments def('stripe', { @@ -730,6 +739,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro privacy: PRIVACY_HEATMAP, }, partytown: { forwards: ['dataLayer.push', 'gtag'] }, + consentAdapter: googleAnalyticsConsentAdapter, }), def('umamiAnalytics', { schema: UmamiAnalyticsOptions, diff --git a/packages/script/src/runtime/registry/bing-uet.ts b/packages/script/src/runtime/registry/bing-uet.ts index 9c4920eb..fe4e5513 100644 --- a/packages/script/src/runtime/registry/bing-uet.ts +++ b/packages/script/src/runtime/registry/bing-uet.ts @@ -1,4 +1,4 @@ -import type { RegistryScriptInput } from '#nuxt-scripts/types' +import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' import { useRegistryScript } from '../utils' import { BingUetOptions } from './schemas' @@ -271,7 +271,27 @@ export function useScriptBingUet(_options?: BingUetInput & : () => { const uetq = window.uetq || [] window.uetq = uetq + if (options?.defaultConsent) { + (uetq as unknown as BingUetQueue).push('consent', 'default', options.defaultConsent) + } _options?.onBeforeUetStart?.(uetq as unknown as BingUetQueue) }, }), _options) } + +/** + * GCMv2 -> Bing UET consent adapter. + * UET honours only `ad_storage`, so we project lossy from GCM state. + */ +export const bingUetConsentAdapter: ConsentAdapter = { + applyDefault(state, proxy) { + if (!state.ad_storage) + return + proxy.uetq.push('consent', 'default', { ad_storage: state.ad_storage }) + }, + applyUpdate(state, proxy) { + if (!state.ad_storage) + return + proxy.uetq.push('consent', 'update', { ad_storage: state.ad_storage }) + }, +} diff --git a/packages/script/src/runtime/registry/clarity.ts b/packages/script/src/runtime/registry/clarity.ts index 6f6e0e03..4f9497db 100644 --- a/packages/script/src/runtime/registry/clarity.ts +++ b/packages/script/src/runtime/registry/clarity.ts @@ -1,4 +1,4 @@ -import type { RegistryScriptInput } from '#nuxt-scripts/types' +import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' import { useRegistryScript } from '../utils' import { ClarityOptions } from './schemas' @@ -56,6 +56,28 @@ export function useScriptClarity( window.clarity = window.clarity || function (...params: any[]) { (window.clarity.q = window.clarity.q || []).push(params) } + if (options?.defaultConsent !== undefined) + window.clarity('consent', options.defaultConsent) }, }), _options) } + +/** + * GCMv2 -> Clarity consent adapter. + * Clarity accepts a boolean cookie toggle; we project lossy from `analytics_storage`: + * - `analytics_storage === 'granted'` -> `clarity('consent', true)` + * - `analytics_storage === 'denied'` -> `clarity('consent', false)` + * - other GCM categories are ignored. + */ +export const clarityConsentAdapter: ConsentAdapter = { + applyDefault(state, proxy) { + if (!state.analytics_storage) + return + proxy.clarity('consent', state.analytics_storage === 'granted') + }, + applyUpdate(state, proxy) { + if (!state.analytics_storage) + return + proxy.clarity('consent', state.analytics_storage === 'granted') + }, +} diff --git a/packages/script/src/runtime/registry/google-analytics.ts b/packages/script/src/runtime/registry/google-analytics.ts index 5f76c4f7..019e6d92 100644 --- a/packages/script/src/runtime/registry/google-analytics.ts +++ b/packages/script/src/runtime/registry/google-analytics.ts @@ -1,4 +1,4 @@ -import type { RegistryScriptInput } from '#nuxt-scripts/types' +import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' import { useRegistryScript } from '#nuxt-scripts/utils' import { withQuery } from 'ufo' import { GoogleAnalyticsOptions } from './schemas' @@ -111,6 +111,19 @@ export { GoogleAnalyticsOptions } export type GoogleAnalyticsInput = RegistryScriptInput +/** + * GCMv2 -> Google Analytics consent adapter. + * GA consumes GCMv2 natively; this is a pass-through of the full state. + */ +export const googleAnalyticsConsentAdapter: ConsentAdapter = { + applyDefault(state, proxy) { + proxy.gtag('consent', 'default', state as ConsentOptions) + }, + applyUpdate(state, proxy) { + proxy.gtag('consent', 'update', state as ConsentOptions) + }, +} + export function useScriptGoogleAnalytics(_options?: GoogleAnalyticsInput & { onBeforeGtagStart?: (gtag: GTag) => void }) { return useRegistryScript(_options?.key || 'googleAnalytics', (options) => { const dataLayerName = options?.l ?? 'dataLayer' @@ -136,6 +149,8 @@ export function useScriptGoogleAnalytics(_options? // eslint-disable-next-line prefer-rest-params w[dataLayerName].push(arguments) } + if (options?.defaultConsent) + w.gtag('consent', 'default', options.defaultConsent) // eslint-disable-next-line ts/ban-ts-comment // @ts-ignore _options?.onBeforeGtagStart?.(w.gtag) diff --git a/packages/script/src/runtime/registry/meta-pixel.ts b/packages/script/src/runtime/registry/meta-pixel.ts index 96a298d5..d7c5a544 100644 --- a/packages/script/src/runtime/registry/meta-pixel.ts +++ b/packages/script/src/runtime/registry/meta-pixel.ts @@ -1,4 +1,4 @@ -import type { RegistryScriptInput } from '#nuxt-scripts/types' +import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' import { useRegistryScript } from '../utils' import { MetaPixelOptions } from './schemas' @@ -50,6 +50,24 @@ declare global { export { MetaPixelOptions } export type MetaPixelInput = RegistryScriptInput +function applyMetaConsent(state: { ad_storage?: 'granted' | 'denied' }, proxy: MetaPixelApi) { + if (!state.ad_storage) + return + proxy.fbq('consent', state.ad_storage === 'granted' ? 'grant' : 'revoke') +} + +/** + * GCMv2 -> Meta Pixel consent adapter. + * Meta only exposes a binary consent toggle, projected lossy from `ad_storage`: + * - `ad_storage === 'granted'` -> `fbq('consent', 'grant')` + * - `ad_storage === 'denied'` -> `fbq('consent', 'revoke')` + * - other GCM categories are ignored. + */ +export const metaPixelConsentAdapter: ConsentAdapter = { + applyDefault: applyMetaConsent, + applyUpdate: applyMetaConsent, +} + export function useScriptMetaPixel(_options?: MetaPixelInput) { return useRegistryScript('metaPixel', options => ({ scriptInput: { @@ -81,6 +99,8 @@ export function useScriptMetaPixel(_options?: MetaPixelI fbq.loaded = true fbq.version = '2.0' fbq.queue = [] + if (options?.defaultConsent) + fbq('consent', options.defaultConsent === 'granted' ? 'grant' : 'revoke') fbq('init', options?.id) fbq('track', 'PageView') }, diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 48387b25..e1c5c006 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1,5 +1,21 @@ import { any, array, boolean, custom, literal, minLength, number, object, optional, pipe, record, string, union } from 'valibot' +// Shared GCMv2 consent category value. +const consentCategoryValue = union([literal('granted'), literal('denied')]) + +// Shared GCMv2 consent state (+ GA-only control fields). +const gcmConsentState = object({ + ad_storage: optional(consentCategoryValue), + ad_user_data: optional(consentCategoryValue), + ad_personalization: optional(consentCategoryValue), + analytics_storage: optional(consentCategoryValue), + functionality_storage: optional(consentCategoryValue), + personalization_storage: optional(consentCategoryValue), + security_storage: optional(consentCategoryValue), + wait_for_update: optional(number()), + region: optional(array(string())), +}) + export const BlueskyEmbedOptions = object({ /** * The Bluesky post URL to embed. @@ -24,6 +40,13 @@ export const ClarityOptions = object({ * @see https://learn.microsoft.com/en-us/clarity/setup-clarity */ id: pipe(string(), minLength(10)), + /** + * Default consent state applied before Clarity starts. + * - `boolean` - enable / disable cookies. + * - `Record` - advanced consent vector (see Clarity docs). + * @see https://learn.microsoft.com/en-us/clarity/setup-and-installation/cookie-consent + */ + defaultConsent: optional(union([boolean(), record(string(), string())])), }) export const CloudflareWebAnalyticsOptions = object({ @@ -282,6 +305,11 @@ export const GoogleAnalyticsOptions = object({ * @see https://developers.google.com/analytics/devguides/collection/gtagjs/setting-up-gtag#rename_the_data_layer */ 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 + */ + defaultConsent: optional(gcmConsentState), }) export const GoogleMapsOptions = object({ @@ -574,6 +602,12 @@ export const MetaPixelOptions = object({ * @see https://developers.facebook.com/docs/meta-pixel/get-started */ id: union([string(), number()]), + /** + * Default consent state. `'granted'` fires `fbq('consent', 'grant')`, + * `'denied'` fires `fbq('consent', 'revoke')`, both called before `fbq('init', id)`. + * @see https://www.facebook.com/business/help/1151321516677370 + */ + defaultConsent: optional(union([literal('granted'), literal('denied')])), }) export const NpmOptions = object({ @@ -759,6 +793,13 @@ export const BingUetOptions = object({ * @default true */ enableAutoSpaTracking: optional(boolean()), + /** + * Default consent state fired as `uetq.push('consent', 'default', ...)` before UET init. + * @see https://help.ads.microsoft.com/#apex/ads/en/60119/1-500 + */ + defaultConsent: optional(object({ + ad_storage: optional(consentCategoryValue), + })), }) export const SegmentOptions = object({ @@ -859,6 +900,12 @@ export const TikTokPixelOptions = object({ * @default true */ trackPageView: optional(boolean()), + /** + * Default consent state. `'granted'` fires `ttq.consent.grant()`, + * `'denied'` fires `ttq.consent.revoke()`, both called before `ttq('init', id)`. + * @see https://business-api.tiktok.com/portal/docs?id=1739585600931842 + */ + defaultConsent: optional(union([literal('granted'), literal('denied')])), }) export const UmamiAnalyticsOptions = object({ diff --git a/packages/script/src/runtime/registry/tiktok-pixel.ts b/packages/script/src/runtime/registry/tiktok-pixel.ts index 9521e31c..e94a3f28 100644 --- a/packages/script/src/runtime/registry/tiktok-pixel.ts +++ b/packages/script/src/runtime/registry/tiktok-pixel.ts @@ -1,4 +1,4 @@ -import type { RegistryScriptInput } from '#nuxt-scripts/types' +import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' import { withQuery } from 'ufo' import { useRegistryScript } from '../utils' import { TikTokPixelOptions } from './schemas' @@ -48,9 +48,36 @@ export interface TikTokPixelApi { push: TtqFns loaded: boolean queue: any[] + /** Opt user in to tracking. Available after the script loads. */ + grantConsent: () => void + /** Opt user out of tracking. Available after the script loads. */ + revokeConsent: () => void + /** Defer consent until an explicit grant/revoke. Available after the script loads. */ + holdConsent: () => void } } +function applyTikTokConsent(state: { ad_storage?: 'granted' | 'denied' }, proxy: TikTokPixelApi) { + if (!state.ad_storage) + return + if (state.ad_storage === 'granted') + proxy.ttq.grantConsent() + else + proxy.ttq.revokeConsent() +} + +/** + * GCMv2 -> TikTok consent adapter. + * TikTok only exposes a binary ad-storage toggle, so we project lossy: + * - `ad_storage === 'granted'` -> `ttq.grantConsent()` + * - `ad_storage === 'denied'` -> `ttq.revokeConsent()` + * - other GCM categories are ignored. + */ +export const tiktokPixelConsentAdapter: ConsentAdapter = { + applyDefault: applyTikTokConsent, + applyUpdate: applyTikTokConsent, +} + declare global { interface Window extends TikTokPixelApi { TiktokAnalyticsObject: string @@ -93,6 +120,18 @@ export function useScriptTikTokPixel(_options?: TikTok ttq.push = ttq ttq.loaded = true ttq.queue = [] + // Queue consent stubs so pre-load `ttq.grantConsent()` / `ttq.revokeConsent()` work. + // The real bat.js replaces these with live bindings once loaded. + const consentMethods = ['grantConsent', 'revokeConsent', 'holdConsent'] as const + for (const name of consentMethods) { + ;(ttq as any)[name] = function (...params: any[]) { + ttq.queue.push([name, ...params]) + } + } + if (options?.defaultConsent === 'granted') + ttq.grantConsent() + else if (options?.defaultConsent === 'denied') + ttq.revokeConsent() if (options?.id) { ttq('init', options.id) if (options?.trackPageView !== false) { diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 5e755db9..3b0d07da 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -50,6 +50,39 @@ export { MARKER_CLUSTERER_INJECTION_KEY } from './components/GoogleMaps/types' export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' +// -- Consent adapter contract (Scope A / Scope B shared) -- + +/** + * GCMv2 consent category value. + * @see https://developers.google.com/tag-platform/security/guides/consent + */ +export type ConsentCategoryValue = 'granted' | 'denied' + +/** + * Canonical GCMv2 consent state shape shared across all scripts. + * Non-GCM vendors (Meta, TikTok) project a subset of this via their adapters. + */ +export interface ConsentState { + ad_storage?: ConsentCategoryValue + ad_user_data?: ConsentCategoryValue + ad_personalization?: ConsentCategoryValue + analytics_storage?: ConsentCategoryValue + functionality_storage?: ConsentCategoryValue + personalization_storage?: ConsentCategoryValue + security_storage?: ConsentCategoryValue +} + +/** + * Adapter that maps a canonical GCMv2 ConsentState to a vendor's consent API. + * Consumed by the Scope B `useScriptConsent` composable. + */ +export interface ConsentAdapter { + /** Called once to establish the default consent state before the script init. */ + applyDefault: (state: ConsentState, proxy: Proxy) => void + /** Called on every consent update after the script has loaded. */ + applyUpdate: (state: ConsentState, proxy: Proxy) => void +} + export type UseScriptContext> = VueScriptInstance & { /** * Remove and reload the script. Useful for scripts that need to re-execute @@ -439,6 +472,11 @@ export interface RegistryScript { * - absent: not partytown-capable */ partytown?: PartytownCapability + /** + * Consent adapter that maps canonical GCMv2 state to the vendor's native + * consent API. Consumed by the `useScriptConsent` composable. + */ + consentAdapter?: ConsentAdapter } export type ElementScriptTrigger = 'immediate' | 'visible' | string | string[] | false diff --git a/test/unit/default-consent.test.ts b/test/unit/default-consent.test.ts new file mode 100644 index 00000000..9103c303 --- /dev/null +++ b/test/unit/default-consent.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Ensure import.meta.client is truthy in tests so useRegistryScript fires clientInit via beforeInit. +; + +(import.meta as any).client = true + +// Mock runtime config + useScript to capture clientInit and return the options +vi.mock('nuxt/app', () => ({ + useRuntimeConfig: () => ({ public: { scripts: {} } }), +})) + +vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ + useScript: vi.fn((_input: any, options: any) => ({ input: _input, options, proxy: {} })), +})) + +async function invokeClientInit(run: () => any) { + // run the script composable; captured options carry the clientInit via beforeInit wrapper + const result: any = run() + const beforeInit = result?.options?.beforeInit + expect(typeof beforeInit).toBe('function') + await beforeInit() + return result +} + +describe('defaultConsent - TikTok Pixel', () => { + beforeEach(() => { + ;(globalThis as any).window = {} + }) + + it('fires grantConsent before init when defaultConsent="granted"', async () => { + const { useScriptTikTokPixel } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') + await invokeClientInit(() => useScriptTikTokPixel({ id: 'TEST_ID', defaultConsent: 'granted' })) + + const ttq = (globalThis as any).window.ttq + expect(ttq).toBeDefined() + // Queue should contain grantConsent call before 'init' + const queue: any[] = ttq.queue + const grantIdx = queue.findIndex(c => c[0] === 'grantConsent') + const initIdx = queue.findIndex(c => c[0] === 'init') + expect(grantIdx).toBeGreaterThan(-1) + expect(initIdx).toBeGreaterThan(-1) + expect(grantIdx).toBeLessThan(initIdx) + }) + + it('fires revokeConsent when defaultConsent="denied"', async () => { + const { useScriptTikTokPixel } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') + await invokeClientInit(() => useScriptTikTokPixel({ id: 'TEST_ID', defaultConsent: 'denied' })) + + const ttq = (globalThis as any).window.ttq + const queue: any[] = ttq.queue + expect(queue.some(c => c[0] === 'revokeConsent')).toBe(true) + }) + + it('does not fire consent when defaultConsent is unset', async () => { + const { useScriptTikTokPixel } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') + await invokeClientInit(() => useScriptTikTokPixel({ id: 'TEST_ID' })) + const ttq = (globalThis as any).window.ttq + const queue: any[] = ttq.queue + expect(queue.some(c => c[0] === 'grantConsent' || c[0] === 'revokeConsent')).toBe(false) + }) +}) + +describe('defaultConsent - Meta Pixel', () => { + beforeEach(() => { + ;(globalThis as any).window = {} + }) + + it('fires fbq("consent", "grant") before fbq("init", id)', async () => { + const { useScriptMetaPixel } = await import('../../packages/script/src/runtime/registry/meta-pixel') + await invokeClientInit(() => useScriptMetaPixel({ id: '123', defaultConsent: 'granted' })) + + const fbq = (globalThis as any).window.fbq + const queue: any[] = fbq.queue + const consentIdx = queue.findIndex(c => c[0] === 'consent' && c[1] === 'grant') + const initIdx = queue.findIndex(c => c[0] === 'init') + expect(consentIdx).toBeGreaterThan(-1) + expect(initIdx).toBeGreaterThan(-1) + expect(consentIdx).toBeLessThan(initIdx) + }) + + it('fires fbq("consent", "revoke") when denied', async () => { + const { useScriptMetaPixel } = await import('../../packages/script/src/runtime/registry/meta-pixel') + await invokeClientInit(() => useScriptMetaPixel({ id: '123', defaultConsent: 'denied' })) + const fbq = (globalThis as any).window.fbq + const queue: any[] = fbq.queue + expect(queue.some(c => c[0] === 'consent' && c[1] === 'revoke')).toBe(true) + }) +}) + +describe('defaultConsent - Google Analytics', () => { + beforeEach(() => { + ;(globalThis as any).window = {} + }) + + it('fires gtag("consent", "default", state) before gtag("js", ...)', async () => { + const { useScriptGoogleAnalytics } = await import('../../packages/script/src/runtime/registry/google-analytics') + await invokeClientInit(() => + useScriptGoogleAnalytics({ + id: 'G-TEST', + defaultConsent: { ad_storage: 'denied', analytics_storage: 'granted' }, + }), + ) + + const dataLayer: any[] = (globalThis as any).window.dataLayer + // gtag pushes `arguments` objects onto dataLayer + const asArrays = dataLayer.map(a => Array.from(a as ArrayLike)) + const consentIdx = asArrays.findIndex(a => a[0] === 'consent' && a[1] === 'default') + const jsIdx = asArrays.findIndex(a => a[0] === 'js') + expect(consentIdx).toBeGreaterThan(-1) + expect(jsIdx).toBeGreaterThan(-1) + expect(consentIdx).toBeLessThan(jsIdx) + expect(asArrays[consentIdx][2]).toEqual({ ad_storage: 'denied', analytics_storage: 'granted' }) + }) +}) + +describe('defaultConsent - Bing UET', () => { + beforeEach(() => { + ;(globalThis as any).window = {} + }) + + it('pushes ["consent","default", state] before any other push', async () => { + const { useScriptBingUet } = await import('../../packages/script/src/runtime/registry/bing-uet') + await invokeClientInit(() => + useScriptBingUet({ + id: 'UET-TEST', + defaultConsent: { ad_storage: 'denied' }, + onBeforeUetStart: (uetq) => { + // simulate a user-provided push after defaultConsent + ;(uetq as any).push('pageLoad') + }, + }), + ) + + const uetq: any[] = (globalThis as any).window.uetq as any + // variadic push appends each arg separately on a plain array (matches Microsoft's + // snippet; UET's queue processor handles this form on hydration) + expect(uetq[0]).toBe('consent') + expect(uetq[1]).toBe('default') + expect(uetq[2]).toEqual({ ad_storage: 'denied' }) + // followed by the onBeforeUetStart push + expect(uetq[3]).toBe('pageLoad') + }) +}) + +describe('defaultConsent - Clarity', () => { + beforeEach(() => { + ;(globalThis as any).window = {} + }) + + it('calls clarity("consent", value) in clientInit', async () => { + const { useScriptClarity } = await import('../../packages/script/src/runtime/registry/clarity') + await invokeClientInit(() => + useScriptClarity({ id: 'clarity-id-12345', defaultConsent: false }), + ) + + const clarity: any = (globalThis as any).window.clarity + // queue on clarity.q contains [ ["consent", false] ] + expect(clarity.q).toBeDefined() + expect(clarity.q.some((c: any[]) => c[0] === 'consent' && c[1] === false)).toBe(true) + }) + + it('supports advanced consent vector object', async () => { + const { useScriptClarity } = await import('../../packages/script/src/runtime/registry/clarity') + await invokeClientInit(() => + useScriptClarity({ + id: 'clarity-id-12345', + defaultConsent: { ad_storage: 'denied', analytics_storage: 'granted' }, + }), + ) + + const clarity: any = (globalThis as any).window.clarity + const consentCall = clarity.q.find((c: any[]) => c[0] === 'consent') + expect(consentCall?.[1]).toEqual({ ad_storage: 'denied', analytics_storage: 'granted' }) + }) +}) + +describe('consentAdapter - per-script projection', () => { + it('tikTok adapter maps ad_storage to grantConsent/revokeConsent', async () => { + const { tiktokPixelConsentAdapter } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') + const grant = vi.fn() + const revoke = vi.fn() + const proxy: any = { ttq: { grantConsent: grant, revokeConsent: revoke } } + + tiktokPixelConsentAdapter.applyUpdate({ ad_storage: 'granted' }, proxy) + expect(grant).toHaveBeenCalled() + + tiktokPixelConsentAdapter.applyUpdate({ ad_storage: 'denied' }, proxy) + expect(revoke).toHaveBeenCalled() + }) + + it('meta adapter maps ad_storage to fbq("consent", grant|revoke)', async () => { + const { metaPixelConsentAdapter } = await import('../../packages/script/src/runtime/registry/meta-pixel') + const fbq = vi.fn() + const proxy: any = { fbq } + + metaPixelConsentAdapter.applyDefault({ ad_storage: 'granted' }, proxy) + expect(fbq).toHaveBeenCalledWith('consent', 'grant') + + metaPixelConsentAdapter.applyUpdate({ ad_storage: 'denied' }, proxy) + expect(fbq).toHaveBeenCalledWith('consent', 'revoke') + }) + + it('gA adapter passes through full GCM state', async () => { + const { googleAnalyticsConsentAdapter } = await import('../../packages/script/src/runtime/registry/google-analytics') + const gtag = vi.fn() + const proxy: any = { gtag } + const state = { ad_storage: 'granted' as const, analytics_storage: 'granted' as const } + + googleAnalyticsConsentAdapter.applyDefault(state, proxy) + expect(gtag).toHaveBeenCalledWith('consent', 'default', state) + + googleAnalyticsConsentAdapter.applyUpdate(state, proxy) + expect(gtag).toHaveBeenCalledWith('consent', 'update', state) + }) + + it('bing UET adapter maps ad_storage only', async () => { + const { bingUetConsentAdapter } = await import('../../packages/script/src/runtime/registry/bing-uet') + const push = vi.fn() + const proxy: any = { uetq: { push } } + + bingUetConsentAdapter.applyDefault({ ad_storage: 'granted', analytics_storage: 'denied' }, proxy) + expect(push).toHaveBeenCalledWith('consent', 'default', { ad_storage: 'granted' }) + }) + + it('clarity adapter maps analytics_storage to boolean', async () => { + const { clarityConsentAdapter } = await import('../../packages/script/src/runtime/registry/clarity') + const clarity = vi.fn() + const proxy: any = { clarity } + + clarityConsentAdapter.applyDefault({ analytics_storage: 'granted' }, proxy) + expect(clarity).toHaveBeenCalledWith('consent', true) + + clarityConsentAdapter.applyUpdate({ analytics_storage: 'denied' }, proxy) + expect(clarity).toHaveBeenCalledWith('consent', false) + }) +}) From 5000b9862e6edf9a787ccc0b30f86486eb35a503 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 14 Apr 2026 23:27:13 +1000 Subject: [PATCH 02/13] feat(consent): unified consentAdapter + defaultConsent for GTM / Matomo / Mixpanel / PostHog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shared `ConsentAdapter` contract (`applyDefault`, `applyUpdate`) and a GCM-style `ConsentState` so `useScriptTriggerConsent` and similar tooling can drive consent without knowing vendor specifics. Each script now accepts a typed `defaultConsent` option that resolves BEFORE the vendor init / first tracking call: - Google Tag Manager: Partial (GCMv2) — pushes `['consent','default',state]` before the `gtm.js` start event; adapter pushes `['consent','update',state]` for runtime changes. - Matomo: `'required' | 'given' | 'not-required'` — queues `requireConsent` (+ `setConsentGiven`) ahead of `setSiteId` / trackPageView; adapter maps `analytics_storage` to `setConsentGiven` / `forgetConsentGiven`. - Mixpanel: `'opt-in' | 'opt-out'` — opt-out passes `opt_out_tracking_by_default: true` to `mixpanel.init`; opt-in queues `opt_in_tracking`; adapter maps `analytics_storage` to `opt_in_tracking` / `opt_out_tracking`. - PostHog: `'opt-in' | 'opt-out'` — opt-out passes `opt_out_capturing_by_default: true` to `posthog.init`; opt-in calls `opt_in_capturing()` after init; adapter maps `analytics_storage` to `opt_in_capturing` / `opt_out_capturing`. Tests cover clientInit ordering and adapter behaviour for all four scripts. Docs updated for Matomo / Mixpanel / PostHog (GTM already documented). --- docs/content/scripts/matomo-analytics.md | 35 +++ docs/content/scripts/mixpanel-analytics.md | 32 +++ docs/content/scripts/posthog.md | 35 +++ packages/script/src/registry-types.json | 24 +- packages/script/src/registry.ts | 52 ++++ .../src/runtime/registry/matomo-analytics.ts | 8 + .../runtime/registry/mixpanel-analytics.ts | 14 +- .../script/src/runtime/registry/posthog.ts | 7 + .../script/src/runtime/registry/schemas.ts | 22 ++ packages/script/src/runtime/types.ts | 33 +++ .../nuxt-runtime/consent-default.nuxt.test.ts | 255 ++++++++++++++++++ 11 files changed, 512 insertions(+), 5 deletions(-) create mode 100644 test/nuxt-runtime/consent-default.nuxt.test.ts diff --git a/docs/content/scripts/matomo-analytics.md b/docs/content/scripts/matomo-analytics.md index a9ed6591..4f499e09 100644 --- a/docs/content/scripts/matomo-analytics.md +++ b/docs/content/scripts/matomo-analytics.md @@ -83,6 +83,41 @@ useScriptMatomoAnalytics({ }) ``` +## Consent Mode + +Matomo has a built-in [tracking-consent API](https://developer.matomo.org/guides/tracking-consent). Nuxt Scripts exposes it via the `defaultConsent` option, which is applied BEFORE the first tracker call. + +| Value | Behaviour | +|-------|-----------| +| `'required'` | Pushes `['requireConsent']`. Nothing is tracked until the user opts in. | +| `'given'` | Pushes `['requireConsent']` then `['setConsentGiven']`. Tracking starts immediately. | +| `'not-required'` | Default Matomo behaviour (no consent gating). | + +```ts +useScriptMatomoAnalytics({ + cloudId: 'YOUR_CLOUD_ID', + defaultConsent: 'required', +}) +``` + +### Granting or revoking consent at runtime + +Use the `proxy` to call Matomo's consent commands directly when the user updates their choice: + +```ts +const { proxy } = useScriptMatomoAnalytics({ + cloudId: 'YOUR_CLOUD_ID', + defaultConsent: 'required', +}) + +function onAccept() { + proxy._paq.push(['setConsentGiven']) +} +function onRevoke() { + proxy._paq.push(['forgetConsentGiven']) +} +``` + ### Using Matomo Whitelabel For Matomo Whitelabel, set `trackerUrl` and `scriptInput.src` to customize tracking. diff --git a/docs/content/scripts/mixpanel-analytics.md b/docs/content/scripts/mixpanel-analytics.md index 7735708d..700198c5 100644 --- a/docs/content/scripts/mixpanel-analytics.md +++ b/docs/content/scripts/mixpanel-analytics.md @@ -69,3 +69,35 @@ proxy.mixpanel.register({ }) ``` + +## Consent Mode + +Mixpanel exposes [`opt_in_tracking` / `opt_out_tracking`](https://docs.mixpanel.com/docs/privacy/opt-out-of-tracking). Nuxt Scripts wires these to the `defaultConsent` option, which is resolved BEFORE the first event is tracked. + +| Value | Behaviour | +|-------|-----------| +| `'opt-in'` | Starts opted in. | +| `'opt-out'` | Calls `mixpanel.init(..., { opt_out_tracking_by_default: true })`{lang="ts"} so the SDK boots opted out. | + +```ts +useScriptMixpanelAnalytics({ + token: 'YOUR_TOKEN', + defaultConsent: 'opt-out', +}) +``` + +### Granting or revoking consent at runtime + +```ts +const { proxy } = useScriptMixpanelAnalytics({ + token: 'YOUR_TOKEN', + defaultConsent: 'opt-out', +}) + +function onAccept() { + proxy.mixpanel.opt_in_tracking() +} +function onRevoke() { + proxy.mixpanel.opt_out_tracking() +} +``` diff --git a/docs/content/scripts/posthog.md b/docs/content/scripts/posthog.md index 3334e83b..01412bea 100644 --- a/docs/content/scripts/posthog.md +++ b/docs/content/scripts/posthog.md @@ -101,6 +101,41 @@ onLoaded(({ posthog }) => { }) ``` +## Consent Mode + +PostHog exposes [`opt_in_capturing` / `opt_out_capturing`](https://posthog.com/docs/privacy/opting-out). Nuxt Scripts wires these to the `defaultConsent` option, applied BEFORE the first `capture()`{lang="ts"} call. + +| Value | Behaviour | +|-------|-----------| +| `'opt-in'` | Calls `posthog.opt_in_capturing()`{lang="ts"} immediately after init. | +| `'opt-out'` | Calls `posthog.init(..., { opt_out_capturing_by_default: true })`{lang="ts"} so the SDK boots opted out. | + +```ts +export default defineNuxtConfig({ + scripts: { + registry: { + posthog: { + apiKey: 'YOUR_API_KEY', + defaultConsent: 'opt-out', + } + } + } +}) +``` + +### Granting or revoking consent at runtime + +```ts +const { proxy } = useScriptPostHog() + +function onAccept() { + proxy.posthog.opt_in_capturing() +} +function onRevoke() { + proxy.posthog.opt_out_capturing() +} +``` + ## Disabling Session Recording ```ts diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index e1692b2f..80798430 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -585,7 +585,7 @@ { "name": "MatomoAnalyticsOptions", "kind": "const", - "code": "export const MatomoAnalyticsOptions = object({\n /**\n * The URL of your self-hosted Matomo instance.\n * Either `matomoUrl` or `cloudId` is required.\n * @example 'https://matomo.example.com'\n * @see https://developer.matomo.org/guides/tracking-javascript-guide\n */\n matomoUrl: optional(string()),\n /**\n * Your Matomo site ID.\n * @default '1'\n */\n siteId: optional(union([string(), number()])),\n /**\n * Your Matomo Cloud ID (the subdomain portion of your `*.matomo.cloud` URL).\n * Either `matomoUrl` or `cloudId` is required.\n * @example 'mysite.matomo.cloud'\n */\n cloudId: optional(string()),\n /**\n * A custom tracker URL. Overrides the default tracker endpoint derived from `matomoUrl` or `cloudId`.\n */\n trackerUrl: optional(string()),\n /**\n * Whether to track the initial page view on load.\n * @deprecated Use `watch: true` (default) for automatic page view tracking.\n */\n trackPageView: optional(boolean()),\n /**\n * Enable download and outlink tracking.\n */\n enableLinkTracking: optional(boolean()),\n /**\n * Disable all tracking cookies for cookieless analytics.\n */\n disableCookies: optional(boolean()),\n /**\n * Automatically track page views on route change.\n * @default true\n */\n watch: optional(boolean()),\n})" + "code": "export const MatomoAnalyticsOptions = object({\n /**\n * The URL of your self-hosted Matomo instance.\n * Either `matomoUrl` or `cloudId` is required.\n * @example 'https://matomo.example.com'\n * @see https://developer.matomo.org/guides/tracking-javascript-guide\n */\n matomoUrl: optional(string()),\n /**\n * Your Matomo site ID.\n * @default '1'\n */\n siteId: optional(union([string(), number()])),\n /**\n * Your Matomo Cloud ID (the subdomain portion of your `*.matomo.cloud` URL).\n * Either `matomoUrl` or `cloudId` is required.\n * @example 'mysite.matomo.cloud'\n */\n cloudId: optional(string()),\n /**\n * A custom tracker URL. Overrides the default tracker endpoint derived from `matomoUrl` or `cloudId`.\n */\n trackerUrl: optional(string()),\n /**\n * Whether to track the initial page view on load.\n * @deprecated Use `watch: true` (default) for automatic page view tracking.\n */\n trackPageView: optional(boolean()),\n /**\n * Enable download and outlink tracking.\n */\n enableLinkTracking: optional(boolean()),\n /**\n * Disable all tracking cookies for cookieless analytics.\n */\n disableCookies: optional(boolean()),\n /**\n * Automatically track page views on route change.\n * @default true\n */\n watch: optional(boolean()),\n /**\n * Default tracking-consent state applied BEFORE the tracker is initialised.\n * - `'required'` — call `requireConsent` without granting (user must opt in later).\n * - `'given'` — call `requireConsent` then `setConsentGiven`.\n * - `'not-required'` — no consent gating (default Matomo behaviour).\n * @see https://developer.matomo.org/guides/tracking-consent\n */\n defaultConsent: optional(union([literal('required'), literal('given'), literal('not-required')])),\n})" }, { "name": "MatomoAnalyticsApi", @@ -634,7 +634,7 @@ { "name": "MixpanelAnalyticsOptions", "kind": "const", - "code": "export const MixpanelAnalyticsOptions = object({\n /**\n * Your Mixpanel project token.\n * @see https://docs.mixpanel.com/docs/tracking-methods/sdks/javascript#1-initialize-the-library\n */\n token: string(),\n})" + "code": "export const MixpanelAnalyticsOptions = object({\n /**\n * Your Mixpanel project token.\n * @see https://docs.mixpanel.com/docs/tracking-methods/sdks/javascript#1-initialize-the-library\n */\n token: string(),\n /**\n * Default tracking-consent state applied BEFORE `mixpanel.init`.\n * - `'opt-in'` — calls `mixpanel.opt_in_tracking()`.\n * - `'opt-out'` — calls `mixpanel.opt_out_tracking()`.\n * @see https://docs.mixpanel.com/docs/privacy/opt-out-of-tracking\n */\n defaultConsent: optional(union([literal('opt-in'), literal('opt-out')])),\n})" }, { "name": "MixpanelAnalyticsApi", @@ -722,7 +722,7 @@ { "name": "PostHogOptions", "kind": "const", - "code": "export const PostHogOptions = object({\n /**\n * Your PostHog project API key.\n * @see https://posthog.com/docs/libraries/js#usage\n */\n apiKey: string(),\n /**\n * Your PostHog data region.\n * @default 'us'\n * @see https://posthog.com/docs/libraries/js#config\n */\n region: optional(union([literal('us'), literal('eu')])),\n /**\n * Custom API host URL. Overrides the default derived from `region`.\n * Useful for self-hosted instances or reverse proxies.\n */\n apiHost: optional(string()),\n /**\n * Enable autocapture of clicks, form submissions, and page views.\n * @default true\n */\n autocapture: optional(boolean()),\n /**\n * Capture page views automatically. Set to `'history_change'` to only capture on history changes.\n * @default true\n */\n capturePageview: optional(union([boolean(), literal('history_change')])),\n /**\n * Capture page leave events automatically.\n * @default true\n */\n capturePageleave: optional(boolean()),\n /**\n * Disable session recording.\n */\n disableSessionRecording: optional(boolean()),\n /**\n * Additional PostHog configuration options passed directly to `posthog.init()`.\n * @see https://posthog.com/docs/libraries/js#config\n */\n config: optional(record(string(), any())),\n})" + "code": "export const PostHogOptions = object({\n /**\n * Your PostHog project API key.\n * @see https://posthog.com/docs/libraries/js#usage\n */\n apiKey: string(),\n /**\n * Your PostHog data region.\n * @default 'us'\n * @see https://posthog.com/docs/libraries/js#config\n */\n region: optional(union([literal('us'), literal('eu')])),\n /**\n * Custom API host URL. Overrides the default derived from `region`.\n * Useful for self-hosted instances or reverse proxies.\n */\n apiHost: optional(string()),\n /**\n * Enable autocapture of clicks, form submissions, and page views.\n * @default true\n */\n autocapture: optional(boolean()),\n /**\n * Capture page views automatically. Set to `'history_change'` to only capture on history changes.\n * @default true\n */\n capturePageview: optional(union([boolean(), literal('history_change')])),\n /**\n * Capture page leave events automatically.\n * @default true\n */\n capturePageleave: optional(boolean()),\n /**\n * Disable session recording.\n */\n disableSessionRecording: optional(boolean()),\n /**\n * Additional PostHog configuration options passed directly to `posthog.init()`.\n * @see https://posthog.com/docs/libraries/js#config\n */\n config: optional(record(string(), any())),\n /**\n * Default capture-consent state applied BEFORE `posthog.init`.\n * - `'opt-in'` — calls `posthog.opt_in_capturing()`.\n * - `'opt-out'` — calls `posthog.opt_out_capturing()`.\n * @see https://posthog.com/docs/privacy/opting-out\n */\n defaultConsent: optional(union([literal('opt-in'), literal('opt-out')])),\n})" }, { "name": "PostHogApi", @@ -1715,6 +1715,12 @@ "required": false, "description": "Automatically track page views on route change.", "defaultValue": "true" + }, + { + "name": "defaultConsent", + "type": "'required' | 'given' | 'not-required'", + "required": false, + "description": "Default tracking-consent state applied BEFORE the tracker is initialised. - `'required'` — call `requireConsent` without granting (user must opt in later). - `'given'` — call `requireConsent` then `setConsentGiven`. - `'not-required'` — no consent gating (default Matomo behaviour)." } ], "MetaPixelOptions": [ @@ -1805,6 +1811,12 @@ "type": "Record", "required": false, "description": "Additional PostHog configuration options passed directly to `posthog.init()`." + }, + { + "name": "defaultConsent", + "type": "'opt-in' | 'opt-out'", + "required": false, + "description": "Default capture-consent state applied BEFORE `posthog.init`. - `'opt-in'` — calls `posthog.opt_in_capturing()`. - `'opt-out'` — calls `posthog.opt_out_capturing()`." } ], "RedditPixelOptions": [ @@ -1903,6 +1915,12 @@ "type": "string", "required": true, "description": "Your Mixpanel project token." + }, + { + "name": "defaultConsent", + "type": "'opt-in' | 'opt-out'", + "required": false, + "description": "Default tracking-consent state applied BEFORE `mixpanel.init`. - `'opt-in'` — calls `mixpanel.opt_in_tracking()`. - `'opt-out'` — calls `mixpanel.opt_out_tracking()`." } ], "BingUetOptions": [ diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 0f935281..ebe083e3 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -329,6 +329,20 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, }, }, + consentAdapter: { + applyDefault: (state, { posthog }) => { + if (state.analytics_storage === 'granted') + posthog?.opt_in_capturing?.() + else if (state.analytics_storage === 'denied') + posthog?.opt_out_capturing?.() + }, + applyUpdate: (state, { posthog }) => { + if (state.analytics_storage === 'granted') + posthog?.opt_in_capturing?.() + else if (state.analytics_storage === 'denied') + posthog?.opt_out_capturing?.() + }, + }, }), def('fathomAnalytics', { schema: FathomAnalyticsOptions, @@ -354,6 +368,20 @@ export async function registry(resolve?: (path: string) => Promise): Pro privacy: PRIVACY_IP_ONLY, }, partytown: { forwards: ['_paq.push'] }, + consentAdapter: { + applyDefault: (state, { _paq }) => { + if (state.analytics_storage === 'granted') + _paq?.push?.(['setConsentGiven']) + else if (state.analytics_storage === 'denied') + _paq?.push?.(['forgetConsentGiven']) + }, + applyUpdate: (state, { _paq }) => { + if (state.analytics_storage === 'granted') + _paq?.push?.(['setConsentGiven']) + else if (state.analytics_storage === 'denied') + _paq?.push?.(['forgetConsentGiven']) + }, + }, }), def('rybbitAnalytics', { schema: RybbitAnalyticsOptions, @@ -414,6 +442,20 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, }, partytown: { forwards: ['mixpanel', 'mixpanel.init', 'mixpanel.track', 'mixpanel.identify', 'mixpanel.people.set', 'mixpanel.reset', 'mixpanel.register'] }, + consentAdapter: { + applyDefault: (state, { mixpanel }) => { + if (state.analytics_storage === 'granted') + mixpanel?.opt_in_tracking?.() + else if (state.analytics_storage === 'denied') + mixpanel?.opt_out_tracking?.() + }, + applyUpdate: (state, { mixpanel }) => { + if (state.analytics_storage === 'granted') + mixpanel?.opt_in_tracking?.() + else if (state.analytics_storage === 'denied') + mixpanel?.opt_out_tracking?.() + }, + }, }), // ad def('bingUet', { @@ -712,6 +754,16 @@ export async function registry(resolve?: (path: string) => Promise): Pro }) }, }, + consentAdapter: { + // GTM lives on the same dataLayer as gtag. The gtag wrapper pushes its arguments + // object; we emulate it by pushing a plain array matching `['consent', 'default'|'update', state]`. + applyDefault: (state, { dataLayer }) => { + dataLayer?.push?.(['consent', 'default', state] as any) + }, + applyUpdate: (state, { dataLayer }) => { + dataLayer?.push?.(['consent', 'update', state] as any) + }, + }, }), def('googleAnalytics', { schema: GoogleAnalyticsOptions, diff --git a/packages/script/src/runtime/registry/matomo-analytics.ts b/packages/script/src/runtime/registry/matomo-analytics.ts index b285008c..4bc05060 100644 --- a/packages/script/src/runtime/registry/matomo-analytics.ts +++ b/packages/script/src/runtime/registry/matomo-analytics.ts @@ -49,6 +49,14 @@ export function useScriptMatomoAnalytics(_options? clientInit: import.meta.server ? undefined : () => { + // Consent MUST be queued before any tracking call so Matomo honors it for the first page view. + if (options?.defaultConsent === 'required') { + _paq.push(['requireConsent']) + } + else if (options?.defaultConsent === 'given') { + _paq.push(['requireConsent']) + _paq.push(['setConsentGiven']) + } if (options?.enableLinkTracking) { _paq.push(['enableLinkTracking']) } diff --git a/packages/script/src/runtime/registry/mixpanel-analytics.ts b/packages/script/src/runtime/registry/mixpanel-analytics.ts index c5be0858..4f1596ee 100644 --- a/packages/script/src/runtime/registry/mixpanel-analytics.ts +++ b/packages/script/src/runtime/registry/mixpanel-analytics.ts @@ -67,8 +67,18 @@ export function useScriptMixpanelAnalytics(_opti } } - if (options?.token) - mp.init(options.token) + if (options?.token) { + // Mixpanel accepts `opt_out_tracking_by_default` on init() to start in the + // opted-out state. For explicit opt-in we still default out then flip in, + // so consent is resolved BEFORE the first track call. + const optOutByDefault = options?.defaultConsent !== undefined + && options.defaultConsent !== 'opt-in' + mp.init(options.token, optOutByDefault ? { opt_out_tracking_by_default: true } : undefined) + if (options?.defaultConsent === 'opt-in') { + // opt_in_tracking isn't part of the stub; push so the real SDK runs it on load. + mp.push(['opt_in_tracking']) + } + } }, } }, _options) diff --git a/packages/script/src/runtime/registry/posthog.ts b/packages/script/src/runtime/registry/posthog.ts index 5bdb6113..40d625d8 100644 --- a/packages/script/src/runtime/registry/posthog.ts +++ b/packages/script/src/runtime/registry/posthog.ts @@ -93,6 +93,10 @@ export function useScriptPostHog(_options?: PostHogInput) config.capture_pageleave = options.capturePageleave if (typeof options?.disableSessionRecording === 'boolean') config.disable_session_recording = options.disableSessionRecording + // Start opted-out if consent is denied, so init doesn't capture anything + // until the user grants consent. + if (options?.defaultConsent === 'opt-out') + config.opt_out_capturing_by_default = true const instance = posthog.init(options.apiKey, config) if (!instance) { @@ -102,6 +106,9 @@ export function useScriptPostHog(_options?: PostHogInput) } window.posthog = instance + // Apply explicit opt-in AFTER init (opt-out is handled by init config above). + if (options?.defaultConsent === 'opt-in') + instance.opt_in_capturing?.() // Flush queued calls now that PostHog is ready if (window._posthogQueue && window._posthogQueue.length > 0) { window._posthogQueue.forEach(q => (window.posthog as any)[q.prop]?.(...q.args)) diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 48387b25..484af31a 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -566,6 +566,14 @@ export const MatomoAnalyticsOptions = object({ * @default true */ watch: optional(boolean()), + /** + * Default tracking-consent state applied BEFORE the tracker is initialised. + * - `'required'` — call `requireConsent` without granting (user must opt in later). + * - `'given'` — call `requireConsent` then `setConsentGiven`. + * - `'not-required'` — no consent gating (default Matomo behaviour). + * @see https://developer.matomo.org/guides/tracking-consent + */ + defaultConsent: optional(union([literal('required'), literal('given'), literal('not-required')])), }) export const MetaPixelOptions = object({ @@ -670,6 +678,13 @@ export const PostHogOptions = object({ * @see https://posthog.com/docs/libraries/js#config */ config: optional(record(string(), any())), + /** + * Default capture-consent state applied BEFORE `posthog.init`. + * - `'opt-in'` — calls `posthog.opt_in_capturing()`. + * - `'opt-out'` — calls `posthog.opt_out_capturing()`. + * @see https://posthog.com/docs/privacy/opting-out + */ + defaultConsent: optional(union([literal('opt-in'), literal('opt-out')])), }) export const RedditPixelOptions = object({ @@ -746,6 +761,13 @@ export const MixpanelAnalyticsOptions = object({ * @see https://docs.mixpanel.com/docs/tracking-methods/sdks/javascript#1-initialize-the-library */ token: string(), + /** + * Default tracking-consent state applied BEFORE `mixpanel.init`. + * - `'opt-in'` — calls `mixpanel.opt_in_tracking()`. + * - `'opt-out'` — calls `mixpanel.opt_out_tracking()`. + * @see https://docs.mixpanel.com/docs/privacy/opt-out-of-tracking + */ + defaultConsent: optional(union([literal('opt-in'), literal('opt-out')])), }) export const BingUetOptions = object({ diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 5e755db9..0876bcf6 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -50,6 +50,33 @@ export { MARKER_CLUSTERER_INJECTION_KEY } from './components/GoogleMaps/types' export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' +// -- Consent mode (GCM-style) -- + +export type ConsentCategoryValue = 'granted' | 'denied' + +export interface ConsentState { + ad_storage?: ConsentCategoryValue + ad_user_data?: ConsentCategoryValue + ad_personalization?: ConsentCategoryValue + analytics_storage?: ConsentCategoryValue + functionality_storage?: ConsentCategoryValue + personalization_storage?: ConsentCategoryValue + security_storage?: ConsentCategoryValue +} + +/** + * Shared adapter contract that unifies consent APIs across vendors. + * Consumers (and `useScriptTriggerConsent`) apply GCM-style state without + * caring whether the vendor exposes `gtag('consent', ...)`, `_paq.push`, + * `mixpanel.opt_in_tracking()`, etc. + */ +export interface ConsentAdapter { + /** Called once before the vendor init call. */ + applyDefault: (state: ConsentState, proxy: Proxy) => void + /** Called whenever consent changes at runtime. */ + applyUpdate: (state: ConsentState, proxy: Proxy) => void +} + export type UseScriptContext> = VueScriptInstance & { /** * Remove and reload the script. Useful for scripts that need to re-execute @@ -439,6 +466,12 @@ export interface RegistryScript { * - absent: not partytown-capable */ partytown?: PartytownCapability + /** + * Consent adapter. Lets tooling (e.g. `useScriptTriggerConsent`) apply + * GCM-style consent state against the vendor's runtime proxy, without + * knowing vendor-specific APIs. + */ + consentAdapter?: ConsentAdapter } export type ElementScriptTrigger = 'immediate' | 'visible' | string | string[] | false diff --git a/test/nuxt-runtime/consent-default.nuxt.test.ts b/test/nuxt-runtime/consent-default.nuxt.test.ts new file mode 100644 index 00000000..7eae3394 --- /dev/null +++ b/test/nuxt-runtime/consent-default.nuxt.test.ts @@ -0,0 +1,255 @@ +/** + * Consent Mode — verifies each script fires its vendor-specific consent call + * inside `clientInit` BEFORE the vendor init / first tracking call. + * + * Runs inside the nuxt (browser-like) environment so `import.meta.client` is true + * and the module-level `_paq`/`window` initialisation takes the client branch. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Force useRegistryScript to a pass-through so we can call `clientInit` directly +// instead of going through the real beforeInit wrapping (which requires the full +// Nuxt + useScript plumbing). +vi.mock('#nuxt-scripts/utils', () => ({ + useRegistryScript: (_key: string, optionsFn: any, userOptions?: any) => { + const opts = optionsFn(userOptions || {}, { scriptInput: userOptions?.scriptInput }) + return { _opts: opts, proxy: opts.scriptOptions?.use?.() } + }, +})) + +vi.mock('../../packages/script/src/runtime/utils', () => ({ + useRegistryScript: (_key: string, optionsFn: any, userOptions?: any) => { + const opts = optionsFn(userOptions || {}, { scriptInput: userOptions?.scriptInput }) + return { _opts: opts, proxy: opts.scriptOptions?.use?.() } + }, + scriptRuntimeConfig: () => ({}), + scriptsPrefix: () => '/_scripts', + requireRegistryEndpoint: () => {}, +})) + +vi.mock('../../packages/script/src/runtime/composables/useScriptEventPage', () => ({ + useScriptEventPage: vi.fn(), +})) + +describe('consent defaults — clientInit ordering', () => { + beforeEach(() => { + delete (window as any).dataLayer + delete (window as any)._paq + delete (window as any).mixpanel + delete (window as any).posthog + delete (window as any).__posthogInitPromise + }) + + it('gtm: pushes ["consent","default",state] before gtm.start', async () => { + const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager') + const result: any = useScriptGoogleTagManager({ + id: 'GTM-XXXX', + defaultConsent: { analytics_storage: 'denied', ad_storage: 'denied' }, + }) + + result._opts.clientInit() + + const dl = (window as any).dataLayer as any[] + expect(Array.isArray(dl)).toBe(true) + + const consentIdx = dl.findIndex(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(consentIdx).toBeGreaterThanOrEqual(0) + expect(startIdx).toBeGreaterThanOrEqual(0) + expect(consentIdx).toBeLessThan(startIdx) + expect(dl[consentIdx][2]).toMatchObject({ analytics_storage: 'denied', ad_storage: 'denied' }) + }) + + it('matomo: "required" pushes requireConsent before setSiteId', async () => { + ;(window as any)._paq = [] + const { useScriptMatomoAnalytics } = await import('../../packages/script/src/runtime/registry/matomo-analytics') + const result: any = useScriptMatomoAnalytics({ + matomoUrl: 'https://m.example.com', + siteId: 1, + defaultConsent: 'required', + }) + + result._opts.clientInit() + + const calls = (window as any)._paq as any[] + const findIdx = (cmd: string) => calls.findIndex(c => Array.isArray(c) && c[0] === cmd) + + expect(findIdx('requireConsent')).toBeGreaterThanOrEqual(0) + expect(findIdx('setSiteId')).toBeGreaterThan(findIdx('requireConsent')) + }) + + it('matomo: "given" queues requireConsent then setConsentGiven, both before setSiteId', async () => { + ;(window as any)._paq = [] + const { useScriptMatomoAnalytics } = await import('../../packages/script/src/runtime/registry/matomo-analytics') + const result: any = useScriptMatomoAnalytics({ + matomoUrl: 'https://m.example.com', + siteId: 1, + defaultConsent: 'given', + }) + + result._opts.clientInit() + + const calls = (window as any)._paq as any[] + const findIdx = (cmd: string) => calls.findIndex(c => Array.isArray(c) && c[0] === cmd) + + expect(findIdx('requireConsent')).toBeLessThan(findIdx('setConsentGiven')) + expect(findIdx('setConsentGiven')).toBeLessThan(findIdx('setSiteId')) + }) + + it('mixpanel: opt-out sets opt_out_tracking_by_default on init config', async () => { + const { useScriptMixpanelAnalytics } = await import('../../packages/script/src/runtime/registry/mixpanel-analytics') + + const initSpy = vi.fn() + const mp: any = [] + mp.__SV = 1.2 + mp._i = [] + mp.init = initSpy + ;(window as any).mixpanel = mp + + const result: any = useScriptMixpanelAnalytics({ + token: 'tok_xyz', + defaultConsent: 'opt-out', + }) + result._opts.clientInit() + + expect(initSpy).toHaveBeenCalledTimes(1) + const [token, config] = initSpy.mock.calls[0] as any[] + expect(token).toBe('tok_xyz') + expect(config).toMatchObject({ opt_out_tracking_by_default: true }) + }) + + it('mixpanel: opt-in queues opt_in_tracking AFTER init', async () => { + const { useScriptMixpanelAnalytics } = await import('../../packages/script/src/runtime/registry/mixpanel-analytics') + + const seq: string[] = [] + const initSpy = vi.fn(() => { seq.push('init') }) + const mp: any = [] + const origPush = Array.prototype.push.bind(mp) + mp.push = (arg: any) => { + if (Array.isArray(arg)) + seq.push(`push:${arg[0]}`) + else seq.push('push:other') + return origPush(arg) + } + mp.__SV = 1.2 + mp._i = [] + mp.init = initSpy + ;(window as any).mixpanel = mp + + const result: any = useScriptMixpanelAnalytics({ + token: 'tok_xyz', + defaultConsent: 'opt-in', + }) + result._opts.clientInit() + + const cfg = initSpy.mock.calls[0]?.[1] + expect(cfg?.opt_out_tracking_by_default).toBeUndefined() + + expect(seq.indexOf('init')).toBeGreaterThanOrEqual(0) + expect(seq.indexOf('push:opt_in_tracking')).toBeGreaterThan(seq.indexOf('init')) + }) + + it('posthog: opt-out sets opt_out_capturing_by_default on init config', async () => { + const posthogInit = vi.fn(() => ({ opt_in_capturing: vi.fn() })) + vi.doMock('posthog-js', () => ({ default: { init: posthogInit } })) + vi.resetModules() + + const { useScriptPostHog } = await import('../../packages/script/src/runtime/registry/posthog') + + const result: any = useScriptPostHog({ + apiKey: 'phc_xxx', + defaultConsent: 'opt-out', + }) + await result._opts.clientInit() + await (window as any).__posthogInitPromise + + expect(posthogInit).toHaveBeenCalledTimes(1) + const [key, config] = posthogInit.mock.calls[0] as any[] + expect(key).toBe('phc_xxx') + expect(config).toMatchObject({ opt_out_capturing_by_default: true }) + }) + + it('posthog: opt-in calls opt_in_capturing AFTER init returns', async () => { + const optInSpy = vi.fn() + const instance = { opt_in_capturing: optInSpy } + const posthogInit = vi.fn(() => { + expect(optInSpy).not.toHaveBeenCalled() + return instance + }) + vi.doMock('posthog-js', () => ({ default: { init: posthogInit } })) + vi.resetModules() + + const { useScriptPostHog } = await import('../../packages/script/src/runtime/registry/posthog') + + const result: any = useScriptPostHog({ + apiKey: 'phc_xxx', + defaultConsent: 'opt-in', + }) + await result._opts.clientInit() + await (window as any).__posthogInitPromise + + expect(posthogInit).toHaveBeenCalledTimes(1) + expect(optInSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe('consentAdapter contract', () => { + it('matomo adapter: granted pushes setConsentGiven; denied pushes forgetConsentGiven', async () => { + const { registry } = await import('../../packages/script/src/registry') + const all = await registry() + const matomo = all.find(s => s.registryKey === 'matomoAnalytics')! + expect(matomo.consentAdapter).toBeDefined() + + const _paq: any[] = [] + matomo.consentAdapter!.applyUpdate({ analytics_storage: 'granted' }, { _paq }) + matomo.consentAdapter!.applyUpdate({ analytics_storage: 'denied' }, { _paq }) + + expect(_paq).toEqual([['setConsentGiven'], ['forgetConsentGiven']]) + }) + + it('mixpanel adapter: granted calls opt_in_tracking; denied calls opt_out_tracking', async () => { + const { registry } = await import('../../packages/script/src/registry') + const all = await registry() + const mp = all.find(s => s.registryKey === 'mixpanelAnalytics')! + const optIn = vi.fn() + const optOut = vi.fn() + const mixpanel = { opt_in_tracking: optIn, opt_out_tracking: optOut } + + mp.consentAdapter!.applyUpdate({ analytics_storage: 'granted' }, { mixpanel }) + expect(optIn).toHaveBeenCalledOnce() + + mp.consentAdapter!.applyUpdate({ analytics_storage: 'denied' }, { mixpanel }) + expect(optOut).toHaveBeenCalledOnce() + }) + + it('posthog adapter: granted calls opt_in_capturing; denied calls opt_out_capturing', async () => { + const { registry } = await import('../../packages/script/src/registry') + const all = await registry() + const ph = all.find(s => s.registryKey === 'posthog')! + const optIn = vi.fn() + const optOut = vi.fn() + const posthog = { opt_in_capturing: optIn, opt_out_capturing: optOut } + + ph.consentAdapter!.applyUpdate({ analytics_storage: 'granted' }, { posthog }) + expect(optIn).toHaveBeenCalledOnce() + + ph.consentAdapter!.applyUpdate({ analytics_storage: 'denied' }, { posthog }) + expect(optOut).toHaveBeenCalledOnce() + }) + + it('gtm adapter: default pushes ["consent","default",state]; update pushes ["consent","update",state]', async () => { + const { registry } = await import('../../packages/script/src/registry') + const all = await registry() + const gtm = all.find(s => s.registryKey === 'googleTagManager')! + const dataLayer: any[] = [] + + gtm.consentAdapter!.applyDefault({ analytics_storage: 'denied' }, { dataLayer }) + gtm.consentAdapter!.applyUpdate({ analytics_storage: 'granted' }, { dataLayer }) + + expect(dataLayer).toEqual([ + ['consent', 'default', { analytics_storage: 'denied' }], + ['consent', 'update', { analytics_storage: 'granted' }], + ]) + }) +}) From fe1548b113d72d50cccd8bd88a265cbd3f716e0d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 14 Apr 2026 23:41:46 +1000 Subject: [PATCH 03/13] feat(script): unified useScriptConsent composable Adds a single `useScriptConsent` composable that supersedes `useScriptTriggerConsent`. Keeps the existing binary load gate behaviour (`consent` Ref/Promise/boolean plus `postConsentTrigger`) while adding Google Consent Mode v2 granular state, batched `update()` fan out, and adapter registration so registry scripts can subscribe to category changes via their `consentAdapter`. - New `useScriptConsent` composable with reactive `state`, `update()`, `register()`, `accept`/`revoke`, and an awaitable load gate Promise - `useScriptTriggerConsent` is now a dev-warning shim that delegates to `useScriptConsent`; existing call sites remain unchanged - Adds `ConsentAdapter`, `ConsentState`, `ConsentCategoryValue`, and `UseScriptConsentOptions` to the public type surface - Wires optional `consent` + `_consentAdapter` registration into the base `useScript` composable so Scope A's registry adapters auto subscribe - Unit tests cover default state, merging, batching, registration, binary compat, and the migration shim - Reworks the consent guide with GCMv2 schema, vendor mapping table, OneTrust and Cookiebot recipes, and a migration section --- docs/content/docs/1.guides/3.consent.md | 231 ++++++++++++++---- packages/script/src/module.ts | 1 + .../src/runtime/composables/useScript.ts | 15 ++ .../runtime/composables/useScriptConsent.ts | 209 ++++++++++++++++ .../composables/useScriptTriggerConsent.ts | 110 +-------- packages/script/src/runtime/types.ts | 66 +++++ .../use-script-consent.nuxt.test.ts | 136 +++++++++++ 7 files changed, 621 insertions(+), 147 deletions(-) create mode 100644 packages/script/src/runtime/composables/useScriptConsent.ts create mode 100644 test/nuxt-runtime/use-script-consent.nuxt.test.ts diff --git a/docs/content/docs/1.guides/3.consent.md b/docs/content/docs/1.guides/3.consent.md index 4261b90d..a24f127c 100644 --- a/docs/content/docs/1.guides/3.consent.md +++ b/docs/content/docs/1.guides/3.consent.md @@ -1,6 +1,6 @@ --- title: Consent Management -description: Learn how to get user consent before loading scripts. +description: Gate scripts behind user consent, drive Google Consent Mode v2 granular state, and fan it out to registry scripts. --- ::callout{icon="i-heroicons-play" to="https://stackblitz.com/github/nuxt/scripts/tree/main/examples/cookie-consent" target="_blank"} @@ -9,43 +9,40 @@ Try the live [Cookie Consent Example](https://stackblitz.com/github/nuxt/scripts ## Background -Many third-party scripts include tracking cookies that require user consent under privacy laws. Nuxt Scripts simplifies this process with the [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable, allowing scripts to load only after you receive user consent. +Many third-party scripts set tracking cookies that require user consent. Nuxt Scripts ships a single composable, [`useScriptConsent()`{lang="ts"}](/docs/api/use-script-consent){lang="ts"}, that handles both: -## Usage +1. The binary load gate: the script only starts loading after consent is granted. +2. Granular Google Consent Mode v2 state: categories like `ad_storage`, `analytics_storage`, etc., are tracked reactively and fanned out to subscribed registry scripts via their `consentAdapter`. -The [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable offers flexible interaction options suitable for various scenarios. +`useScriptConsent` is a superset of the deprecated `useScriptTriggerConsent`. Every option on the old composable works unchanged; see [migration](#migration-from-usescripttriggerconsent). -See the [API](/docs/api/use-script-trigger-consent) docs for full details on the available options. +## Binary load gate -### Accepting as a Function - -The easiest way to make use of [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} is by invoking the `accept` method when user consent is granted. - -For an example of how you might lay out your code to handle this, see the following: +The simplest usage matches the classic cookie-banner flow: load the script only after the user clicks accept. ::code-group ```ts [utils/cookie.ts] -export const agreedToCookiesScriptConsent = useScriptTriggerConsent() +export const scriptsConsent = useScriptConsent() ``` ```vue [app.vue] ``` ```vue [components/cookie-banner.vue] @@ -53,56 +50,202 @@ import { agreedToCookiesScriptConsent } from '#imports' :: -### Accepting as a resolvable boolean +### Reactive source -Alternatively, you can pass a reactive reference to the consent state to the [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent){lang="ts"} composable. This will automatically load the script when the consent state is `true`. +Pass a `Ref`{lang="html"} if an external store owns the state. ```ts const agreedToCookies = ref(false) -useScript('https://www.google-analytics.com/analytics.js', { - trigger: useScriptTriggerConsent({ - consent: agreedToCookies - }) -}) +const consent = useScriptConsent({ consent: agreedToCookies }) ``` -### Revoking Consent +### Revoking -You can revoke consent after it has been granted using the `revoke` method. Use the reactive `consented` ref to track the current consent state. - -```vue [components/cookie-banner.vue] - +Consent revocation flips the reactive `consented` ref; once the load-gate promise has resolved, it stays resolved. Watch `consented` if a script needs to tear down on revoke. +```vue ``` -### Delaying script load after consent +### Delaying the load after consent + +```ts +const consent = useScriptConsent({ + consent: agreedToCookies, + postConsentTrigger: () => new Promise(resolve => + setTimeout(resolve, 3000), + ), +}) +``` + +## Granular consent (Google Consent Mode v2) + +Pass a `default` state and the composable tracks every category reactively. Registry scripts that declare a `consentAdapter` automatically subscribe when you pass the composable as their `consent`. -There may be instances where you want to trigger the script load after a certain event, only if the user has consented. +### GCMv2 category schema -For this you can use the `postConsentTrigger`, which shares the same API as `trigger` from the [`useScript()`{lang="ts"}](/docs/api/use-script){lang="ts"} composable. +| Category | Purpose | +| --- | --- | +| `ad_storage` | Advertising cookies and storage | +| `ad_user_data` | Sending user data to ad platforms | +| `ad_personalization` | Personalised ads and retargeting | +| `analytics_storage` | Analytics cookies and storage | +| `functionality_storage` | Functional features like chat or preferences | +| `personalization_storage` | Personalised content | +| `security_storage` | Security features such as fraud prevention | + +Each category is either `'granted'` or `'denied'`. + +### Vendor mapping + +Registry scripts map GCMv2 categories to their internal consent APIs via an adapter. Representative mappings: + +| Script | Categories used | +| --- | --- | +| Google Tag Manager / Google Analytics | `ad_storage`, `ad_user_data`, `ad_personalization`, `analytics_storage` | +| Meta Pixel | `ad_storage`, `ad_user_data`, `ad_personalization` | +| TikTok Pixel | `ad_storage`, `ad_user_data` | +| X Pixel | `ad_storage`, `ad_user_data` | +| Reddit Pixel | `ad_storage` | +| Snapchat Pixel | `ad_storage`, `ad_user_data` | +| Hotjar | `analytics_storage` | +| Clarity | `analytics_storage` | +| Crisp / Intercom | `functionality_storage` | + +Refer to each script's registry page for the exact mapping. Scripts without a declared adapter only observe the binary load gate. + +### Example ```ts -const agreedToCookies = ref(false) -useScript('https://www.google-analytics.com/analytics.js', { - trigger: useScriptTriggerConsent({ - consent: agreedToCookies, - // load 3 seconds after consent is granted - postConsentTrigger: () => new Promise(resolve => - setTimeout(resolve, 3000), - ), +const consent = useScriptConsent({ + default: { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: 'denied', + }, +}) + +useScriptGoogleTagManager({ + id: 'GTM-XXXXXX', + scriptOptions: { consent }, +}) + +useScriptMetaPixel({ + id: '1234567890', + scriptOptions: { consent }, +}) + +function savePreferences(choices: { analytics: boolean, marketing: boolean }) { + consent.update({ + analytics_storage: choices.analytics ? 'granted' : 'denied', + ad_storage: choices.marketing ? 'granted' : 'denied', + ad_user_data: choices.marketing ? 'granted' : 'denied', + ad_personalization: choices.marketing ? 'granted' : 'denied', }) +} +``` + +`consent.update()`{lang="ts"} merges the partial state and fans out to every subscribed adapter once per tick, so calling it three times in a row fires one update per script. + +## Third-party CMP recipes + +When a dedicated Consent Management Platform owns the UI, bridge its events into `useScriptConsent.update()`{lang="ts"}. + +### OneTrust + +```ts +const consent = useScriptConsent({ + default: { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: 'denied', + }, +}) + +onNuxtReady(() => { + function apply() { + const groups = (window as any).OnetrustActiveGroups as string | undefined + if (!groups) + return + consent.update({ + analytics_storage: groups.includes('C0002') ? 'granted' : 'denied', + ad_storage: groups.includes('C0004') ? 'granted' : 'denied', + ad_user_data: groups.includes('C0004') ? 'granted' : 'denied', + ad_personalization: groups.includes('C0004') ? 'granted' : 'denied', + }) + } + + apply() + window.addEventListener('OneTrustGroupsUpdated', apply) +}) +``` + +### Cookiebot + +```ts +const consent = useScriptConsent({ + default: { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: 'denied', + }, +}) + +onNuxtReady(() => { + function apply() { + const cb = (window as any).Cookiebot + if (!cb?.consent) + return + consent.update({ + analytics_storage: cb.consent.statistics ? 'granted' : 'denied', + ad_storage: cb.consent.marketing ? 'granted' : 'denied', + ad_user_data: cb.consent.marketing ? 'granted' : 'denied', + ad_personalization: cb.consent.marketing ? 'granted' : 'denied', + }) + } + + apply() + window.addEventListener('CookiebotOnAccept', apply) + window.addEventListener('CookiebotOnDecline', apply) }) ``` + +## Migration from `useScriptTriggerConsent` + +`useScriptTriggerConsent` is deprecated and logs a dev-only warning. It re-exports `useScriptConsent`, so options are 100 percent compatible: rename the function, keep everything else. + +**Before** + +```ts +const consent = useScriptTriggerConsent({ + consent: agreedToCookies, + postConsentTrigger: 'onNuxtReady', +}) + +useScript('https://www.google-analytics.com/analytics.js', { trigger: consent }) +``` + +**After** + +```ts +const consent = useScriptConsent({ + consent: agreedToCookies, + postConsentTrigger: 'onNuxtReady', +}) + +useScript('https://www.google-analytics.com/analytics.js', { trigger: consent }) +``` + +Moving to granular state is purely additive: add a `default` object and swap `trigger: consent` for `scriptOptions: { consent }` on registry scripts that declare a `consentAdapter`. diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index bbd4d4c6..ad0e8b64 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -513,6 +513,7 @@ export default defineNuxtModule({ const composables = [ 'useScript', + 'useScriptConsent', 'useScriptEventPage', 'useScriptTriggerConsent', 'useScriptTriggerElement', diff --git a/packages/script/src/runtime/composables/useScript.ts b/packages/script/src/runtime/composables/useScript.ts index 6a6edcf1..3c49f04e 100644 --- a/packages/script/src/runtime/composables/useScript.ts +++ b/packages/script/src/runtime/composables/useScript.ts @@ -281,6 +281,21 @@ export function useScript = Record { + unregister() + return _removeWithUnregister() + } + } // used for devtools integration if (import.meta.dev && import.meta.client) { if (exists) { diff --git a/packages/script/src/runtime/composables/useScriptConsent.ts b/packages/script/src/runtime/composables/useScriptConsent.ts new file mode 100644 index 00000000..f3a91596 --- /dev/null +++ b/packages/script/src/runtime/composables/useScriptConsent.ts @@ -0,0 +1,209 @@ +import type { Ref } from 'vue' +import type { ConsentAdapter, ConsentState, UseScriptConsentOptions } from '../types' +import { onNuxtReady, requestIdleCallback, tryUseNuxtApp } from 'nuxt/app' +import { computed, isRef, nextTick, ref, toValue, watch } from 'vue' + +export interface UseScriptConsentApi extends Promise { + /** + * Set every Google Consent Mode v2 category to `'granted'` and resolve the awaitable promise. + * Existing `useScriptTriggerConsent` semantics are preserved. + */ + accept: () => void + /** + * Set every Google Consent Mode v2 category to `'denied'`. + * Subscribed adapters receive an update. The load-gate promise, once resolved, stays resolved; + * subscribers should watch `consented` to react to revocation. + */ + revoke: () => void + /** + * Reactive boolean that is `true` when at least one category in `state` is `'granted'`. + */ + consented: Ref + /** + * Reactive granular consent state. + */ + state: Ref + /** + * Merge a partial state and fan out to subscribed adapters. Multiple calls within the same + * tick are coalesced into a single `applyUpdate` per adapter with the merged snapshot. + */ + update: (partial: ConsentState) => void + /** + * Subscribe a script's adapter. Fires `applyDefault` with the current state immediately, + * and `applyUpdate` on every subsequent `update()`. + */ + register: (adapter: ConsentAdapter, proxy: Proxy) => () => void +} + +interface Subscription { + adapter: ConsentAdapter + proxy: any +} + +function createServerStub(): UseScriptConsentApi { + const p = new Promise(() => {}) as UseScriptConsentApi + const state = ref({}) as Ref + p.accept = () => {} + p.revoke = () => {} + p.consented = ref(false) + p.state = state + p.update = () => {} + p.register = () => () => {} + return p +} + +const ALL_CATEGORIES: (keyof ConsentState)[] = [ + 'ad_storage', + 'ad_user_data', + 'ad_personalization', + 'analytics_storage', + 'functionality_storage', + 'personalization_storage', + 'security_storage', +] + +/** + * Unified consent composable. Replaces `useScriptTriggerConsent` as a superset: + * every existing option (`consent`, `postConsentTrigger`) behaves identically, while + * the new `default` option plus the `state` / `update` / `register` API add granular + * Google Consent Mode v2 state with adapter fan-out to subscribed registry scripts. + */ +export function useScriptConsent(options?: UseScriptConsentOptions): UseScriptConsentApi { + if (import.meta.server) { + return createServerStub() + } + + const nuxtApp = tryUseNuxtApp() + const state = ref({ ...(options?.default || {}) }) as Ref + + const consented = computed(() => { + const current = state.value + for (const key of ALL_CATEGORIES) { + if (current[key] === 'granted') { + return true + } + } + return false + }) as Ref + + const subscriptions = new Set() + let pendingFlush = false + + function scheduleFlush() { + if (pendingFlush) + return + pendingFlush = true + nextTick(() => { + pendingFlush = false + const snapshot = { ...state.value } + for (const sub of subscriptions) { + sub.adapter.applyUpdate(snapshot, sub.proxy) + } + }) + } + + function update(partial: ConsentState) { + state.value = { ...state.value, ...partial } + scheduleFlush() + } + + function grantAll() { + const next: ConsentState = { ...state.value } + for (const key of ALL_CATEGORIES) { + next[key] = 'granted' + } + state.value = next + scheduleFlush() + } + + function denyAll() { + const next: ConsentState = { ...state.value } + for (const key of ALL_CATEGORIES) { + next[key] = 'denied' + } + state.value = next + scheduleFlush() + } + + function register(adapter: ConsentAdapter, proxy: Proxy): () => void { + const sub: Subscription = { adapter, proxy } + subscriptions.add(sub) + // fire default with the current state so the SDK sees defaults before init + adapter.applyDefault({ ...state.value }, proxy) + return () => { + subscriptions.delete(sub) + } + } + + // ---- load-gate (ported from useScriptTriggerConsent, unchanged semantics) ---- + if (options?.consent) { + if (isRef(options.consent)) { + watch(options.consent, (_val) => { + const val = toValue(_val) + if (Boolean(val) && !consented.value) { + // Promote the legacy boolean `consent` signal into a full grant so the granular + // state stays coherent for adapter fan-out. + grantAll() + } + else if (!val && consented.value) { + denyAll() + } + }, { immediate: true }) + } + else if (typeof options.consent === 'boolean') { + if (options.consent) { + grantAll() + } + } + else if (options.consent instanceof Promise) { + options.consent + .then((res) => { + const granted = typeof res === 'boolean' ? res : true + if (granted) { + grantAll() + } + }) + .catch(() => { + // swallow, keep denied + }) + } + } + + const promise = new Promise((resolve) => { + watch(consented, (newValue, oldValue) => { + if (newValue && !oldValue) { + const runner = nuxtApp?.runWithContext || ((cb: () => void) => cb()) + if (options?.postConsentTrigger instanceof Promise) { + options.postConsentTrigger.then(() => runner(resolve)) + return + } + if (typeof options?.postConsentTrigger === 'function') { + if (options.postConsentTrigger.length === 1) { + options.postConsentTrigger(resolve) + return + } + const val = (options.postConsentTrigger as (() => Promise))() + if (val instanceof Promise) { + return val.then(() => runner(resolve)) + } + return + } + if (options?.postConsentTrigger === 'onNuxtReady') { + const idleTimeout = options?.postConsentTrigger ? (nuxtApp ? onNuxtReady : requestIdleCallback) : (cb: () => void) => cb() + runner(() => idleTimeout(resolve)) + return + } + runner(resolve) + } + }, { immediate: true }) + }) as UseScriptConsentApi + + promise.accept = grantAll + promise.revoke = denyAll + promise.consented = consented + promise.state = state + promise.update = update + promise.register = register + + return promise +} diff --git a/packages/script/src/runtime/composables/useScriptTriggerConsent.ts b/packages/script/src/runtime/composables/useScriptTriggerConsent.ts index 2b5d4767..cf035d6f 100644 --- a/packages/script/src/runtime/composables/useScriptTriggerConsent.ts +++ b/packages/script/src/runtime/composables/useScriptTriggerConsent.ts @@ -1,109 +1,13 @@ -import type { Ref } from 'vue' import type { ConsentScriptTriggerOptions } from '../types' -import { onNuxtReady, requestIdleCallback, tryUseNuxtApp } from 'nuxt/app' -import { isRef, ref, toValue, watch } from 'vue' - -interface UseConsentScriptTriggerApi extends Promise { - /** - * A function that can be called to accept the consent and load the script. - */ - accept: () => void - /** - * A function that can be called to revoke consent and signal the script should be unloaded. - */ - revoke: () => void - /** - * Reactive reference to the consent state - */ - consented: Ref -} +import { useScriptConsent } from './useScriptConsent' /** - * Load a script once consent has been provided either through a resolvable `consent` or calling the `accept` method. - * Supports revoking consent via the reactive `consented` ref. Consumers should watch `consented` to react to revocation. - * @param options + * @deprecated Use `useScriptConsent` instead. `useScriptTriggerConsent` will be removed in the next major. + * Migration: rename the function. All options are compatible. See https://scripts.nuxt.com/docs/guides/consent-mode */ -export function useScriptTriggerConsent(options?: ConsentScriptTriggerOptions): UseConsentScriptTriggerApi { - if (import.meta.server) { - const p = new Promise(() => {}) as UseConsentScriptTriggerApi - p.accept = () => {} - p.revoke = () => {} - p.consented = ref(false) - return p - } - - const consented = ref(false) - const nuxtApp = tryUseNuxtApp() - - // Setup initial consent value - if (options?.consent) { - if (isRef(options?.consent)) { - watch(options.consent, (_val) => { - const val = toValue(_val) - consented.value = Boolean(val) - }, { immediate: true }) - } - // check for boolean primitive - else if (typeof options?.consent === 'boolean') { - consented.value = options?.consent - } - // consent is a promise - else if (options?.consent instanceof Promise) { - options.consent - .then((res) => { - consented.value = typeof res === 'boolean' ? res : true - }) - .catch(() => { - consented.value = false - }) - } - } - - const promise = new Promise((resolve) => { - watch(consented, (newValue, oldValue) => { - if (newValue && !oldValue) { - // Consent granted - load script - const runner = nuxtApp?.runWithContext || ((cb: () => void) => cb()) - if (options?.postConsentTrigger instanceof Promise) { - options.postConsentTrigger.then(() => runner(resolve)) - return - } - if (typeof options?.postConsentTrigger === 'function') { - // check if function has an argument - if (options?.postConsentTrigger.length === 1) { - options.postConsentTrigger(resolve) - return - } - // else it's returning a promise to await - const val = (options.postConsentTrigger as (() => Promise))() - if (val instanceof Promise) { - return val.then(() => runner(resolve)) - } - return - } - if (options?.postConsentTrigger === 'onNuxtReady') { - const idleTimeout = options?.postConsentTrigger ? (nuxtApp ? onNuxtReady : requestIdleCallback) : (cb: () => void) => cb() - runner(() => idleTimeout(resolve)) - return - } - // other trigger not supported - runner(resolve) - } - // Revocation is handled via the reactive `consented` ref, not promise rejection. - // Once resolved, a promise cannot be rejected — consumers should watch `consented` instead. - }, { immediate: true }) - }) as UseConsentScriptTriggerApi - - // we augment the promise with a consent API - promise.accept = () => { - consented.value = true - } - - promise.revoke = () => { - consented.value = false +export function useScriptTriggerConsent(options?: ConsentScriptTriggerOptions) { + if (import.meta.dev) { + console.warn('[nuxt-scripts] useScriptTriggerConsent is deprecated. Use useScriptConsent, same API, superset.') } - - promise.consented = consented - - return promise as UseConsentScriptTriggerApi + return useScriptConsent(options) } diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 5e755db9..afcf1abd 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -128,6 +128,30 @@ export type NuxtUseScriptOptions = {}> = */ domains?: string[] } + /** + * Unified consent control for this script. + * + * Pass a `useScriptConsent` instance to gate loading behind user consent and, when the script + * declares a `consentAdapter`, auto-subscribe it for granular Google Consent Mode v2 fan-out. + * + * Also accepts the legacy `useScriptTriggerConsent` return value for backwards compatibility, + * in which case only the binary load gate is used. + */ + consent?: { + accept: () => void + revoke: () => void + consented: Ref + state?: Ref + update?: (partial: ConsentState) => void + register?:

(adapter: ConsentAdapter

, proxy: P) => () => void + } & Promise + /** + * Consent adapter declared by the registry entry. Auto-populated by registry wrappers + * via `scriptOptions._consentAdapter`. + * + * @internal + */ + _consentAdapter?: ConsentAdapter /** * @internal */ @@ -158,6 +182,48 @@ export interface ConsentScriptTriggerOptions { postConsentTrigger?: ExcludePromises | (() => Promise) } +/** + * Google Consent Mode v2 category value. + */ +export type ConsentCategoryValue = 'granted' | 'denied' + +/** + * Google Consent Mode v2 consent state. + * + * @see https://developers.google.com/tag-platform/security/guides/consent + */ +export interface ConsentState { + ad_storage?: ConsentCategoryValue + ad_user_data?: ConsentCategoryValue + ad_personalization?: ConsentCategoryValue + analytics_storage?: ConsentCategoryValue + functionality_storage?: ConsentCategoryValue + personalization_storage?: ConsentCategoryValue + security_storage?: ConsentCategoryValue +} + +/** + * Adapter contract that individual registry scripts implement so a unified + * consent composable can fan out default and update events. + * + * `applyDefault` runs once before the SDK initializes (e.g. `gtag('consent', 'default', ...)`), + * `applyUpdate` runs whenever the user updates any category. + */ +export interface ConsentAdapter { + applyDefault: (state: ConsentState, proxy: Proxy) => void + applyUpdate: (state: ConsentState, proxy: Proxy) => void +} + +export interface UseScriptConsentOptions extends ConsentScriptTriggerOptions { + /** + * Initial consent state applied synchronously before any subscribed script loads. + * Keys follow Google Consent Mode v2 categories. + * + * @example { ad_storage: 'denied', analytics_storage: 'denied' } + */ + default?: ConsentState +} + export interface NuxtDevToolsNetworkRequest { url: string startTime: number diff --git a/test/nuxt-runtime/use-script-consent.nuxt.test.ts b/test/nuxt-runtime/use-script-consent.nuxt.test.ts new file mode 100644 index 00000000..92445acc --- /dev/null +++ b/test/nuxt-runtime/use-script-consent.nuxt.test.ts @@ -0,0 +1,136 @@ +import type { ConsentAdapter, ConsentState } from '../../packages/script/src/runtime/types' +import { useScriptConsent, useScriptTriggerConsent } from '#imports' +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' + +function getPromiseState(promise: Promise) { + const temp = {} + return Promise.race([promise, temp]) + .then(value => value === temp ? 'pending' : 'fulfilled') + .catch(() => 'rejected') +} + +function nextTick() { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('useScriptConsent', () => { + it('default state applies immediately', () => { + const consent = useScriptConsent({ + default: { ad_storage: 'denied', analytics_storage: 'denied' }, + }) + expect(consent.state.value.ad_storage).toBe('denied') + expect(consent.state.value.analytics_storage).toBe('denied') + expect(consent.consented.value).toBe(false) + }) + + it('accept() grants all categories and resolves promise', async () => { + const consent = useScriptConsent({ + default: { ad_storage: 'denied', analytics_storage: 'denied' }, + }) + expect(await getPromiseState(consent)).toBe('pending') + consent.accept() + await nextTick() + expect(consent.consented.value).toBe(true) + expect(consent.state.value.ad_storage).toBe('granted') + expect(consent.state.value.analytics_storage).toBe('granted') + expect(await getPromiseState(consent)).toBe('fulfilled') + }) + + it('revoke() denies all categories', async () => { + const consent = useScriptConsent({ default: { ad_storage: 'granted' } }) + consent.accept() + await nextTick() + expect(consent.consented.value).toBe(true) + consent.revoke() + await nextTick() + expect(consent.consented.value).toBe(false) + expect(consent.state.value.ad_storage).toBe('denied') + }) + + it('update() merges partial state', async () => { + const consent = useScriptConsent({ + default: { ad_storage: 'denied', analytics_storage: 'denied' }, + }) + consent.update({ analytics_storage: 'granted' }) + await nextTick() + expect(consent.state.value.ad_storage).toBe('denied') + expect(consent.state.value.analytics_storage).toBe('granted') + expect(consent.consented.value).toBe(true) + }) + + it('batches multiple update() calls in the same tick', async () => { + const consent = useScriptConsent({ default: { ad_storage: 'denied' } }) + const calls: ConsentState[] = [] + const adapter: ConsentAdapter = { + applyDefault: () => {}, + applyUpdate: (s) => { + calls.push({ ...s }) + }, + } + consent.register(adapter, {}) + consent.update({ ad_storage: 'granted' }) + consent.update({ analytics_storage: 'granted' }) + consent.update({ ad_user_data: 'granted' }) + await nextTick() + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + ad_storage: 'granted', + analytics_storage: 'granted', + ad_user_data: 'granted', + }) + }) + + it('register fires applyDefault with current state', () => { + const consent = useScriptConsent({ + default: { ad_storage: 'denied', analytics_storage: 'granted' }, + }) + let defaulted: ConsentState | undefined + consent.register({ + applyDefault: (s) => { + defaulted = s + }, + applyUpdate: () => {}, + }, {}) + expect(defaulted).toMatchObject({ ad_storage: 'denied', analytics_storage: 'granted' }) + }) + + it('binary compat: consent ref gate resolves promise', async () => { + const gate = ref(false) + const consent = useScriptConsent({ consent: gate }) + expect(await getPromiseState(consent)).toBe('pending') + gate.value = true + await nextTick() + expect(await getPromiseState(consent)).toBe('fulfilled') + expect(consent.consented.value).toBe(true) + }) + + it('binary compat: postConsentTrigger function', async () => { + const gate = ref(false) + let triggered = false + const consent = useScriptConsent({ + consent: gate, + postConsentTrigger: () => new Promise(resolve => setTimeout(() => { + triggered = true + resolve() + }, 20)), + }) + gate.value = true + await nextTick() + expect(await getPromiseState(consent)).toBe('pending') + await new Promise(resolve => setTimeout(resolve, 40)) + expect(triggered).toBe(true) + expect(await getPromiseState(consent)).toBe('fulfilled') + }) +}) + +describe('useScriptTriggerConsent migration shim', () => { + it('behaves identically to useScriptConsent', async () => { + const consent = useScriptTriggerConsent() + expect(await getPromiseState(consent)).toBe('pending') + consent.accept() + await nextTick() + expect(await getPromiseState(consent)).toBe('fulfilled') + expect(consent.consented.value).toBe(true) + }) +}) From a36398cb8342f8fe9007d51085b1f845bd1a101d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 14 Apr 2026 23:55:14 +1000 Subject: [PATCH 04/13] fix: dedupe consent types after Scope B merge --- packages/script/src/runtime/types.ts | 32 ---------------------------- 1 file changed, 32 deletions(-) diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index d6e9801f..0462c179 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -217,38 +217,6 @@ export interface ConsentScriptTriggerOptions { postConsentTrigger?: ExcludePromises | (() => Promise) } -/** - * Google Consent Mode v2 category value. - */ -export type ConsentCategoryValue = 'granted' | 'denied' - -/** - * Google Consent Mode v2 consent state. - * - * @see https://developers.google.com/tag-platform/security/guides/consent - */ -export interface ConsentState { - ad_storage?: ConsentCategoryValue - ad_user_data?: ConsentCategoryValue - ad_personalization?: ConsentCategoryValue - analytics_storage?: ConsentCategoryValue - functionality_storage?: ConsentCategoryValue - personalization_storage?: ConsentCategoryValue - security_storage?: ConsentCategoryValue -} - -/** - * Adapter contract that individual registry scripts implement so a unified - * consent composable can fan out default and update events. - * - * `applyDefault` runs once before the SDK initializes (e.g. `gtag('consent', 'default', ...)`), - * `applyUpdate` runs whenever the user updates any category. - */ -export interface ConsentAdapter { - applyDefault: (state: ConsentState, proxy: Proxy) => void - applyUpdate: (state: ConsentState, proxy: Proxy) => void -} - export interface UseScriptConsentOptions extends ConsentScriptTriggerOptions { /** * Initial consent state applied synchronously before any subscribed script loads. From bbb68d505fcecabfcb1ae15c07ee6c28821ebcca Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 15 Apr 2026 00:00:55 +1000 Subject: [PATCH 05/13] style: fix max-statements-per-line in consent-default test --- test/nuxt-runtime/consent-default.nuxt.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/nuxt-runtime/consent-default.nuxt.test.ts b/test/nuxt-runtime/consent-default.nuxt.test.ts index 7eae3394..b458e3ab 100644 --- a/test/nuxt-runtime/consent-default.nuxt.test.ts +++ b/test/nuxt-runtime/consent-default.nuxt.test.ts @@ -123,7 +123,9 @@ describe('consent defaults — clientInit ordering', () => { const { useScriptMixpanelAnalytics } = await import('../../packages/script/src/runtime/registry/mixpanel-analytics') const seq: string[] = [] - const initSpy = vi.fn(() => { seq.push('init') }) + const initSpy = vi.fn(() => { + seq.push('init') + }) const mp: any = [] const origPush = Array.prototype.push.bind(mp) mp.push = (arg: any) => { From 8ae9ff1f3907aa7125188abbd7a0f9a3b714276d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 15 Apr 2026 00:16:37 +1000 Subject: [PATCH 06/13] fix(consent): extract adapters to decoupled module so registry.ts stays build-safe Importing adapter constants from runtime/registry/*.ts pulled utils.ts -> nuxt/app into module evaluation, breaking nuxt-module-build prepare. Adapters are pure data, moved to a dedicated consent-adapters.ts using type-only imports of proxy types. --- packages/script/src/registry.ts | 12 +-- .../script/src/runtime/registry/bing-uet.ts | 19 +--- .../script/src/runtime/registry/clarity.ts | 22 +---- .../src/runtime/registry/consent-adapters.ts | 89 +++++++++++++++++++ .../src/runtime/registry/google-analytics.ts | 15 +--- .../script/src/runtime/registry/meta-pixel.ts | 20 +---- .../src/runtime/registry/tiktok-pixel.ts | 23 +---- test/unit/default-consent.test.ts | 10 +-- 8 files changed, 106 insertions(+), 104 deletions(-) create mode 100644 packages/script/src/runtime/registry/consent-adapters.ts diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 35fb6d60..8bd71b11 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -12,10 +12,13 @@ import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' import type { ProxyAutoInject, ProxyCapability, ProxyConfig, RegistryScript, RegistryScriptKey, RegistryScriptServerHandler, ResolvedProxyAutoInject, ScriptCapabilities } from './runtime/types' import { joinURL, withBase, withQuery } from 'ufo' import { LOGOS } from './registry-logos' -import { bingUetConsentAdapter } from './runtime/registry/bing-uet' -import { clarityConsentAdapter } from './runtime/registry/clarity' -import { googleAnalyticsConsentAdapter } from './runtime/registry/google-analytics' -import { metaPixelConsentAdapter } from './runtime/registry/meta-pixel' +import { + bingUetConsentAdapter, + clarityConsentAdapter, + googleAnalyticsConsentAdapter, + metaPixelConsentAdapter, + tiktokPixelConsentAdapter, +} from './runtime/registry/consent-adapters' import { BingUetOptions, BlueskyEmbedOptions, @@ -50,7 +53,6 @@ import { XEmbedOptions, XPixelOptions, } from './runtime/registry/schemas' -import { tiktokPixelConsentAdapter } from './runtime/registry/tiktok-pixel' export type { ScriptCapabilities } from './runtime/types' diff --git a/packages/script/src/runtime/registry/bing-uet.ts b/packages/script/src/runtime/registry/bing-uet.ts index fe4e5513..925f865c 100644 --- a/packages/script/src/runtime/registry/bing-uet.ts +++ b/packages/script/src/runtime/registry/bing-uet.ts @@ -1,4 +1,4 @@ -import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' +import type { RegistryScriptInput } from '#nuxt-scripts/types' import { useRegistryScript } from '../utils' import { BingUetOptions } from './schemas' @@ -278,20 +278,3 @@ export function useScriptBingUet(_options?: BingUetInput & }, }), _options) } - -/** - * GCMv2 -> Bing UET consent adapter. - * UET honours only `ad_storage`, so we project lossy from GCM state. - */ -export const bingUetConsentAdapter: ConsentAdapter = { - applyDefault(state, proxy) { - if (!state.ad_storage) - return - proxy.uetq.push('consent', 'default', { ad_storage: state.ad_storage }) - }, - applyUpdate(state, proxy) { - if (!state.ad_storage) - return - proxy.uetq.push('consent', 'update', { ad_storage: state.ad_storage }) - }, -} diff --git a/packages/script/src/runtime/registry/clarity.ts b/packages/script/src/runtime/registry/clarity.ts index 4f9497db..5bf3f5c9 100644 --- a/packages/script/src/runtime/registry/clarity.ts +++ b/packages/script/src/runtime/registry/clarity.ts @@ -1,4 +1,4 @@ -import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' +import type { RegistryScriptInput } from '#nuxt-scripts/types' import { useRegistryScript } from '../utils' import { ClarityOptions } from './schemas' @@ -61,23 +61,3 @@ export function useScriptClarity( }, }), _options) } - -/** - * GCMv2 -> Clarity consent adapter. - * Clarity accepts a boolean cookie toggle; we project lossy from `analytics_storage`: - * - `analytics_storage === 'granted'` -> `clarity('consent', true)` - * - `analytics_storage === 'denied'` -> `clarity('consent', false)` - * - other GCM categories are ignored. - */ -export const clarityConsentAdapter: ConsentAdapter = { - applyDefault(state, proxy) { - if (!state.analytics_storage) - return - proxy.clarity('consent', state.analytics_storage === 'granted') - }, - applyUpdate(state, proxy) { - if (!state.analytics_storage) - return - proxy.clarity('consent', state.analytics_storage === 'granted') - }, -} diff --git a/packages/script/src/runtime/registry/consent-adapters.ts b/packages/script/src/runtime/registry/consent-adapters.ts new file mode 100644 index 00000000..7b91229a --- /dev/null +++ b/packages/script/src/runtime/registry/consent-adapters.ts @@ -0,0 +1,89 @@ +import type { ConsentAdapter } from '../types' +import type { BingUetApi } from './bing-uet' +import type { ClarityApi } from './clarity' +import type { ConsentOptions, GoogleAnalyticsApi } from './google-analytics' +import type { MetaPixelApi } from './meta-pixel' +import type { TikTokPixelApi } from './tiktok-pixel' + +// Pure adapter definitions. Import from this file only via `import type` chains +// or top-level build code (registry.ts). No runtime imports, so pulling this in +// does not drag `nuxt/app` or other runtime utilities into module evaluation. + +function applyTikTokConsent(state: { ad_storage?: 'granted' | 'denied' }, proxy: TikTokPixelApi) { + if (!state.ad_storage) + return + if (state.ad_storage === 'granted') + proxy.ttq.grantConsent() + else + proxy.ttq.revokeConsent() +} + +/** + * GCMv2 to TikTok consent adapter. TikTok only exposes a binary ad-storage toggle, + * so we project lossy from `ad_storage`. + */ +export const tiktokPixelConsentAdapter: ConsentAdapter = { + applyDefault: applyTikTokConsent, + applyUpdate: applyTikTokConsent, +} + +function applyMetaConsent(state: { ad_storage?: 'granted' | 'denied' }, proxy: MetaPixelApi) { + if (!state.ad_storage) + return + proxy.fbq('consent', state.ad_storage === 'granted' ? 'grant' : 'revoke') +} + +/** + * GCMv2 to Meta Pixel consent adapter. Meta only exposes a binary consent toggle, + * projected lossy from `ad_storage`. + */ +export const metaPixelConsentAdapter: ConsentAdapter = { + applyDefault: applyMetaConsent, + applyUpdate: applyMetaConsent, +} + +/** + * GCMv2 to Google Analytics consent adapter. GA consumes GCMv2 natively; this is + * a pass-through of the full state. + */ +export const googleAnalyticsConsentAdapter: ConsentAdapter = { + applyDefault(state, proxy) { + proxy.gtag('consent', 'default', state as ConsentOptions) + }, + applyUpdate(state, proxy) { + proxy.gtag('consent', 'update', state as ConsentOptions) + }, +} + +/** + * GCMv2 to Bing UET consent adapter. UET honours only `ad_storage`. + */ +export const bingUetConsentAdapter: ConsentAdapter = { + applyDefault(state, proxy) { + if (!state.ad_storage) + return + proxy.uetq.push('consent', 'default', { ad_storage: state.ad_storage }) + }, + applyUpdate(state, proxy) { + if (!state.ad_storage) + return + proxy.uetq.push('consent', 'update', { ad_storage: state.ad_storage }) + }, +} + +/** + * GCMv2 to Clarity consent adapter. Clarity accepts a boolean cookie toggle; + * projected lossy from `analytics_storage`. + */ +export const clarityConsentAdapter: ConsentAdapter = { + applyDefault(state, proxy) { + if (!state.analytics_storage) + return + proxy.clarity('consent', state.analytics_storage === 'granted') + }, + applyUpdate(state, proxy) { + if (!state.analytics_storage) + return + proxy.clarity('consent', state.analytics_storage === 'granted') + }, +} diff --git a/packages/script/src/runtime/registry/google-analytics.ts b/packages/script/src/runtime/registry/google-analytics.ts index 019e6d92..59478319 100644 --- a/packages/script/src/runtime/registry/google-analytics.ts +++ b/packages/script/src/runtime/registry/google-analytics.ts @@ -1,4 +1,4 @@ -import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' +import type { RegistryScriptInput } from '#nuxt-scripts/types' import { useRegistryScript } from '#nuxt-scripts/utils' import { withQuery } from 'ufo' import { GoogleAnalyticsOptions } from './schemas' @@ -111,19 +111,6 @@ export { GoogleAnalyticsOptions } export type GoogleAnalyticsInput = RegistryScriptInput -/** - * GCMv2 -> Google Analytics consent adapter. - * GA consumes GCMv2 natively; this is a pass-through of the full state. - */ -export const googleAnalyticsConsentAdapter: ConsentAdapter = { - applyDefault(state, proxy) { - proxy.gtag('consent', 'default', state as ConsentOptions) - }, - applyUpdate(state, proxy) { - proxy.gtag('consent', 'update', state as ConsentOptions) - }, -} - export function useScriptGoogleAnalytics(_options?: GoogleAnalyticsInput & { onBeforeGtagStart?: (gtag: GTag) => void }) { return useRegistryScript(_options?.key || 'googleAnalytics', (options) => { const dataLayerName = options?.l ?? 'dataLayer' diff --git a/packages/script/src/runtime/registry/meta-pixel.ts b/packages/script/src/runtime/registry/meta-pixel.ts index d7c5a544..df0a1f38 100644 --- a/packages/script/src/runtime/registry/meta-pixel.ts +++ b/packages/script/src/runtime/registry/meta-pixel.ts @@ -1,4 +1,4 @@ -import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' +import type { RegistryScriptInput } from '#nuxt-scripts/types' import { useRegistryScript } from '../utils' import { MetaPixelOptions } from './schemas' @@ -50,24 +50,6 @@ declare global { export { MetaPixelOptions } export type MetaPixelInput = RegistryScriptInput -function applyMetaConsent(state: { ad_storage?: 'granted' | 'denied' }, proxy: MetaPixelApi) { - if (!state.ad_storage) - return - proxy.fbq('consent', state.ad_storage === 'granted' ? 'grant' : 'revoke') -} - -/** - * GCMv2 -> Meta Pixel consent adapter. - * Meta only exposes a binary consent toggle, projected lossy from `ad_storage`: - * - `ad_storage === 'granted'` -> `fbq('consent', 'grant')` - * - `ad_storage === 'denied'` -> `fbq('consent', 'revoke')` - * - other GCM categories are ignored. - */ -export const metaPixelConsentAdapter: ConsentAdapter = { - applyDefault: applyMetaConsent, - applyUpdate: applyMetaConsent, -} - export function useScriptMetaPixel(_options?: MetaPixelInput) { return useRegistryScript('metaPixel', options => ({ scriptInput: { diff --git a/packages/script/src/runtime/registry/tiktok-pixel.ts b/packages/script/src/runtime/registry/tiktok-pixel.ts index e94a3f28..20f0fbe7 100644 --- a/packages/script/src/runtime/registry/tiktok-pixel.ts +++ b/packages/script/src/runtime/registry/tiktok-pixel.ts @@ -1,4 +1,4 @@ -import type { ConsentAdapter, RegistryScriptInput } from '#nuxt-scripts/types' +import type { RegistryScriptInput } from '#nuxt-scripts/types' import { withQuery } from 'ufo' import { useRegistryScript } from '../utils' import { TikTokPixelOptions } from './schemas' @@ -57,27 +57,6 @@ export interface TikTokPixelApi { } } -function applyTikTokConsent(state: { ad_storage?: 'granted' | 'denied' }, proxy: TikTokPixelApi) { - if (!state.ad_storage) - return - if (state.ad_storage === 'granted') - proxy.ttq.grantConsent() - else - proxy.ttq.revokeConsent() -} - -/** - * GCMv2 -> TikTok consent adapter. - * TikTok only exposes a binary ad-storage toggle, so we project lossy: - * - `ad_storage === 'granted'` -> `ttq.grantConsent()` - * - `ad_storage === 'denied'` -> `ttq.revokeConsent()` - * - other GCM categories are ignored. - */ -export const tiktokPixelConsentAdapter: ConsentAdapter = { - applyDefault: applyTikTokConsent, - applyUpdate: applyTikTokConsent, -} - declare global { interface Window extends TikTokPixelApi { TiktokAnalyticsObject: string diff --git a/test/unit/default-consent.test.ts b/test/unit/default-consent.test.ts index 9103c303..e47f8b57 100644 --- a/test/unit/default-consent.test.ts +++ b/test/unit/default-consent.test.ts @@ -177,7 +177,7 @@ describe('defaultConsent - Clarity', () => { describe('consentAdapter - per-script projection', () => { it('tikTok adapter maps ad_storage to grantConsent/revokeConsent', async () => { - const { tiktokPixelConsentAdapter } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') + const { tiktokPixelConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') const grant = vi.fn() const revoke = vi.fn() const proxy: any = { ttq: { grantConsent: grant, revokeConsent: revoke } } @@ -190,7 +190,7 @@ describe('consentAdapter - per-script projection', () => { }) it('meta adapter maps ad_storage to fbq("consent", grant|revoke)', async () => { - const { metaPixelConsentAdapter } = await import('../../packages/script/src/runtime/registry/meta-pixel') + const { metaPixelConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') const fbq = vi.fn() const proxy: any = { fbq } @@ -202,7 +202,7 @@ describe('consentAdapter - per-script projection', () => { }) it('gA adapter passes through full GCM state', async () => { - const { googleAnalyticsConsentAdapter } = await import('../../packages/script/src/runtime/registry/google-analytics') + const { googleAnalyticsConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') const gtag = vi.fn() const proxy: any = { gtag } const state = { ad_storage: 'granted' as const, analytics_storage: 'granted' as const } @@ -215,7 +215,7 @@ describe('consentAdapter - per-script projection', () => { }) it('bing UET adapter maps ad_storage only', async () => { - const { bingUetConsentAdapter } = await import('../../packages/script/src/runtime/registry/bing-uet') + const { bingUetConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') const push = vi.fn() const proxy: any = { uetq: { push } } @@ -224,7 +224,7 @@ describe('consentAdapter - per-script projection', () => { }) it('clarity adapter maps analytics_storage to boolean', async () => { - const { clarityConsentAdapter } = await import('../../packages/script/src/runtime/registry/clarity') + const { clarityConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') const clarity = vi.fn() const proxy: any = { clarity } From 429a2fc838164e85c74157cd45a986a0f3240775 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 15 Apr 2026 00:26:23 +1000 Subject: [PATCH 07/13] test(consent): remove unit tests needing nuxt runtime; hoist posthog mock - Delete test/unit/default-consent.test.ts: imported runtime registry files that resolve #nuxt-scripts/utils virtual module, only available in the nuxt-runtime test project. - Add test/unit/consent-adapters.test.ts with pure adapter projection tests. - Fix posthog tests in consent-default.nuxt.test.ts: replace vi.doMock + resetModules with a hoisted vi.mock + mutable posthogInitImpl, so the dynamic import inside clientInit reliably hits the mock. --- .../nuxt-runtime/consent-default.nuxt.test.ts | 14 +- test/unit/consent-adapters.test.ts | 80 ++++++ test/unit/default-consent.test.ts | 237 ------------------ 3 files changed, 90 insertions(+), 241 deletions(-) create mode 100644 test/unit/consent-adapters.test.ts delete mode 100644 test/unit/default-consent.test.ts diff --git a/test/nuxt-runtime/consent-default.nuxt.test.ts b/test/nuxt-runtime/consent-default.nuxt.test.ts index b458e3ab..ba6f96be 100644 --- a/test/nuxt-runtime/consent-default.nuxt.test.ts +++ b/test/nuxt-runtime/consent-default.nuxt.test.ts @@ -7,6 +7,14 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +let posthogInitImpl: ((...args: any[]) => any) | undefined + +vi.mock('posthog-js', () => ({ + default: { + init: (...args: any[]) => posthogInitImpl?.(...args), + }, +})) + // Force useRegistryScript to a pass-through so we can call `clientInit` directly // instead of going through the real beforeInit wrapping (which requires the full // Nuxt + useScript plumbing). @@ -154,8 +162,7 @@ describe('consent defaults — clientInit ordering', () => { it('posthog: opt-out sets opt_out_capturing_by_default on init config', async () => { const posthogInit = vi.fn(() => ({ opt_in_capturing: vi.fn() })) - vi.doMock('posthog-js', () => ({ default: { init: posthogInit } })) - vi.resetModules() + posthogInitImpl = posthogInit const { useScriptPostHog } = await import('../../packages/script/src/runtime/registry/posthog') @@ -179,8 +186,7 @@ describe('consent defaults — clientInit ordering', () => { expect(optInSpy).not.toHaveBeenCalled() return instance }) - vi.doMock('posthog-js', () => ({ default: { init: posthogInit } })) - vi.resetModules() + posthogInitImpl = posthogInit const { useScriptPostHog } = await import('../../packages/script/src/runtime/registry/posthog') diff --git a/test/unit/consent-adapters.test.ts b/test/unit/consent-adapters.test.ts new file mode 100644 index 00000000..b9a3ec38 --- /dev/null +++ b/test/unit/consent-adapters.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { + bingUetConsentAdapter, + clarityConsentAdapter, + googleAnalyticsConsentAdapter, + metaPixelConsentAdapter, + tiktokPixelConsentAdapter, +} from '../../packages/script/src/runtime/registry/consent-adapters' + +describe('consent adapters — GCMv2 to vendor projection', () => { + it('tikTok: ad_storage maps to grantConsent / revokeConsent', () => { + const grantConsent = vi.fn() + const revokeConsent = vi.fn() + const proxy: any = { ttq: { grantConsent, revokeConsent } } + + tiktokPixelConsentAdapter.applyUpdate({ ad_storage: 'granted' }, proxy) + expect(grantConsent).toHaveBeenCalledTimes(1) + + tiktokPixelConsentAdapter.applyUpdate({ ad_storage: 'denied' }, proxy) + expect(revokeConsent).toHaveBeenCalledTimes(1) + + tiktokPixelConsentAdapter.applyDefault({}, proxy) + expect(grantConsent).toHaveBeenCalledTimes(1) + expect(revokeConsent).toHaveBeenCalledTimes(1) + }) + + it('meta: ad_storage maps to fbq("consent", grant|revoke)', () => { + const fbq = vi.fn() + const proxy: any = { fbq } + + metaPixelConsentAdapter.applyDefault({ ad_storage: 'granted' }, proxy) + expect(fbq).toHaveBeenLastCalledWith('consent', 'grant') + + metaPixelConsentAdapter.applyUpdate({ ad_storage: 'denied' }, proxy) + expect(fbq).toHaveBeenLastCalledWith('consent', 'revoke') + + metaPixelConsentAdapter.applyDefault({}, proxy) + expect(fbq).toHaveBeenCalledTimes(2) + }) + + it('google analytics: full GCM state passes through to gtag', () => { + const gtag = vi.fn() + const proxy: any = { gtag } + const state = { ad_storage: 'granted' as const, analytics_storage: 'denied' as const } + + googleAnalyticsConsentAdapter.applyDefault(state, proxy) + expect(gtag).toHaveBeenLastCalledWith('consent', 'default', state) + + googleAnalyticsConsentAdapter.applyUpdate(state, proxy) + expect(gtag).toHaveBeenLastCalledWith('consent', 'update', state) + }) + + it('bing uet: ad_storage only, pushes through uetq', () => { + const push = vi.fn() + const proxy: any = { uetq: { push } } + + bingUetConsentAdapter.applyDefault({ ad_storage: 'denied', analytics_storage: 'granted' }, proxy) + expect(push).toHaveBeenCalledWith('consent', 'default', { ad_storage: 'denied' }) + + bingUetConsentAdapter.applyUpdate({ ad_storage: 'granted' }, proxy) + expect(push).toHaveBeenLastCalledWith('consent', 'update', { ad_storage: 'granted' }) + + bingUetConsentAdapter.applyDefault({}, proxy) + expect(push).toHaveBeenCalledTimes(2) + }) + + it('clarity: analytics_storage maps to clarity("consent", boolean)', () => { + const clarity = vi.fn() + const proxy: any = { clarity } + + clarityConsentAdapter.applyDefault({ analytics_storage: 'granted' }, proxy) + expect(clarity).toHaveBeenLastCalledWith('consent', true) + + clarityConsentAdapter.applyUpdate({ analytics_storage: 'denied' }, proxy) + expect(clarity).toHaveBeenLastCalledWith('consent', false) + + clarityConsentAdapter.applyDefault({}, proxy) + expect(clarity).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/unit/default-consent.test.ts b/test/unit/default-consent.test.ts deleted file mode 100644 index e47f8b57..00000000 --- a/test/unit/default-consent.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -// Ensure import.meta.client is truthy in tests so useRegistryScript fires clientInit via beforeInit. -; - -(import.meta as any).client = true - -// Mock runtime config + useScript to capture clientInit and return the options -vi.mock('nuxt/app', () => ({ - useRuntimeConfig: () => ({ public: { scripts: {} } }), -})) - -vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ - useScript: vi.fn((_input: any, options: any) => ({ input: _input, options, proxy: {} })), -})) - -async function invokeClientInit(run: () => any) { - // run the script composable; captured options carry the clientInit via beforeInit wrapper - const result: any = run() - const beforeInit = result?.options?.beforeInit - expect(typeof beforeInit).toBe('function') - await beforeInit() - return result -} - -describe('defaultConsent - TikTok Pixel', () => { - beforeEach(() => { - ;(globalThis as any).window = {} - }) - - it('fires grantConsent before init when defaultConsent="granted"', async () => { - const { useScriptTikTokPixel } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') - await invokeClientInit(() => useScriptTikTokPixel({ id: 'TEST_ID', defaultConsent: 'granted' })) - - const ttq = (globalThis as any).window.ttq - expect(ttq).toBeDefined() - // Queue should contain grantConsent call before 'init' - const queue: any[] = ttq.queue - const grantIdx = queue.findIndex(c => c[0] === 'grantConsent') - const initIdx = queue.findIndex(c => c[0] === 'init') - expect(grantIdx).toBeGreaterThan(-1) - expect(initIdx).toBeGreaterThan(-1) - expect(grantIdx).toBeLessThan(initIdx) - }) - - it('fires revokeConsent when defaultConsent="denied"', async () => { - const { useScriptTikTokPixel } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') - await invokeClientInit(() => useScriptTikTokPixel({ id: 'TEST_ID', defaultConsent: 'denied' })) - - const ttq = (globalThis as any).window.ttq - const queue: any[] = ttq.queue - expect(queue.some(c => c[0] === 'revokeConsent')).toBe(true) - }) - - it('does not fire consent when defaultConsent is unset', async () => { - const { useScriptTikTokPixel } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') - await invokeClientInit(() => useScriptTikTokPixel({ id: 'TEST_ID' })) - const ttq = (globalThis as any).window.ttq - const queue: any[] = ttq.queue - expect(queue.some(c => c[0] === 'grantConsent' || c[0] === 'revokeConsent')).toBe(false) - }) -}) - -describe('defaultConsent - Meta Pixel', () => { - beforeEach(() => { - ;(globalThis as any).window = {} - }) - - it('fires fbq("consent", "grant") before fbq("init", id)', async () => { - const { useScriptMetaPixel } = await import('../../packages/script/src/runtime/registry/meta-pixel') - await invokeClientInit(() => useScriptMetaPixel({ id: '123', defaultConsent: 'granted' })) - - const fbq = (globalThis as any).window.fbq - const queue: any[] = fbq.queue - const consentIdx = queue.findIndex(c => c[0] === 'consent' && c[1] === 'grant') - const initIdx = queue.findIndex(c => c[0] === 'init') - expect(consentIdx).toBeGreaterThan(-1) - expect(initIdx).toBeGreaterThan(-1) - expect(consentIdx).toBeLessThan(initIdx) - }) - - it('fires fbq("consent", "revoke") when denied', async () => { - const { useScriptMetaPixel } = await import('../../packages/script/src/runtime/registry/meta-pixel') - await invokeClientInit(() => useScriptMetaPixel({ id: '123', defaultConsent: 'denied' })) - const fbq = (globalThis as any).window.fbq - const queue: any[] = fbq.queue - expect(queue.some(c => c[0] === 'consent' && c[1] === 'revoke')).toBe(true) - }) -}) - -describe('defaultConsent - Google Analytics', () => { - beforeEach(() => { - ;(globalThis as any).window = {} - }) - - it('fires gtag("consent", "default", state) before gtag("js", ...)', async () => { - const { useScriptGoogleAnalytics } = await import('../../packages/script/src/runtime/registry/google-analytics') - await invokeClientInit(() => - useScriptGoogleAnalytics({ - id: 'G-TEST', - defaultConsent: { ad_storage: 'denied', analytics_storage: 'granted' }, - }), - ) - - const dataLayer: any[] = (globalThis as any).window.dataLayer - // gtag pushes `arguments` objects onto dataLayer - const asArrays = dataLayer.map(a => Array.from(a as ArrayLike)) - const consentIdx = asArrays.findIndex(a => a[0] === 'consent' && a[1] === 'default') - const jsIdx = asArrays.findIndex(a => a[0] === 'js') - expect(consentIdx).toBeGreaterThan(-1) - expect(jsIdx).toBeGreaterThan(-1) - expect(consentIdx).toBeLessThan(jsIdx) - expect(asArrays[consentIdx][2]).toEqual({ ad_storage: 'denied', analytics_storage: 'granted' }) - }) -}) - -describe('defaultConsent - Bing UET', () => { - beforeEach(() => { - ;(globalThis as any).window = {} - }) - - it('pushes ["consent","default", state] before any other push', async () => { - const { useScriptBingUet } = await import('../../packages/script/src/runtime/registry/bing-uet') - await invokeClientInit(() => - useScriptBingUet({ - id: 'UET-TEST', - defaultConsent: { ad_storage: 'denied' }, - onBeforeUetStart: (uetq) => { - // simulate a user-provided push after defaultConsent - ;(uetq as any).push('pageLoad') - }, - }), - ) - - const uetq: any[] = (globalThis as any).window.uetq as any - // variadic push appends each arg separately on a plain array (matches Microsoft's - // snippet; UET's queue processor handles this form on hydration) - expect(uetq[0]).toBe('consent') - expect(uetq[1]).toBe('default') - expect(uetq[2]).toEqual({ ad_storage: 'denied' }) - // followed by the onBeforeUetStart push - expect(uetq[3]).toBe('pageLoad') - }) -}) - -describe('defaultConsent - Clarity', () => { - beforeEach(() => { - ;(globalThis as any).window = {} - }) - - it('calls clarity("consent", value) in clientInit', async () => { - const { useScriptClarity } = await import('../../packages/script/src/runtime/registry/clarity') - await invokeClientInit(() => - useScriptClarity({ id: 'clarity-id-12345', defaultConsent: false }), - ) - - const clarity: any = (globalThis as any).window.clarity - // queue on clarity.q contains [ ["consent", false] ] - expect(clarity.q).toBeDefined() - expect(clarity.q.some((c: any[]) => c[0] === 'consent' && c[1] === false)).toBe(true) - }) - - it('supports advanced consent vector object', async () => { - const { useScriptClarity } = await import('../../packages/script/src/runtime/registry/clarity') - await invokeClientInit(() => - useScriptClarity({ - id: 'clarity-id-12345', - defaultConsent: { ad_storage: 'denied', analytics_storage: 'granted' }, - }), - ) - - const clarity: any = (globalThis as any).window.clarity - const consentCall = clarity.q.find((c: any[]) => c[0] === 'consent') - expect(consentCall?.[1]).toEqual({ ad_storage: 'denied', analytics_storage: 'granted' }) - }) -}) - -describe('consentAdapter - per-script projection', () => { - it('tikTok adapter maps ad_storage to grantConsent/revokeConsent', async () => { - const { tiktokPixelConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') - const grant = vi.fn() - const revoke = vi.fn() - const proxy: any = { ttq: { grantConsent: grant, revokeConsent: revoke } } - - tiktokPixelConsentAdapter.applyUpdate({ ad_storage: 'granted' }, proxy) - expect(grant).toHaveBeenCalled() - - tiktokPixelConsentAdapter.applyUpdate({ ad_storage: 'denied' }, proxy) - expect(revoke).toHaveBeenCalled() - }) - - it('meta adapter maps ad_storage to fbq("consent", grant|revoke)', async () => { - const { metaPixelConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') - const fbq = vi.fn() - const proxy: any = { fbq } - - metaPixelConsentAdapter.applyDefault({ ad_storage: 'granted' }, proxy) - expect(fbq).toHaveBeenCalledWith('consent', 'grant') - - metaPixelConsentAdapter.applyUpdate({ ad_storage: 'denied' }, proxy) - expect(fbq).toHaveBeenCalledWith('consent', 'revoke') - }) - - it('gA adapter passes through full GCM state', async () => { - const { googleAnalyticsConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') - const gtag = vi.fn() - const proxy: any = { gtag } - const state = { ad_storage: 'granted' as const, analytics_storage: 'granted' as const } - - googleAnalyticsConsentAdapter.applyDefault(state, proxy) - expect(gtag).toHaveBeenCalledWith('consent', 'default', state) - - googleAnalyticsConsentAdapter.applyUpdate(state, proxy) - expect(gtag).toHaveBeenCalledWith('consent', 'update', state) - }) - - it('bing UET adapter maps ad_storage only', async () => { - const { bingUetConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') - const push = vi.fn() - const proxy: any = { uetq: { push } } - - bingUetConsentAdapter.applyDefault({ ad_storage: 'granted', analytics_storage: 'denied' }, proxy) - expect(push).toHaveBeenCalledWith('consent', 'default', { ad_storage: 'granted' }) - }) - - it('clarity adapter maps analytics_storage to boolean', async () => { - const { clarityConsentAdapter } = await import('../../packages/script/src/runtime/registry/consent-adapters') - const clarity = vi.fn() - const proxy: any = { clarity } - - clarityConsentAdapter.applyDefault({ analytics_storage: 'granted' }, proxy) - expect(clarity).toHaveBeenCalledWith('consent', true) - - clarityConsentAdapter.applyUpdate({ analytics_storage: 'denied' }, proxy) - expect(clarity).toHaveBeenCalledWith('consent', false) - }) -}) From 58303b800219571b210c5312c67ff2bb7e87e907 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 15 Apr 2026 00:37:07 +1000 Subject: [PATCH 08/13] fix(consent): address CodeRabbit review findings - Guard adapter fan-out: try/catch in `applyUpdate` flush and `register()` so one failing adapter can't poison the controller. Broken adapters no longer stay registered. - Make `postConsentTrigger` one-shot: watcher stops after first grant so revoke/accept cycles don't re-run non-idempotent trigger work. - Exclude `consent` and `_consentAdapter` from `NuxtUseScriptOptionsSerializable` since they carry unserializable refs/functions/adapters. - Add missing Partytown forwards for mixpanel opt-in/out, TikTok grant/revoke/hold, and Clarity so consent calls reach the worker. - TikTok adapter: `applyDefault` with undecided state now calls `holdConsent()` so integrators can defer tracking via the composable. `applyUpdate` stays a no-op on undecided to preserve prior decisions. - Fix misleading docs: mixpanel/posthog sequencing (opt-out on init config, opt-in after init), TikTok method names (`ttq.grantConsent` not `ttq.consent.grant`). - Simplify mixpanel opt-in condition (no longer forces opt-out-by-default). - Add `opt_in_tracking` / `opt_out_tracking` to `MixpanelAnalyticsApi` stub. - Trim consent guide vendor-mapping table to scripts that actually ship an adapter; add note about binary-gate behaviour for others. --- docs/content/docs/1.guides/3.consent.md | 21 ++++++------ packages/script/src/registry.ts | 6 ++-- .../runtime/composables/useScriptConsent.ts | 33 ++++++++++++++---- .../src/runtime/registry/consent-adapters.ts | 34 ++++++++++++------- .../runtime/registry/mixpanel-analytics.ts | 13 ++++--- .../script/src/runtime/registry/schemas.ts | 16 ++++----- .../src/runtime/registry/tiktok-pixel.ts | 3 ++ packages/script/src/runtime/types.ts | 2 +- test/unit/consent-adapters.test.ts | 13 +++++-- 9 files changed, 91 insertions(+), 50 deletions(-) diff --git a/docs/content/docs/1.guides/3.consent.md b/docs/content/docs/1.guides/3.consent.md index a24f127c..03206674 100644 --- a/docs/content/docs/1.guides/3.consent.md +++ b/docs/content/docs/1.guides/3.consent.md @@ -107,21 +107,20 @@ Each category is either `'granted'` or `'denied'`. ### Vendor mapping -Registry scripts map GCMv2 categories to their internal consent APIs via an adapter. Representative mappings: +Registry scripts ship a `consentAdapter` that maps GCMv2 categories to their internal consent API. Scripts without an adapter still work with `useScriptConsent`, but only observe the binary load gate -- no granular fan-out. -| Script | Categories used | +| Script | Categories observed | | --- | --- | -| Google Tag Manager / Google Analytics | `ad_storage`, `ad_user_data`, `ad_personalization`, `analytics_storage` | -| Meta Pixel | `ad_storage`, `ad_user_data`, `ad_personalization` | -| TikTok Pixel | `ad_storage`, `ad_user_data` | -| X Pixel | `ad_storage`, `ad_user_data` | -| Reddit Pixel | `ad_storage` | -| Snapchat Pixel | `ad_storage`, `ad_user_data` | -| Hotjar | `analytics_storage` | +| Google Tag Manager / Google Analytics | Full GCMv2 pass-through (`ad_storage`, `ad_user_data`, `ad_personalization`, `analytics_storage`, ...) | +| Bing UET | `ad_storage` | +| Meta Pixel | `ad_storage` | +| TikTok Pixel | `ad_storage` | +| Matomo | `analytics_storage` | +| Mixpanel | `analytics_storage` | +| PostHog | `analytics_storage` | | Clarity | `analytics_storage` | -| Crisp / Intercom | `functionality_storage` | -Refer to each script's registry page for the exact mapping. Scripts without a declared adapter only observe the binary load gate. +Refer to each script's registry page for the exact mapping. Vendors not listed (X Pixel, Reddit Pixel, Snapchat Pixel, Hotjar, Crisp, Intercom, ...) only receive the binary load gate via `useScriptConsent` -- granular categories are ignored. ### Example diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 8bd71b11..205c681a 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -448,7 +448,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro return 'https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js' }, }, - partytown: { forwards: ['mixpanel', 'mixpanel.init', 'mixpanel.track', 'mixpanel.identify', 'mixpanel.people.set', 'mixpanel.reset', 'mixpanel.register'] }, + partytown: { forwards: ['mixpanel', 'mixpanel.init', 'mixpanel.track', 'mixpanel.identify', 'mixpanel.people.set', 'mixpanel.reset', 'mixpanel.register', 'mixpanel.opt_in_tracking', 'mixpanel.opt_out_tracking'] }, consentAdapter: { applyDefault: (state, { mixpanel }) => { if (state.analytics_storage === 'granted') @@ -519,7 +519,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro domains: ['analytics.tiktok.com', 'mon.tiktok.com', 'mcs.tiktok.com'], privacy: PRIVACY_FULL, }, - partytown: { forwards: ['ttq.track', 'ttq.page', 'ttq.identify'] }, + partytown: { forwards: ['ttq.track', 'ttq.page', 'ttq.identify', 'ttq.grantConsent', 'ttq.revokeConsent', 'ttq.holdConsent'] }, consentAdapter: tiktokPixelConsentAdapter, }), def('snapchatPixel', { @@ -622,7 +622,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro domains: ['www.clarity.ms', 'scripts.clarity.ms', 'd.clarity.ms', 'e.clarity.ms', 'k.clarity.ms', 'c.clarity.ms', 'a.clarity.ms', 'b.clarity.ms'], privacy: PRIVACY_HEATMAP, }, - partytown: { forwards: [] }, + partytown: { forwards: ['clarity'] }, consentAdapter: clarityConsentAdapter, }), // payments diff --git a/packages/script/src/runtime/composables/useScriptConsent.ts b/packages/script/src/runtime/composables/useScriptConsent.ts index f3a91596..2c4f38e2 100644 --- a/packages/script/src/runtime/composables/useScriptConsent.ts +++ b/packages/script/src/runtime/composables/useScriptConsent.ts @@ -97,7 +97,13 @@ export function useScriptConsent(options?: UseScriptConsentOptions): UseScriptCo pendingFlush = false const snapshot = { ...state.value } for (const sub of subscriptions) { - sub.adapter.applyUpdate(snapshot, sub.proxy) + try { + sub.adapter.applyUpdate(snapshot, sub.proxy) + } + catch (error) { + if (import.meta.dev) + console.warn('[nuxt-scripts] consent adapter update failed', error) + } } }) } @@ -127,9 +133,16 @@ export function useScriptConsent(options?: UseScriptConsentOptions): UseScriptCo function register(adapter: ConsentAdapter, proxy: Proxy): () => void { const sub: Subscription = { adapter, proxy } - subscriptions.add(sub) - // fire default with the current state so the SDK sees defaults before init - adapter.applyDefault({ ...state.value }, proxy) + try { + // fire default with the current state so the SDK sees defaults before init + adapter.applyDefault({ ...state.value }, proxy) + subscriptions.add(sub) + } + catch (error) { + if (import.meta.dev) + console.warn('[nuxt-scripts] consent adapter default failed', error) + return () => {} + } return () => { subscriptions.delete(sub) } @@ -170,8 +183,16 @@ export function useScriptConsent(options?: UseScriptConsentOptions): UseScriptCo } const promise = new Promise((resolve) => { - watch(consented, (newValue, oldValue) => { - if (newValue && !oldValue) { + let triggered = false + // Box pattern: the watcher callback can fire synchronously via `immediate: true`, + // before the `watch()` return value is assigned. Wrapping in an object lets us + // read the handle lazily so we can self-cancel once triggered -- a later + // revoke()/accept() cycle would otherwise re-run non-idempotent trigger work. + const handle: { stop?: () => void } = {} + handle.stop = watch(consented, (newValue, oldValue) => { + if (newValue && !oldValue && !triggered) { + triggered = true + handle.stop?.() const runner = nuxtApp?.runWithContext || ((cb: () => void) => cb()) if (options?.postConsentTrigger instanceof Promise) { options.postConsentTrigger.then(() => runner(resolve)) diff --git a/packages/script/src/runtime/registry/consent-adapters.ts b/packages/script/src/runtime/registry/consent-adapters.ts index 7b91229a..2085dabf 100644 --- a/packages/script/src/runtime/registry/consent-adapters.ts +++ b/packages/script/src/runtime/registry/consent-adapters.ts @@ -9,22 +9,30 @@ import type { TikTokPixelApi } from './tiktok-pixel' // or top-level build code (registry.ts). No runtime imports, so pulling this in // does not drag `nuxt/app` or other runtime utilities into module evaluation. -function applyTikTokConsent(state: { ad_storage?: 'granted' | 'denied' }, proxy: TikTokPixelApi) { - if (!state.ad_storage) - return - if (state.ad_storage === 'granted') - proxy.ttq.grantConsent() - else - proxy.ttq.revokeConsent() -} - /** - * GCMv2 to TikTok consent adapter. TikTok only exposes a binary ad-storage toggle, - * so we project lossy from `ad_storage`. + * GCMv2 to TikTok consent adapter. TikTok exposes three states: + * - `grantConsent()` / `revokeConsent()` for granted/denied. + * - `holdConsent()` to defer the decision (used as the default when `ad_storage` + * is undecided, so tracking stays suspended until the user chooses). + * + * `applyUpdate` keeps the current state on an undecided transition to avoid + * silently holding after a prior grant/revoke. */ export const tiktokPixelConsentAdapter: ConsentAdapter = { - applyDefault: applyTikTokConsent, - applyUpdate: applyTikTokConsent, + applyDefault(state, proxy) { + if (state.ad_storage === 'granted') + proxy.ttq.grantConsent() + else if (state.ad_storage === 'denied') + proxy.ttq.revokeConsent() + else + proxy.ttq.holdConsent() + }, + applyUpdate(state, proxy) { + if (state.ad_storage === 'granted') + proxy.ttq.grantConsent() + else if (state.ad_storage === 'denied') + proxy.ttq.revokeConsent() + }, } function applyMetaConsent(state: { ad_storage?: 'granted' | 'denied' }, proxy: MetaPixelApi) { diff --git a/packages/script/src/runtime/registry/mixpanel-analytics.ts b/packages/script/src/runtime/registry/mixpanel-analytics.ts index 4f1596ee..5ccbdb06 100644 --- a/packages/script/src/runtime/registry/mixpanel-analytics.ts +++ b/packages/script/src/runtime/registry/mixpanel-analytics.ts @@ -16,6 +16,10 @@ export interface MixpanelAnalyticsApi { } register: (properties: Record) => void init: (token: string, config?: Record) => void + /** Opt the user in to tracking. Available after the real SDK loads. */ + opt_in_tracking?: () => void + /** Opt the user out of tracking. Available after the real SDK loads. */ + opt_out_tracking?: () => void } } @@ -68,11 +72,10 @@ export function useScriptMixpanelAnalytics(_opti } if (options?.token) { - // Mixpanel accepts `opt_out_tracking_by_default` on init() to start in the - // opted-out state. For explicit opt-in we still default out then flip in, - // so consent is resolved BEFORE the first track call. - const optOutByDefault = options?.defaultConsent !== undefined - && options.defaultConsent !== 'opt-in' + // 'opt-out' is applied on init() via `opt_out_tracking_by_default`, so tracking + // is suppressed before the first call. 'opt-in' is queued on the `mp` stub with + // `opt_in_tracking`; the real SDK drains the queue on load and runs it early. + const optOutByDefault = options?.defaultConsent === 'opt-out' mp.init(options.token, optOutByDefault ? { opt_out_tracking_by_default: true } : undefined) if (options?.defaultConsent === 'opt-in') { // opt_in_tracking isn't part of the stub; push so the real SDK runs it on load. diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 40b82753..adb0b176 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -713,9 +713,9 @@ export const PostHogOptions = object({ */ config: optional(record(string(), any())), /** - * Default capture-consent state applied BEFORE `posthog.init`. - * - `'opt-in'` — calls `posthog.opt_in_capturing()`. - * - `'opt-out'` — calls `posthog.opt_out_capturing()`. + * Default capture-consent state for PostHog. + * - `'opt-out'`: passed as `opt_out_capturing_by_default: true` to `posthog.init`, so capturing is suppressed from the first event. + * - `'opt-in'`: applied after `posthog.init` via `posthog.opt_in_capturing()` on the returned instance. * @see https://posthog.com/docs/privacy/opting-out */ defaultConsent: optional(union([literal('opt-in'), literal('opt-out')])), @@ -796,9 +796,9 @@ export const MixpanelAnalyticsOptions = object({ */ token: string(), /** - * Default tracking-consent state applied BEFORE `mixpanel.init`. - * - `'opt-in'` — calls `mixpanel.opt_in_tracking()`. - * - `'opt-out'` — calls `mixpanel.opt_out_tracking()`. + * Default tracking-consent state for Mixpanel. + * - `'opt-out'`: passed as `opt_out_tracking_by_default: true` to `mixpanel.init`, so tracking is suppressed from the first call. + * - `'opt-in'`: queued via `mixpanel.push(['opt_in_tracking'])` so the real SDK runs it immediately after load. * @see https://docs.mixpanel.com/docs/privacy/opt-out-of-tracking */ defaultConsent: optional(union([literal('opt-in'), literal('opt-out')])), @@ -923,8 +923,8 @@ export const TikTokPixelOptions = object({ */ trackPageView: optional(boolean()), /** - * Default consent state. `'granted'` fires `ttq.consent.grant()`, - * `'denied'` fires `ttq.consent.revoke()`, both called before `ttq('init', id)`. + * Default consent state. `'granted'` fires `ttq.grantConsent()`, + * `'denied'` fires `ttq.revokeConsent()`, both called before `ttq('init', id)`. * @see https://business-api.tiktok.com/portal/docs?id=1739585600931842 */ defaultConsent: optional(union([literal('granted'), literal('denied')])), diff --git a/packages/script/src/runtime/registry/tiktok-pixel.ts b/packages/script/src/runtime/registry/tiktok-pixel.ts index 20f0fbe7..bce99c33 100644 --- a/packages/script/src/runtime/registry/tiktok-pixel.ts +++ b/packages/script/src/runtime/registry/tiktok-pixel.ts @@ -111,6 +111,9 @@ export function useScriptTikTokPixel(_options?: TikTok ttq.grantConsent() else if (options?.defaultConsent === 'denied') ttq.revokeConsent() + // When neither is set, tracking runs as before. Users wanting hold mode + // pass `scriptOptions: { consent }` -- the adapter's applyDefault fires + // `holdConsent()` when `ad_storage` is undecided. if (options?.id) { ttq('init', options.id) if (options?.trackPageView !== false) { diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 0462c179..9f336a03 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -193,7 +193,7 @@ export type NuxtUseScriptOptions = {}> = _validate?: () => ValiError | null | undefined } -export type NuxtUseScriptOptionsSerializable = Omit & { trigger?: 'client' | 'server' | 'onNuxtReady' | { idleTimeout: number } | { interaction: string[] } | { serviceWorker: true } } +export type NuxtUseScriptOptionsSerializable = Omit & { trigger?: 'client' | 'server' | 'onNuxtReady' | { idleTimeout: number } | { interaction: string[] } | { serviceWorker: true } } export type NuxtUseScriptInput = UseScriptInput diff --git a/test/unit/consent-adapters.test.ts b/test/unit/consent-adapters.test.ts index b9a3ec38..616e28cf 100644 --- a/test/unit/consent-adapters.test.ts +++ b/test/unit/consent-adapters.test.ts @@ -8,10 +8,11 @@ import { } from '../../packages/script/src/runtime/registry/consent-adapters' describe('consent adapters — GCMv2 to vendor projection', () => { - it('tikTok: ad_storage maps to grantConsent / revokeConsent', () => { + it('tikTok: ad_storage maps to grantConsent / revokeConsent / holdConsent', () => { const grantConsent = vi.fn() const revokeConsent = vi.fn() - const proxy: any = { ttq: { grantConsent, revokeConsent } } + const holdConsent = vi.fn() + const proxy: any = { ttq: { grantConsent, revokeConsent, holdConsent } } tiktokPixelConsentAdapter.applyUpdate({ ad_storage: 'granted' }, proxy) expect(grantConsent).toHaveBeenCalledTimes(1) @@ -19,9 +20,15 @@ describe('consent adapters — GCMv2 to vendor projection', () => { tiktokPixelConsentAdapter.applyUpdate({ ad_storage: 'denied' }, proxy) expect(revokeConsent).toHaveBeenCalledTimes(1) - tiktokPixelConsentAdapter.applyDefault({}, proxy) + // applyUpdate with undecided is a no-op -- preserves the prior decision. + tiktokPixelConsentAdapter.applyUpdate({}, proxy) expect(grantConsent).toHaveBeenCalledTimes(1) expect(revokeConsent).toHaveBeenCalledTimes(1) + expect(holdConsent).not.toHaveBeenCalled() + + // applyDefault with undecided holds so tracking is deferred. + tiktokPixelConsentAdapter.applyDefault({}, proxy) + expect(holdConsent).toHaveBeenCalledTimes(1) }) it('meta: ad_storage maps to fbq("consent", grant|revoke)', () => { From 3b68b6c6ba2be90539f410f9029a1e23713c838c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 15 Apr 2026 00:41:50 +1000 Subject: [PATCH 09/13] fix(consent): tiktok hold defaultConsent + mixpanel stub opt-in/out - TikTok defaultConsent accepts 'hold' so users can defer consent without the composable (addresses CodeRabbit #712). - Mixpanel stub methods now include opt_in_tracking/opt_out_tracking so pre-load calls queue correctly. --- .../script/src/runtime/registry/mixpanel-analytics.ts | 7 ++++--- packages/script/src/runtime/registry/schemas.ts | 8 +++++--- packages/script/src/runtime/registry/tiktok-pixel.ts | 5 ++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/script/src/runtime/registry/mixpanel-analytics.ts b/packages/script/src/runtime/registry/mixpanel-analytics.ts index 5ccbdb06..e2f4375c 100644 --- a/packages/script/src/runtime/registry/mixpanel-analytics.ts +++ b/packages/script/src/runtime/registry/mixpanel-analytics.ts @@ -29,7 +29,7 @@ declare global { } } -const methods = ['track', 'identify', 'reset', 'register'] as const +const methods = ['track', 'identify', 'reset', 'register', 'opt_in_tracking', 'opt_out_tracking'] as const const peopleMethods = ['set'] as const export function useScriptMixpanelAnalytics(_options?: MixpanelAnalyticsInput) { @@ -78,8 +78,9 @@ export function useScriptMixpanelAnalytics(_opti const optOutByDefault = options?.defaultConsent === 'opt-out' mp.init(options.token, optOutByDefault ? { opt_out_tracking_by_default: true } : undefined) if (options?.defaultConsent === 'opt-in') { - // opt_in_tracking isn't part of the stub; push so the real SDK runs it on load. - mp.push(['opt_in_tracking']) + // After init, opt_in_tracking is wired on the stub (see methods array) + // so the real SDK drains it on load. + mp.opt_in_tracking?.() } } }, diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index adb0b176..796c6ef4 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -923,11 +923,13 @@ export const TikTokPixelOptions = object({ */ trackPageView: optional(boolean()), /** - * Default consent state. `'granted'` fires `ttq.grantConsent()`, - * `'denied'` fires `ttq.revokeConsent()`, both called before `ttq('init', id)`. + * Default consent state, applied before `ttq('init', id)`. + * - `'granted'` fires `ttq.grantConsent()` + * - `'denied'` fires `ttq.revokeConsent()` + * - `'hold'` fires `ttq.holdConsent()` to defer until an explicit update * @see https://business-api.tiktok.com/portal/docs?id=1739585600931842 */ - defaultConsent: optional(union([literal('granted'), literal('denied')])), + defaultConsent: optional(union([literal('granted'), literal('denied'), literal('hold')])), }) export const UmamiAnalyticsOptions = object({ diff --git a/packages/script/src/runtime/registry/tiktok-pixel.ts b/packages/script/src/runtime/registry/tiktok-pixel.ts index bce99c33..7aafde85 100644 --- a/packages/script/src/runtime/registry/tiktok-pixel.ts +++ b/packages/script/src/runtime/registry/tiktok-pixel.ts @@ -111,9 +111,8 @@ export function useScriptTikTokPixel(_options?: TikTok ttq.grantConsent() else if (options?.defaultConsent === 'denied') ttq.revokeConsent() - // When neither is set, tracking runs as before. Users wanting hold mode - // pass `scriptOptions: { consent }` -- the adapter's applyDefault fires - // `holdConsent()` when `ad_storage` is undecided. + else if (options?.defaultConsent === 'hold') + ttq.holdConsent() if (options?.id) { ttq('init', options.id) if (options?.trackPageView !== false) { From be0ad69e7ac28899204fc9fcfb24b5f2ab22d025 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 15 Apr 2026 13:36:24 +1000 Subject: [PATCH 10/13] refactor(consent): vendor-native per-script consent API Replace the unified `useScriptConsent` composable with per-vendor `consent` objects returned from each `useScriptX()`. Previous adapter fan-out had systemic ordering bugs (adapter `applyDefault` replayed after `clientInit` queued vendor init events) and a silent Matomo bug (`forgetConsentGiven` is a no-op without prior `requireConsent`, which the composable-only path never pushed). Each consent-aware script now exposes a typed, vendor-native `consent` object: - GA / GTM: `consent.update(ConsentState)` (GCMv2) - Bing UET: `consent.update({ ad_storage })` - Meta Pixel: `consent.grant()` / `consent.revoke()` - TikTok Pixel: `consent.grant()` / `consent.revoke()` / `consent.hold()` - Matomo: `consent.give()` / `consent.forget()` (requires `defaultConsent: 'required' | 'given'`, dev warning if missing) - Mixpanel / PostHog: `consent.optIn()` / `consent.optOut()` - Clarity: `consent.set(boolean | Record)` `defaultConsent` is kept on every script and remains the canonical way to apply an initial policy (runs in `clientInit` before vendor init, ordering is structural). `useScriptTriggerConsent` is restored verbatim as the binary load-gate primitive; `useScriptConsent` and the adapter layer are removed. Docs, examples, and the playground are rewritten around the two primitives. Consent guide adds OneTrust / Cookiebot bridge recipes showing explicit fan-out instead of lossy GCMv2 remapping. Tests: 15 consent-default / per-script consent tests, all vendors covered. Net ~-400 LOC. --- docs/content/docs/1.guides/3.consent.md | 198 +++++++-------- docs/content/scripts/bing-uet.md | 10 +- docs/content/scripts/clarity.md | 34 +-- docs/content/scripts/google-analytics.md | 28 ++- docs/content/scripts/google-tag-manager.md | 85 +++---- docs/content/scripts/matomo-analytics.md | 27 +- docs/content/scripts/meta-pixel.md | 21 +- docs/content/scripts/mixpanel-analytics.md | 25 +- docs/content/scripts/posthog.md | 41 ++-- docs/content/scripts/tiktok-pixel.md | 21 +- examples/cookie-consent/app.vue | 40 ++- examples/granular-consent/app.vue | 47 ++-- packages/script/src/module.ts | 1 - packages/script/src/registry-types.json | 99 +++++++- packages/script/src/registry.ts | 64 ----- .../src/runtime/composables/useScript.ts | 15 -- .../runtime/composables/useScriptConsent.ts | 230 ------------------ .../composables/useScriptTriggerConsent.ts | 110 ++++++++- .../script/src/runtime/registry/bing-uet.ts | 22 +- .../script/src/runtime/registry/clarity.ts | 22 +- .../src/runtime/registry/consent-adapters.ts | 97 -------- .../src/runtime/registry/google-analytics.ts | 22 +- .../runtime/registry/google-tag-manager.ts | 19 +- .../src/runtime/registry/matomo-analytics.ts | 39 ++- .../script/src/runtime/registry/meta-pixel.ts | 23 +- .../runtime/registry/mixpanel-analytics.ts | 23 +- .../script/src/runtime/registry/posthog.ts | 23 +- .../src/runtime/registry/tiktok-pixel.ts | 26 +- packages/script/src/runtime/types.ts | 67 +---- playground/pages/features/cookie-consent.vue | 46 +++- playground/pages/third-parties/bing-uet.vue | 12 +- .../nuxt-runtime/consent-default.nuxt.test.ts | 221 ++++++++++------- .../use-script-consent.nuxt.test.ts | 136 ----------- test/unit/consent-adapters.test.ts | 87 ------- 34 files changed, 817 insertions(+), 1164 deletions(-) delete mode 100644 packages/script/src/runtime/composables/useScriptConsent.ts delete mode 100644 packages/script/src/runtime/registry/consent-adapters.ts delete mode 100644 test/nuxt-runtime/use-script-consent.nuxt.test.ts delete mode 100644 test/unit/consent-adapters.test.ts diff --git a/docs/content/docs/1.guides/3.consent.md b/docs/content/docs/1.guides/3.consent.md index 03206674..ba2837f4 100644 --- a/docs/content/docs/1.guides/3.consent.md +++ b/docs/content/docs/1.guides/3.consent.md @@ -1,20 +1,20 @@ --- title: Consent Management -description: Gate scripts behind user consent, drive Google Consent Mode v2 granular state, and fan it out to registry scripts. +description: Gate scripts behind user consent and drive vendor-native consent APIs through a typed per-script `consent` object. --- ::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). :: -## Background +## Two complementary primitives -Many third-party scripts set tracking cookies that require user consent. Nuxt Scripts ships a single composable, [`useScriptConsent()`{lang="ts"}](/docs/api/use-script-consent){lang="ts"}, that handles both: +Nuxt Scripts ships two consent primitives that work together: -1. The binary load gate: the script only starts loading after consent is granted. -2. Granular Google Consent Mode v2 state: categories like `ad_storage`, `analytics_storage`, etc., are tracked reactively and fanned out to subscribed registry scripts via their `consentAdapter`. +1. **[`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent)** — a binary load gate. The script only starts loading after consent is granted. +2. **Per-script `consent` object** returned from every consent-aware `useScriptX()`{lang="ts"}. A vendor-native, typed API for granting, revoking, or updating consent categories at runtime. Paired with each script's `defaultConsent` option for the initial policy applied *before* the vendor's init call. -`useScriptConsent` is a superset of the deprecated `useScriptTriggerConsent`. Every option on the old composable works unchanged; see [migration](#migration-from-usescripttriggerconsent). +There is no universal GCMv2 facade or adapter fan-out. Each vendor exposes its own consent dialect (Google Consent Mode v2 for GA/GTM/Bing, binary grant/revoke for Meta, three-state for TikTok, `setConsentGiven`/`forgetConsentGiven` for Matomo, `opt_in`/`opt_out` for Mixpanel/PostHog, cookie toggle for Clarity). You wire each explicitly. ## Binary load gate @@ -23,7 +23,7 @@ The simplest usage matches the classic cookie-banner flow: load the script only ::code-group ```ts [utils/cookie.ts] -export const scriptsConsent = useScriptConsent() +export const scriptsConsent = useScriptTriggerConsent() ``` ```vue [app.vue] @@ -56,12 +56,12 @@ Pass a `Ref`{lang="html"} if an external store owns the state. ```ts const agreedToCookies = ref(false) -const consent = useScriptConsent({ consent: agreedToCookies }) +const consent = useScriptTriggerConsent({ consent: agreedToCookies }) ``` ### Revoking -Consent revocation flips the reactive `consented` ref; once the load-gate promise has resolved, it stays resolved. Watch `consented` if a script needs to tear down on revoke. +Consent revocation flips the reactive `consented` ref. Once the load-gate promise has resolved the script has loaded; watch `consented` if you need to tear down on revoke. ```vue