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-
typeTtqFns
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+
exportinterfaceTtqInstance{
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+
typeTtqCallable
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+
exporttypeTtq=TtqCallable&TtqInstance&{
112+
/** Resolve the per-pixel method bag for a specific pixel id. */
113+
instance: (id: string)=>TtqInstance
114+
}
115+
79116
exportinterfaceTikTokPixelApi{
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+
typeTtqArray=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-
interfaceWindowextendsTikTokPixelApi{
135+
interfaceWindow{
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+
constTTQ_METHODS=['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie','holdConsent','revokeConsent','grantConsent']
154+
110155
constSHA256_HEX=/^[a-f0-9]{64}$/i
111156

112157
functionwarnUnhashedIdentify(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+
functioncreateTtqAdapter(): Ttq{
174+
constdispatch=(method: string, ...args: any[])=>{
175+
if(import.meta.dev&&method==='identify'&&args[0])
176+
warnUnhashedIdentify(args[0])
177+
return(window.ttqasany)[method]?.(...args)
178+
}
179+
constadapter=((method: string, ...args: any[])=>dispatch(method, ...args))asTtq
180+
for(constmethodof[...TTQ_METHODS,'instance'])
181+
(adapterasany)[method]=(...args: any[])=>dispatch(method, ...args)
182+
returnadapter
183+
}
184+
123185
exportinterfaceTikTokPixelConsent{
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-
constttq: 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-
}asany
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-
constconsentMethods=['grantConsent','revokeConsent','holdConsent']asconst
169-
for(constnameofconsentMethods){
170-
;(ttqasany)[name]=function(...params: any[]){
171-
ttq.queue.push([name, ...params])
217+
constttq=(window.ttq=window.ttq||([]asunknownasTtqArray))
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-
elseif(options?.defaultConsent==='denied')
177-
ttq.revokeConsent()
178-
elseif(options?.defaultConsent==='hold')
179-
ttq.holdConsent()
224+
for(constmethodofttq.methods)
225+
ttq.setAndDefer(ttq,method)
226+
ttq.instance=function(id: string){
227+
constbag=ttq._i?.[id]||[]
228+
for(constmethodofttq.methods)
229+
ttq.setAndDefer(bag,method)
230+
returnbagasunknownasTtqInstance
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]asany)._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+
elseif(options?.defaultConsent==='denied')
246+
ttq.revokeConsent()
247+
elseif(options?.defaultConsent==='hold')
248+
ttq.holdConsent()
249+
if(options?.trackPageView!==false)
250+
ttq.page()
185251
}
186252
},
187253
}),_options)asUseScriptContext<T,TikTokPixelConsent>

0 commit comments

Comments
 (0)