Skip to content

useScriptTikTokPixel sends zero browser events: clientInit uses the Facebook fbq snippet protocol, not TikTok's #785

Description

@felixgabler

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

  1. Register the pixel: useScriptTikTokPixel({ id: '<PIXEL_ID>' }).
  2. Let events.js load and fire page / track.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions