Skip to content

Commit f46ccb6

Browse files
authored
fix(tiktok-pixel): use TikTok's array snippet protocol (#786)
1 parent 0829ee0 commit f46ccb6

12 files changed

Lines changed: 431 additions & 57 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"dev": "nuxt dev playground",
2020
"dev:ssl": "nuxt dev playground --https",
2121
"dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures",
22-
"prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics",
22+
"prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/tiktok-pixel && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics",
2323
"typecheck": "nuxt typecheck",
2424
"release": "pnpm build && bumpp -r --output=CHANGELOG.md",
2525
"lint": "eslint .",

packages/script/src/registry-types.json

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,14 +1015,29 @@
10151015
"code": "interface TrackOptions {\n /** Used to deduplicate events sent from both the browser Pixel and the server-side Events API. */\n event_id?: string\n /**\n * Sandbox test-event identifier. When set, events route to TikTok's Test Events panel\n * without affecting production reporting.\n */\n test_event_code?: string\n [key: string]: any\n}"
10161016
},
10171017
{
1018-
"name": "TtqFns",
1018+
"name": "TtqInstance",
1019+
"kind": "interface",
1020+
"code": "export interface TtqInstance {\n /** Track a page view. */\n page: () => void\n /** Track a standard or custom conversion event. */\n track: (event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void\n /** Associate advanced-matching identifiers with the current user. */\n identify: (properties: IdentifyProperties) => void\n /** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */\n grantConsent: () => void\n /** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */\n revokeConsent: () => void\n /** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */\n holdConsent: () => void\n enableCookie: () => void\n disableCookie: () => void\n}"
1021+
},
1022+
{
1023+
"name": "TtqCallable",
10191024
"kind": "type",
1020-
"code": "type TtqFns\n = ((cmd: 'track', event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void)\n & ((cmd: 'page') => void)\n & ((cmd: 'identify', properties: IdentifyProperties) => void)\n & ((cmd: (string & {}), ...args: any[]) => void)"
1025+
"code": "type TtqCallable\n = ((cmd: 'track', event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void)\n & ((cmd: 'page') => void)\n & ((cmd: 'identify', properties: IdentifyProperties) => void)\n & ((cmd: (string & {}), ...args: any[]) => void)"
1026+
},
1027+
{
1028+
"name": "Ttq",
1029+
"kind": "type",
1030+
"code": "export type Ttq = TtqCallable & TtqInstance & {\n /** Resolve the per-pixel method bag for a specific pixel id. */\n instance: (id: string) => TtqInstance\n}"
10211031
},
10221032
{
10231033
"name": "TikTokPixelApi",
10241034
"kind": "interface",
1025-
"code": "export interface TikTokPixelApi {\n ttq: TtqFns & {\n push: TtqFns\n loaded: boolean\n queue: any[]\n /** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */\n grantConsent: () => void\n /** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */\n revokeConsent: () => void\n /** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */\n holdConsent: () => void\n }\n}"
1035+
"code": "export interface TikTokPixelApi {\n ttq: Ttq\n}"
1036+
},
1037+
{
1038+
"name": "TtqArray",
1039+
"kind": "type",
1040+
"code": "type TtqArray = TtqInstance & Array<any[]> & {\n methods: string[]\n setAndDefer: (target: any, method: string) => void\n instance: (id: string) => TtqInstance\n _i?: Record<string, any[]>\n _t?: Record<string, number>\n _o?: Record<string, any>\n}"
10261041
},
10271042
{
10281043
"name": "TikTokPixelOptions",

packages/script/src/runtime/registry/tiktok-pixel.ts

Lines changed: 111 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -70,28 +70,70 @@ interface TrackOptions {
7070
[key: string]: any
7171
}
7272

73-
type TtqFns
73+
/**
74+
* The deferred methods TikTok's base code installs on `ttq`. Calling one before
75+
* `events.js` loads pushes a `[method, ...args]` tuple onto the queue; afterwards
76+
* `events.js` rebinds them to live implementations.
77+
*/
78+
export interface TtqInstance {
79+
/** Track a page view. */
80+
page: () => void
81+
/** Track a standard or custom conversion event. */
82+
track: (event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void
83+
/** Associate advanced-matching identifiers with the current user. */
84+
identify: (properties: IdentifyProperties) => void
85+
/** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */
86+
grantConsent: () => void
87+
/** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */
88+
revokeConsent: () => void
89+
/** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */
90+
holdConsent: () => void
91+
enableCookie: () => void
92+
disableCookie: () => void
93+
}
94+
95+
/**
96+
* Legacy callable signature. Pre-1.2 `useScriptTikTokPixel` exposed `ttq` as an
97+
* `fbq`-style callable (`ttq('page')`, `ttq('track', …)`); the adapter keeps it
98+
* working alongside the method form.
99+
*/
100+
type TtqCallable
74101
= ((cmd: 'track', event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void)
75102
& ((cmd: 'page') => void)
76103
& ((cmd: 'identify', properties: IdentifyProperties) => void)
77104
& ((cmd: (string & {}), ...args: any[]) => void)
78105

106+
/**
107+
* The public `ttq` returned by the composable: a callable adapter (legacy form)
108+
* that also carries the deferred methods (`ttq.page()`, `ttq.track(…)`). The
109+
* adapter forwards to `window.ttq`, which is TikTok's real array protocol.
110+
*/
111+
export type Ttq = TtqCallable & TtqInstance & {
112+
/** Resolve the per-pixel method bag for a specific pixel id. */
113+
instance: (id: string) => TtqInstance
114+
}
115+
79116
export interface TikTokPixelApi {
80-
ttq: TtqFns & {
81-
push: TtqFns
82-
loaded: boolean
83-
queue: any[]
84-
/** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */
85-
grantConsent: () => void
86-
/** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */
87-
revokeConsent: () => void
88-
/** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */
89-
holdConsent: () => void
90-
}
117+
ttq: Ttq
118+
}
119+
120+
/**
121+
* TikTok's `window.ttq` is an Array, not a callable: `events.js` reads it as a
122+
* queue of `[method, ...args]` tuples and replays them, with the deferred
123+
* methods installed as own properties on the array.
124+
*/
125+
type TtqArray = TtqInstance & Array<any[]> & {
126+
methods: string[]
127+
setAndDefer: (target: any, method: string) => void
128+
instance: (id: string) => TtqInstance
129+
_i?: Record<string, any[]>
130+
_t?: Record<string, number>
131+
_o?: Record<string, any>
91132
}
92133

93134
declare global {
94-
interface Window extends TikTokPixelApi {
135+
interface Window {
136+
ttq: TtqArray
95137
TiktokAnalyticsObject: string
96138
}
97139
}
@@ -107,6 +149,9 @@ export function tiktokPixelSrc(region?: 'global' | 'us'): string {
107149
: 'https://analytics.tiktok.com/i18n/pixel/events.js'
108150
}
109151

152+
// The deferred-method names TikTok's base code defers onto `ttq`.
153+
const TTQ_METHODS = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie', 'holdConsent', 'revokeConsent', 'grantConsent']
154+
110155
const SHA256_HEX = /^[a-f0-9]{64}$/i
111156

112157
function warnUnhashedIdentify(props: Record<string, unknown>): void {
@@ -120,6 +165,23 @@ function warnUnhashedIdentify(props: Record<string, unknown>): void {
120165
}
121166
}
122167

168+
/**
169+
* Build the callable adapter returned to consumers. It dispatches to the live
170+
* `window.ttq` array on every call, so it tracks `events.js` rebinding the
171+
* deferred methods. Supports both `ttq('page')` (legacy) and `ttq.page()`.
172+
*/
173+
function createTtqAdapter(): Ttq {
174+
const dispatch = (method: string, ...args: any[]) => {
175+
if (import.meta.dev && method === 'identify' && args[0])
176+
warnUnhashedIdentify(args[0])
177+
return (window.ttq as any)[method]?.(...args)
178+
}
179+
const adapter = ((method: string, ...args: any[]) => dispatch(method, ...args)) as Ttq
180+
for (const method of [...TTQ_METHODS, 'instance'])
181+
(adapter as any)[method] = (...args: any[]) => dispatch(method, ...args)
182+
return adapter
183+
}
184+
123185
export interface TikTokPixelConsent {
124186
/** Call `ttq.grantConsent()`. */
125187
grant: () => void
@@ -141,47 +203,51 @@ export function useScriptTikTokPixel<T extends TikTokPixelApi>(_options?: TikTok
141203
schema: import.meta.dev ? TikTokPixelOptions : undefined,
142204
scriptOptions: {
143205
use() {
144-
return { ttq: window.ttq }
206+
return { ttq: createTtqAdapter() }
145207
},
146208
},
209+
// TikTok's `events.js` consumes the official array-based snippet protocol:
210+
// `window.ttq` is an Array of `[method, ...args]` tuples that `events.js`
211+
// drains once loaded. (It does NOT understand the Facebook `fbq` callable
212+
// protocol — using that shape silently drops every browser Pixel event.)
147213
clientInit: import.meta.server
148214
? undefined
149215
: () => {
150216
window.TiktokAnalyticsObject = 'ttq'
151-
const ttq: TikTokPixelApi['ttq'] = window.ttq = function (...params: any[]) {
152-
if (import.meta.dev && params[0] === 'identify' && params[1])
153-
warnUnhashedIdentify(params[1])
154-
// @ts-expect-error untyped
155-
if (ttq.callMethod) {
156-
// @ts-expect-error untyped
157-
ttq.callMethod(...params)
158-
}
159-
else {
160-
ttq.queue.push(params)
161-
}
162-
} as any
163-
ttq.push = ttq
164-
ttq.loaded = true
165-
ttq.queue = []
166-
// Queue consent stubs so pre-load `ttq.grantConsent()` / `ttq.revokeConsent()` work.
167-
// The real events.js replaces these with live bindings once loaded.
168-
const consentMethods = ['grantConsent', 'revokeConsent', 'holdConsent'] as const
169-
for (const name of consentMethods) {
170-
;(ttq as any)[name] = function (...params: any[]) {
171-
ttq.queue.push([name, ...params])
217+
const ttq = (window.ttq = window.ttq || ([] as unknown as TtqArray))
218+
ttq.methods = TTQ_METHODS
219+
ttq.setAndDefer = function (target: any, method: string) {
220+
target[method] = function (...args: any[]) {
221+
target.push([method, ...args])
172222
}
173223
}
174-
if (options?.defaultConsent === 'granted')
175-
ttq.grantConsent()
176-
else if (options?.defaultConsent === 'denied')
177-
ttq.revokeConsent()
178-
else if (options?.defaultConsent === 'hold')
179-
ttq.holdConsent()
224+
for (const method of ttq.methods)
225+
ttq.setAndDefer(ttq, method)
226+
ttq.instance = function (id: string) {
227+
const bag = ttq._i?.[id] || []
228+
for (const method of ttq.methods)
229+
ttq.setAndDefer(bag, method)
230+
return bag as unknown as TtqInstance
231+
}
180232
if (options?.id) {
181-
ttq('init', options.id)
182-
if (options?.trackPageView !== false) {
183-
ttq('page')
184-
}
233+
// Per-pixel scaffolding read by `events.js` (equivalent to the base
234+
// code's `ttq.load(id)`, minus the <script> insertion that the
235+
// registry script tag already handles).
236+
ttq._i = ttq._i || {}
237+
ttq._i[options.id] = ttq._i[options.id] || []
238+
;(ttq._i[options.id] as any)._u = tiktokPixelSrc(options?.region)
239+
ttq._t = ttq._t || {}
240+
ttq._t[options.id] = Date.now()
241+
ttq._o = ttq._o || {}
242+
ttq._o[options.id] = {}
243+
if (options?.defaultConsent === 'granted')
244+
ttq.grantConsent()
245+
else if (options?.defaultConsent === 'denied')
246+
ttq.revokeConsent()
247+
else if (options?.defaultConsent === 'hold')
248+
ttq.holdConsent()
249+
if (options?.trackPageView !== false)
250+
ttq.page()
185251
}
186252
},
187253
}), _options) as UseScriptContext<T, TikTokPixelConsent>

0 commit comments

Comments
 (0)