Skip to content

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

@felixgabler

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions