Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,14 @@ export default defineNuxtModule<ModuleOptions>({
: undefined,
} as any

// Build-time constant: `__NUXT_SCRIPTS_DEBUG__` is replaced inline by the
// bundler, so debug branches DCE away in production when `debug: false`.
const debugConst = JSON.stringify(!!config.debug)
nuxt.options.vite ||= {}
nuxt.options.vite.define = { ...nuxt.options.vite.define, __NUXT_SCRIPTS_DEBUG__: debugConst }
nuxt.options.nitro ||= {}
nuxt.options.nitro.replace = { ...nuxt.options.nitro.replace, __NUXT_SCRIPTS_DEBUG__: debugConst }

// Register proxy handler unconditionally. The handler rejects unknown domains
// at runtime, so it's safe to register even when no scripts use proxy.
const scriptsBase = config.prefix || '/_scripts'
Expand Down
7 changes: 7 additions & 0 deletions packages/script/src/runtime/logger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { createConsola } from 'consola'

declare const __NUXT_SCRIPTS_DEBUG__: boolean

const debugEnabled = (typeof __NUXT_SCRIPTS_DEBUG__ !== 'undefined' && __NUXT_SCRIPTS_DEBUG__) || !!import.meta.dev

export const logger = createConsola({
// 4 = debug, 3 = info (consola defaults). Lift the threshold so `logger.debug`
// fires when debug is opted-in at build time or in dev.
level: debugEnabled ? 4 : 3,
defaults: {
tag: 'nuxt-scripts',
},
Expand Down
48 changes: 47 additions & 1 deletion packages/script/src/runtime/registry/google-tag-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import type { ConsentState, NuxtUseScriptOptions, RegistryScriptInput, UseFuncti
import type { GTag } from './google-analytics'
import { withQuery } from 'ufo'
import { useRegistryScript } from '#nuxt-scripts/utils'
import { logger } from '../logger'
import { GoogleTagManagerOptions } from './schemas'

declare const __NUXT_SCRIPTS_DEBUG__: boolean

const log = logger.withTag('gtm')

/**
* Improved DataLayer type that better reflects GTM's capabilities
* Can contain either gtag event parameters or custom data objects
Expand Down Expand Up @@ -85,6 +90,33 @@ export interface GoogleTagManagerConsent {
update: (state: ConsentState) => void
}

const GCM_KEYS = new Set([
'ad_storage',
'ad_user_data',
'ad_personalization',
'analytics_storage',
'functionality_storage',
'personalization_storage',
'security_storage',
'wait_for_update',
'region',
])

function validateConsentKeys(state: ConsentState, source: string) {
for (const k of Object.keys(state)) {
if (!GCM_KEYS.has(k))
log.warn(`${source} contains unknown GCMv2 key "${k}". Expected one of: ${[...GCM_KEYS].join(', ')}.`)
}
}

function hasConsentDefault(dataLayer: DataLayer): boolean {
for (const item of dataLayer) {
if (Array.isArray(item) && item[0] === 'consent' && item[1] === 'default')
return true
}
return false
}

/**
* Hook to use Google Tag Manager in Nuxt applications
*/
Expand Down Expand Up @@ -147,8 +179,14 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
const entries = Array.isArray(opts.defaultConsent)
? opts.defaultConsent
: [opts.defaultConsent]
for (const entry of entries)
const debug = (import.meta.dev || (typeof __NUXT_SCRIPTS_DEBUG__ !== 'undefined' && __NUXT_SCRIPTS_DEBUG__))
for (const entry of entries) {
if (debug) {
validateConsentKeys(entry as ConsentState, 'defaultConsent')
log.debug('consent default', entry)
}
gtag('consent', 'default', entry)
}
}

// Allow custom initialization
Expand All @@ -174,8 +212,16 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(

const typed = instance as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GoogleTagManagerConsent>
if (import.meta.client && !typed.consent) {
const dataLayerName = options?.dataLayer ?? options?.l ?? 'dataLayer'
typed.consent = {
update: (state: ConsentState) => {
if ((import.meta.dev || (typeof __NUXT_SCRIPTS_DEBUG__ !== 'undefined' && __NUXT_SCRIPTS_DEBUG__))) {
validateConsentKeys(state, 'consent.update()')
const dl = (window as any)[dataLayerName] as DataLayer | undefined
if (!dl || !hasConsentDefault(dl))
log.warn('consent.update() called before any consent default was queued. GTM may apply implicit defaults; configure `defaultConsent` to avoid races.')
log.debug('consent update', state)
}
;((typed.proxy as unknown as GoogleTagManagerApi).dataLayer as any).push(['consent', 'update', state])
},
}
Expand Down
16 changes: 15 additions & 1 deletion packages/script/src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import { createError, useRuntimeConfig } from 'nuxt/app'
import { parseQuery, parseURL, withQuery } from 'ufo'
import { parse } from 'valibot'
import { useScript } from './composables/useScript'
import { logger } from './logger'
import { createNpmScriptStub } from './npm-script-stub'

declare const __NUXT_SCRIPTS_DEBUG__: boolean

// Dev-only: stack trace parsing for component location detection (only referenced inside import.meta.dev)
const URL_MATCH_RE = /https?:\/\/[^/]+\/_nuxt\/(.+\.vue)(?:\?[^)]*)?:(\d+):(\d+)/
const URL_PAREN_MATCH_RE = /\(https?:\/\/[^/]+\/_nuxt\/(.+\.vue)(?:\?[^)]*)?:(\d+):(\d+)\)/
Expand Down Expand Up @@ -173,5 +176,16 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
options.clientInit?.()
}
}
return useScript<T>(scriptInput, scriptOptions as NuxtUseScriptOptions<T>)
const instance = useScript<T>(scriptInput, scriptOptions as NuxtUseScriptOptions<T>)

if (import.meta.client && (import.meta.dev || (typeof __NUXT_SCRIPTS_DEBUG__ !== 'undefined' && __NUXT_SCRIPTS_DEBUG__))) {
const log = logger.withTag(String(registryKey))
const src = (scriptInput as any)?.src
const trigger = (scriptOptions as any)?.trigger
log.debug('registered', { src, trigger: typeof trigger === 'object' ? JSON.stringify(trigger) : trigger })
instance.onLoaded?.(() => log.debug('loaded', { src }))
instance.onError?.(() => log.warn('errored loading', { src }))
}

return instance
}
84 changes: 84 additions & 0 deletions test/nuxt-runtime/consent-default.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

// Enable the build-time debug constant in tests; in real builds this is replaced
// inline by Vite/Nitro via `define` injected from the module setup.
Object.assign(globalThis, { __NUXT_SCRIPTS_DEBUG__: true })

let posthogInitImpl: ((...args: any[]) => any) | undefined

vi.mock('posthog-js', () => ({
Expand Down Expand Up @@ -51,6 +55,19 @@ vi.mock('../../packages/script/src/runtime/composables/useScriptEventPage', () =
useScriptEventPage: vi.fn(),
}))

// Stub the runtime logger so consent debug/warn calls land on plain spies
// (consola's `withTag` returns a new instance, so we keep `withTag` returning self).
vi.mock('../../packages/script/src/runtime/logger', () => {
const log: any = {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
withTag: () => log,
}
return { logger: log }
})

describe('consent defaults β€” clientInit ordering', () => {
beforeEach(() => {
delete (window as any).dataLayer
Expand Down Expand Up @@ -349,6 +366,73 @@ describe('per-script consent object', () => {
expect(dl).toContainEqual(['consent', 'update', { analytics_storage: 'granted' }])
})

it('gtm: warns when consent.update() runs before any consent default is queued', async () => {
;(window as any).dataLayer = []
const { logger } = await import('../../packages/script/src/runtime/logger') as any
logger.warn.mockClear()
const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager')
const result: any = useScriptGoogleTagManager({ id: 'GTM-XXXX' })
// Note: we deliberately skip clientInit() so no default is queued
result.consent.update({ analytics_storage: 'granted' })
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('called before any consent default was queued'))
})

it('gtm: does NOT warn when consent.update() follows a queued default', async () => {
;(window as any).dataLayer = []
const { logger } = await import('../../packages/script/src/runtime/logger') as any
logger.warn.mockClear()
const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager')
const result: any = useScriptGoogleTagManager({
id: 'GTM-XXXX',
defaultConsent: { analytics_storage: 'denied' },
})
result._opts.clientInit()
result.consent.update({ analytics_storage: 'granted' })
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('called before any consent default was queued'))
})

it('gtm: validateConsentKeys warns on unknown GCMv2 keys in defaultConsent', async () => {
;(window as any).dataLayer = []
const { logger } = await import('../../packages/script/src/runtime/logger') as any
logger.warn.mockClear()
const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager')
const result: any = useScriptGoogleTagManager({
id: 'GTM-XXXX',
defaultConsent: { analytics_storages: 'denied' } as any,
})
result._opts.clientInit()
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('analytics_storages'))
})

it('gtm: validateConsentKeys warns on unknown GCMv2 keys in consent.update()', async () => {
;(window as any).dataLayer = []
const { logger } = await import('../../packages/script/src/runtime/logger') as any
logger.warn.mockClear()
const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager')
const result: any = useScriptGoogleTagManager({
id: 'GTM-XXXX',
defaultConsent: { analytics_storage: 'denied' },
})
result._opts.clientInit()
result.consent.update({ ad_storag: 'granted' } as any)
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ad_storag'))
})

it('gtm: emits logger.debug traces for default and update when debug is enabled', async () => {
;(window as any).dataLayer = []
const { logger } = await import('../../packages/script/src/runtime/logger') as any
logger.debug.mockClear()
const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager')
const result: any = useScriptGoogleTagManager({
id: 'GTM-XXXX',
defaultConsent: { analytics_storage: 'denied' },
})
result._opts.clientInit()
result.consent.update({ analytics_storage: 'granted' })
expect(logger.debug).toHaveBeenCalledWith('consent default', expect.objectContaining({ analytics_storage: 'denied' }))
expect(logger.debug).toHaveBeenCalledWith('consent update', expect.objectContaining({ analytics_storage: 'granted' }))
})

it('meta: consent.grant()/revoke() queue fbq(\'consent\', ...) calls', async () => {
const { useScriptMetaPixel } = await import('../../packages/script/src/runtime/registry/meta-pixel')
const result: any = useScriptMetaPixel({ id: '123' })
Expand Down
Loading