Skip to content

Commit fc3981b

Browse files
fix(googleTagManager): push consent as arguments to dataLayer (#771)
Co-authored-by: motion-work <11538632+motion-work@users.noreply.github.com>
1 parent 727e025 commit fc3981b

2 files changed

Lines changed: 70 additions & 14 deletions

File tree

packages/script/src/runtime/registry/google-tag-manager.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export { GoogleTagManagerOptions }
8181
export type GoogleTagManagerInput = RegistryScriptInput<typeof GoogleTagManagerOptions>
8282

8383
export interface GoogleTagManagerConsent {
84-
/** Push `['consent','update', state]` onto dataLayer with GCMv2 partial state. */
84+
/** Send `gtag('consent','update', state)` so the dataLayer receives consent command (GCMv2 partial state). */
8585
update: (state: ConsentState) => void
8686
}
8787

@@ -97,6 +97,8 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
9797
onBeforeGtmStart?: (gtag: DataLayerPush) => void
9898
},
9999
): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>, GoogleTagManagerConsent> {
100+
const consentDataLayerName = options?.l ?? options?.dataLayer ?? 'dataLayer'
101+
100102
const instance = useRegistryScript<T, typeof GoogleTagManagerOptions>(
101103
options?.key || 'googleTagManager',
102104
(opts) => {
@@ -132,10 +134,13 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
132134
// Initialize dataLayer if it doesn't exist
133135
(window as any)[dataLayerName] = (window as any)[dataLayerName] || []
134136

135-
// Create gtag function
136-
function gtag(...args: any[]) {
137-
// Pushing arguments to dataLayer is necessary for GTM to process events
138-
(window as any)[dataLayerName].push(args)
137+
// Create gtag function (must push the real `arguments` object —
138+
// not a spread array — so GTM processes consent and
139+
// other commands like the official snippet)
140+
function gtag(..._args: any[]) {
141+
// Rest params satisfy TypeScript call sites; gtm expects `arguments` on the queue.
142+
// eslint-disable-next-line prefer-rest-params
143+
(window as any)[dataLayerName].push(arguments)
139144
}
140145

141146
// Assign gtag to window for global access
@@ -176,7 +181,15 @@ export function useScriptGoogleTagManager<T extends GoogleTagManagerApi>(
176181
if (import.meta.client && !typed.consent) {
177182
typed.consent = {
178183
update: (state: ConsentState) => {
179-
;((typed.proxy as unknown as GoogleTagManagerApi).dataLayer as any).push(['consent', 'update', state])
184+
const dl = (window as any)[consentDataLayerName] = (window as any)[consentDataLayerName] || []
185+
// Must push the real `arguments` object — not a
186+
// spread array — so GTM processes consent and
187+
// other commands like the official snippet
188+
;(function (..._args: any[]) {
189+
// Rest params satisfy TypeScript call sites; gtm expects `arguments` on the queue.
190+
// eslint-disable-next-line prefer-rest-params
191+
dl.push(arguments)
192+
})('consent', 'update', state)
180193
},
181194
}
182195
}

test/nuxt-runtime/consent-default.nuxt.test.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ vi.mock('../../packages/script/src/runtime/composables/useScriptEventPage', () =
5151
useScriptEventPage: vi.fn(),
5252
}))
5353

54+
/** `gtag()` queues the Arguments object; `dataLayer.push([...])` uses a real Array. */
55+
function gtmConsentCommandParts(e: any): [string, string, any?] | null {
56+
if (e == null || typeof e !== 'object')
57+
return null
58+
if (!Array.isArray(e) && !('length' in e && typeof (e as any).length === 'number'))
59+
return null
60+
const row = Array.isArray(e) ? e : Array.from(e as ArrayLike<any>)
61+
if (row[0] !== 'consent')
62+
return null
63+
return [row[0] as string, row[1] as string, row[2]]
64+
}
65+
5466
describe('consent defaults — clientInit ordering', () => {
5567
beforeEach(() => {
5668
delete (window as any).dataLayer
@@ -72,13 +84,14 @@ describe('consent defaults — clientInit ordering', () => {
7284
const dl = (window as any).dataLayer as any[]
7385
expect(Array.isArray(dl)).toBe(true)
7486

75-
const consentIdx = dl.findIndex(e => Array.isArray(e) && e[0] === 'consent' && e[1] === 'default')
87+
const consentIdx = dl.findIndex(e => gtmConsentCommandParts(e)?.[1] === 'default')
7688
const startIdx = dl.findIndex(e => e && typeof e === 'object' && !Array.isArray(e) && e.event === 'gtm.js')
7789

7890
expect(consentIdx).toBeGreaterThanOrEqual(0)
7991
expect(startIdx).toBeGreaterThanOrEqual(0)
8092
expect(consentIdx).toBeLessThan(startIdx)
81-
expect(dl[consentIdx][2]).toMatchObject({ analytics_storage: 'denied', ad_storage: 'denied' })
93+
const consentRow = gtmConsentCommandParts(dl[consentIdx])
94+
expect(consentRow?.[2]).toMatchObject({ analytics_storage: 'denied', ad_storage: 'denied' })
8295
})
8396

8497
it('gtm: array form fires multiple ["consent","default",…] entries in input order, all before gtm.js', async () => {
@@ -98,14 +111,14 @@ describe('consent defaults — clientInit ordering', () => {
98111

99112
const consentEntries = dl
100113
.map((e, i) => ({ e, i }))
101-
.filter(({ e }) => Array.isArray(e) && e[0] === 'consent' && e[1] === 'default')
114+
.filter(({ e }) => gtmConsentCommandParts(e)?.[1] === 'default')
102115
const startIdx = dl.findIndex(e => e && typeof e === 'object' && !Array.isArray(e) && e.event === 'gtm.js')
103116

104117
expect(consentEntries).toHaveLength(2)
105118
expect(startIdx).toBeGreaterThanOrEqual(0)
106119
for (const { i } of consentEntries) expect(i).toBeLessThan(startIdx)
107-
expect(consentEntries[0].e[2]).toMatchObject({ analytics_storage: 'denied', region: ['ES', 'US-AK'], wait_for_update: 500 })
108-
expect(consentEntries[1].e[2]).toMatchObject({ ad_storage: 'denied' })
120+
expect(gtmConsentCommandParts(consentEntries[0].e)?.[2]).toMatchObject({ analytics_storage: 'denied', region: ['ES', 'US-AK'], wait_for_update: 500 })
121+
expect(gtmConsentCommandParts(consentEntries[1].e)?.[2]).toMatchObject({ ad_storage: 'denied' })
109122
})
110123

111124
it('gtm: single-entry array is observationally equivalent to a bare object', async () => {
@@ -120,6 +133,14 @@ describe('consent defaults — clientInit ordering', () => {
120133
// Slice up to (but not including) gtm.js — the entry carries a Date.now() timestamp
121134
// that differs between calls. Ordering relative to gtm.js is locked by the sibling test.
122135
return dl.slice(0, startIdx)
136+
// `gtag()` pushes the Arguments object onto dataLayer, so each run yields distinct exotic
137+
// objects. Normalize array-like rows to real arrays so `toEqual` compares values only,
138+
// not Arguments identity or matcher quirks across two separate `clientInit` runs.
139+
.map(e =>
140+
e != null && typeof e === 'object' && !Array.isArray(e) && 'length' in e && typeof (e as any).length === 'number'
141+
? Array.from(e as ArrayLike<any>)
142+
: e,
143+
)
123144
}
124145

125146
const fromObject = runWith({ ad_storage: 'denied' })
@@ -135,13 +156,32 @@ describe('consent defaults — clientInit ordering', () => {
135156
result._opts.clientInit()
136157

137158
const dl = (window as any).dataLayer as any[]
138-
const consentEntries = dl.filter(e => Array.isArray(e) && e[0] === 'consent' && e[1] === 'default')
159+
const consentEntries = dl.filter(e => gtmConsentCommandParts(e)?.[1] === 'default')
139160
const startIdx = dl.findIndex(e => e && typeof e === 'object' && !Array.isArray(e) && e.event === 'gtm.js')
140161

141162
expect(consentEntries).toHaveLength(0)
142163
expect(startIdx).toBeGreaterThanOrEqual(0)
143164
})
144165

166+
it('gtm: defaultConsent via gtag queues Arguments, not a plain Array (gtag.js contract)', async () => {
167+
const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager')
168+
const result: any = useScriptGoogleTagManager({
169+
id: 'GTM-XXXX',
170+
defaultConsent: { analytics_storage: 'denied' },
171+
})
172+
173+
result._opts.clientInit()
174+
175+
const dl = (window as any).dataLayer as any[]
176+
const entry = dl.find(e => gtmConsentCommandParts(e)?.[1] === 'default')
177+
expect(entry).toBeDefined()
178+
expect(Array.isArray(entry)).toBe(false)
179+
const parts = Array.from(entry as ArrayLike<any>)
180+
expect(parts[0]).toBe('consent')
181+
expect(parts[1]).toBe('default')
182+
expect(parts[2]).toMatchObject({ analytics_storage: 'denied' })
183+
})
184+
145185
it('matomo: "required" pushes requireConsent before setSiteId', async () => {
146186
;(window as any)._paq = []
147187
const { useScriptMatomoAnalytics } = await import('../../packages/script/src/runtime/registry/matomo-analytics')
@@ -339,14 +379,17 @@ describe('per-script consent object', () => {
339379
expect(updateArgs?.[2]).toEqual({ ad_storage: 'granted' })
340380
})
341381

342-
it('gtm: consent.update() pushes ["consent","update", state] onto dataLayer', async () => {
382+
it('gtm: consent.update() queues Arguments(consent, update, state) to dataLayer', async () => {
343383
;(window as any).dataLayer = []
344384
const { useScriptGoogleTagManager } = await import('../../packages/script/src/runtime/registry/google-tag-manager')
345385
const result: any = useScriptGoogleTagManager({ id: 'GTM-XXXX' })
346386
result._opts.clientInit()
347387
result.consent.update({ analytics_storage: 'granted' })
348388
const dl = (window as any).dataLayer as any[]
349-
expect(dl).toContainEqual(['consent', 'update', { analytics_storage: 'granted' }])
389+
const entry = dl.find(e => gtmConsentCommandParts(e)?.[1] === 'update')
390+
expect(entry).toBeDefined()
391+
expect(Array.isArray(entry)).toBe(false)
392+
expect(gtmConsentCommandParts(entry)).toEqual(['consent', 'update', { analytics_storage: 'granted' }])
350393
})
351394

352395
it('meta: consent.grant()/revoke() queue fbq(\'consent\', ...) calls', async () => {

0 commit comments

Comments
 (0)