Environment
@nuxt/scripts 1.1.0 (also reproduced on main @ 9e57294)
- Registry composable:
useScriptTikTokPixel
Summary
useScriptTikTokPixel initialises window.ttq with the Facebook Pixel (fbq) snippet protocol, but TikTok's events.js (the script loaded via ?sdkid=<id>&lib=ttq) only consumes TikTok's array-based snippet protocol. The two are incompatible, so events.js never drains the queued calls and no browser Pixel events are ever sent (page, track, identify). Server-side Events API and TikTok's own auto-EngagedSession are unaffected, which makes the gap easy to miss in dashboards.
Root cause
clientInit builds ttq as a callable that mirrors the Meta fbq stub (tiktok-pixel.ts#L151-L185):
const ttq = window.ttq = function (...params) {
if (ttq.callMethod) ttq.callMethod(...params)
else ttq.queue.push(params)
}
ttq.push = ttq
ttq.queue = []
// ...
ttq('init', options.id)
ttq('page')
This is the canonical Facebook pattern (fbq.callMethod / fbq.queue / fbq('init', id)). TikTok's loader expects a different shape. TikTok's official base code declares ttq as an array and defers methods onto it:
const ttq = w[t] = w[t] || []
ttq.methods = ['page', 'track', 'identify', /* ... */ 'grantConsent', 'revokeConsent', 'holdConsent']
ttq.setAndDefer = function (target, method) {
target[method] = function (...args) { target.push([method, ...args]) }
}
ttq.methods.forEach(m => ttq.setAndDefer(ttq, m))
ttq.instance = function (id) { /* per-pixel method bag */ }
// per-pixel scaffolding read by events.js:
ttq._i = { [id]: [] }; ttq._i[id]._u = '<events.js url>'
ttq._t = { [id]: Date.now() }
ttq._o = { [id]: {} }
ttq.page()
When events.js loads, it reads window.ttq as an array, looks up ttq._i[sdkid], and replays the queued [method, ...args] tuples. It never assigns ttq.callMethod and never reads ttq.queue. With the current fbq-shaped stub:
- every
ttq('init') / ttq('page') / ttq('track', ...) call falls into the else branch and is pushed onto ttq.queue,
events.js loads successfully but finds a function instead of the array protocol, so it never binds callMethod and never drains ttq.queue,
- the queue grows unbounded and nothing is ever delivered.
Reproduction
- Register the pixel:
useScriptTikTokPixel({ id: '<PIXEL_ID>' }).
- Let
events.js load and fire page / track.
- Observe in the browser: no request to
https://analytics.tiktok.com/api/v2/pixel is made, and window.ttq.queue keeps growing (entries like ['init', id], ['page'], ['grantConsent']).
Evidence
On a deployed site with a live pixel:
window.ttq.queue stayed populated (e.g. [['holdConsent'], ['init', id], ['grantConsent'], ['page']]) and never drained, several seconds after events.js finished loading.
window.ttq.callMethod was never defined after load (the bridge events.js is expected to install never appears).
- Manually re-initialising
window.ttq with TikTok's array base code, then calling ttq.instance('<PIXEL_ID>').page(), immediately fired a real …/api/v2/pixel beacon, confirming events.js was loaded and functional but waiting for the array protocol.
- Server-side Events API (separate
business-api.tiktok.com channel) and TikTok's auto EngagedSession event both continued to work, which is why the missing browser events are easy to overlook.
Proposed fix
Replace the fbq-shaped clientInit with TikTok's official array base code (ttq.methods + ttq.setAndDefer + the ttq._i / ttq._t / ttq._o per-pixel scaffolding), driving defaultConsent and trackPageView through the deferred methods.
There is one API decision worth surfacing, because TikTok's protocol makes ttq an array rather than a callable:
- Option A (breaking): expose the array directly. Consumers call
ttq.page() / ttq.track(event, props, opts) instead of ttq('page') / ttq('track', ...). This matches TikTok's documented usage exactly and keeps the integration thin, but changes the public ttq signature (and the fixture/docs/types).
- Option B (non-breaking): keep the callable facade. Have
use() return a thin adapter (method, ...args) => window.ttq[method](...args) with the deferred methods copied onto it, so existing ttq('page') / ttq('track', ...) call sites keep working while the underlying window.ttq is the real TikTok array that events.js drains.
Happy to send a PR for whichever direction you prefer. I have a working implementation of the array protocol verified against a live pixel.
Environment
@nuxt/scripts1.1.0(also reproduced onmain@9e57294)useScriptTikTokPixelSummary
useScriptTikTokPixelinitialiseswindow.ttqwith the Facebook Pixel (fbq) snippet protocol, but TikTok'sevents.js(the script loaded via?sdkid=<id>&lib=ttq) only consumes TikTok's array-based snippet protocol. The two are incompatible, soevents.jsnever drains the queued calls and no browser Pixel events are ever sent (page,track,identify). Server-side Events API and TikTok's own auto-EngagedSessionare unaffected, which makes the gap easy to miss in dashboards.Root cause
clientInitbuildsttqas a callable that mirrors the Metafbqstub (tiktok-pixel.ts#L151-L185):This is the canonical Facebook pattern (
fbq.callMethod/fbq.queue/fbq('init', id)). TikTok's loader expects a different shape. TikTok's official base code declaresttqas an array and defers methods onto it:When
events.jsloads, it readswindow.ttqas an array, looks upttq._i[sdkid], and replays the queued[method, ...args]tuples. It never assignsttq.callMethodand never readsttq.queue. With the currentfbq-shaped stub:ttq('init')/ttq('page')/ttq('track', ...)call falls into theelsebranch and is pushed ontottq.queue,events.jsloads successfully but finds a function instead of the array protocol, so it never bindscallMethodand never drainsttq.queue,Reproduction
useScriptTikTokPixel({ id: '<PIXEL_ID>' }).events.jsload and firepage/track.https://analytics.tiktok.com/api/v2/pixelis made, andwindow.ttq.queuekeeps growing (entries like['init', id],['page'],['grantConsent']).Evidence
On a deployed site with a live pixel:
window.ttq.queuestayed populated (e.g.[['holdConsent'], ['init', id], ['grantConsent'], ['page']]) and never drained, several seconds afterevents.jsfinished loading.window.ttq.callMethodwas never defined after load (the bridgeevents.jsis expected to install never appears).window.ttqwith TikTok's array base code, then callingttq.instance('<PIXEL_ID>').page(), immediately fired a real…/api/v2/pixelbeacon, confirmingevents.jswas loaded and functional but waiting for the array protocol.business-api.tiktok.comchannel) and TikTok's autoEngagedSessionevent both continued to work, which is why the missing browser events are easy to overlook.Proposed fix
Replace the
fbq-shapedclientInitwith TikTok's official array base code (ttq.methods+ttq.setAndDefer+ thettq._i/ttq._t/ttq._oper-pixel scaffolding), drivingdefaultConsentandtrackPageViewthrough the deferred methods.There is one API decision worth surfacing, because TikTok's protocol makes
ttqan array rather than a callable:ttq.page()/ttq.track(event, props, opts)instead ofttq('page')/ttq('track', ...). This matches TikTok's documented usage exactly and keeps the integration thin, but changes the publicttqsignature (and the fixture/docs/types).use()return a thin adapter(method, ...args) => window.ttq[method](...args)with the deferred methods copied onto it, so existingttq('page')/ttq('track', ...)call sites keep working while the underlyingwindow.ttqis the real TikTok array thatevents.jsdrains.Happy to send a PR for whichever direction you prefer. I have a working implementation of the array protocol verified against a live pixel.