diff --git a/package.json b/package.json index f53908e1..14898eca 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev": "nuxt dev playground", "dev:ssl": "nuxt dev playground --https", "dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures", - "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", + "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", "typecheck": "nuxt typecheck", "release": "pnpm build && bumpp -r --output=CHANGELOG.md", "lint": "eslint .", diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 27bc4f7e..99d35cc9 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1015,14 +1015,29 @@ "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}" }, { - "name": "TtqFns", + "name": "TtqInstance", + "kind": "interface", + "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}" + }, + { + "name": "TtqCallable", "kind": "type", - "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)" + "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)" + }, + { + "name": "Ttq", + "kind": "type", + "code": "export type Ttq = TtqCallable & TtqInstance & {\n /** Resolve the per-pixel method bag for a specific pixel id. */\n instance: (id: string) => TtqInstance\n}" }, { "name": "TikTokPixelApi", "kind": "interface", - "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}" + "code": "export interface TikTokPixelApi {\n ttq: Ttq\n}" + }, + { + "name": "TtqArray", + "kind": "type", + "code": "type TtqArray = TtqInstance & Array & {\n methods: string[]\n setAndDefer: (target: any, method: string) => void\n instance: (id: string) => TtqInstance\n _i?: Record\n _t?: Record\n _o?: Record\n}" }, { "name": "TikTokPixelOptions", diff --git a/packages/script/src/runtime/registry/tiktok-pixel.ts b/packages/script/src/runtime/registry/tiktok-pixel.ts index 8399b4fd..f9f6d45c 100644 --- a/packages/script/src/runtime/registry/tiktok-pixel.ts +++ b/packages/script/src/runtime/registry/tiktok-pixel.ts @@ -70,28 +70,70 @@ interface TrackOptions { [key: string]: any } -type TtqFns +/** + * The deferred methods TikTok's base code installs on `ttq`. Calling one before + * `events.js` loads pushes a `[method, ...args]` tuple onto the queue; afterwards + * `events.js` rebinds them to live implementations. + */ +export interface TtqInstance { + /** Track a page view. */ + page: () => void + /** Track a standard or custom conversion event. */ + track: (event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void + /** Associate advanced-matching identifiers with the current user. */ + identify: (properties: IdentifyProperties) => void + /** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */ + grantConsent: () => void + /** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */ + revokeConsent: () => void + /** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */ + holdConsent: () => void + enableCookie: () => void + disableCookie: () => void +} + +/** + * Legacy callable signature. Pre-1.2 `useScriptTikTokPixel` exposed `ttq` as an + * `fbq`-style callable (`ttq('page')`, `ttq('track', …)`); the adapter keeps it + * working alongside the method form. + */ +type TtqCallable = ((cmd: 'track', event: StandardEvents | (string & {}), properties?: EventProperties, options?: TrackOptions) => void) & ((cmd: 'page') => void) & ((cmd: 'identify', properties: IdentifyProperties) => void) & ((cmd: (string & {}), ...args: any[]) => void) +/** + * The public `ttq` returned by the composable: a callable adapter (legacy form) + * that also carries the deferred methods (`ttq.page()`, `ttq.track(…)`). The + * adapter forwards to `window.ttq`, which is TikTok's real array protocol. + */ +export type Ttq = TtqCallable & TtqInstance & { + /** Resolve the per-pixel method bag for a specific pixel id. */ + instance: (id: string) => TtqInstance +} + export interface TikTokPixelApi { - ttq: TtqFns & { - push: TtqFns - loaded: boolean - queue: any[] - /** Opt user in to tracking. Queued before the script loads; live once `events.js` binds. */ - grantConsent: () => void - /** Opt user out of tracking. Queued before the script loads; live once `events.js` binds. */ - revokeConsent: () => void - /** Defer consent until an explicit grant/revoke. Queued before the script loads; live once `events.js` binds. */ - holdConsent: () => void - } + ttq: Ttq +} + +/** + * TikTok's `window.ttq` is an Array, not a callable: `events.js` reads it as a + * queue of `[method, ...args]` tuples and replays them, with the deferred + * methods installed as own properties on the array. + */ +type TtqArray = TtqInstance & Array & { + methods: string[] + setAndDefer: (target: any, method: string) => void + instance: (id: string) => TtqInstance + _i?: Record + _t?: Record + _o?: Record } declare global { - interface Window extends TikTokPixelApi { + interface Window { + ttq: TtqArray TiktokAnalyticsObject: string } } @@ -107,6 +149,9 @@ export function tiktokPixelSrc(region?: 'global' | 'us'): string { : 'https://analytics.tiktok.com/i18n/pixel/events.js' } +// The deferred-method names TikTok's base code defers onto `ttq`. +const TTQ_METHODS = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie', 'holdConsent', 'revokeConsent', 'grantConsent'] + const SHA256_HEX = /^[a-f0-9]{64}$/i function warnUnhashedIdentify(props: Record): void { @@ -120,6 +165,23 @@ function warnUnhashedIdentify(props: Record): void { } } +/** + * Build the callable adapter returned to consumers. It dispatches to the live + * `window.ttq` array on every call, so it tracks `events.js` rebinding the + * deferred methods. Supports both `ttq('page')` (legacy) and `ttq.page()`. + */ +function createTtqAdapter(): Ttq { + const dispatch = (method: string, ...args: any[]) => { + if (import.meta.dev && method === 'identify' && args[0]) + warnUnhashedIdentify(args[0]) + return (window.ttq as any)[method]?.(...args) + } + const adapter = ((method: string, ...args: any[]) => dispatch(method, ...args)) as Ttq + for (const method of [...TTQ_METHODS, 'instance']) + (adapter as any)[method] = (...args: any[]) => dispatch(method, ...args) + return adapter +} + export interface TikTokPixelConsent { /** Call `ttq.grantConsent()`. */ grant: () => void @@ -141,47 +203,51 @@ export function useScriptTikTokPixel(_options?: TikTok schema: import.meta.dev ? TikTokPixelOptions : undefined, scriptOptions: { use() { - return { ttq: window.ttq } + return { ttq: createTtqAdapter() } }, }, + // TikTok's `events.js` consumes the official array-based snippet protocol: + // `window.ttq` is an Array of `[method, ...args]` tuples that `events.js` + // drains once loaded. (It does NOT understand the Facebook `fbq` callable + // protocol — using that shape silently drops every browser Pixel event.) clientInit: import.meta.server ? undefined : () => { window.TiktokAnalyticsObject = 'ttq' - const ttq: TikTokPixelApi['ttq'] = window.ttq = function (...params: any[]) { - if (import.meta.dev && params[0] === 'identify' && params[1]) - warnUnhashedIdentify(params[1]) - // @ts-expect-error untyped - if (ttq.callMethod) { - // @ts-expect-error untyped - ttq.callMethod(...params) - } - else { - ttq.queue.push(params) - } - } as any - ttq.push = ttq - ttq.loaded = true - ttq.queue = [] - // Queue consent stubs so pre-load `ttq.grantConsent()` / `ttq.revokeConsent()` work. - // The real events.js replaces these with live bindings once loaded. - const consentMethods = ['grantConsent', 'revokeConsent', 'holdConsent'] as const - for (const name of consentMethods) { - ;(ttq as any)[name] = function (...params: any[]) { - ttq.queue.push([name, ...params]) + const ttq = (window.ttq = window.ttq || ([] as unknown as TtqArray)) + ttq.methods = TTQ_METHODS + ttq.setAndDefer = function (target: any, method: string) { + target[method] = function (...args: any[]) { + target.push([method, ...args]) } } - if (options?.defaultConsent === 'granted') - ttq.grantConsent() - else if (options?.defaultConsent === 'denied') - ttq.revokeConsent() - else if (options?.defaultConsent === 'hold') - ttq.holdConsent() + for (const method of ttq.methods) + ttq.setAndDefer(ttq, method) + ttq.instance = function (id: string) { + const bag = ttq._i?.[id] || [] + for (const method of ttq.methods) + ttq.setAndDefer(bag, method) + return bag as unknown as TtqInstance + } if (options?.id) { - ttq('init', options.id) - if (options?.trackPageView !== false) { - ttq('page') - } + // Per-pixel scaffolding read by `events.js` (equivalent to the base + // code's `ttq.load(id)`, minus the + + diff --git a/test/fixtures/tiktok-pixel/pages/tiktok.vue b/test/fixtures/tiktok-pixel/pages/tiktok.vue new file mode 100644 index 00000000..ab7439ff --- /dev/null +++ b/test/fixtures/tiktok-pixel/pages/tiktok.vue @@ -0,0 +1,37 @@ + + + diff --git a/test/fixtures/tiktok-pixel/tsconfig.json b/test/fixtures/tiktok-pixel/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/tiktok-pixel/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/nuxt-runtime/consent-default.nuxt.test.ts b/test/nuxt-runtime/consent-default.nuxt.test.ts index 8f2c7916..dd3a1ea2 100644 --- a/test/nuxt-runtime/consent-default.nuxt.test.ts +++ b/test/nuxt-runtime/consent-default.nuxt.test.ts @@ -479,11 +479,13 @@ describe('per-script consent object', () => { const { useScriptTikTokPixel } = await import('../../packages/script/src/runtime/registry/tiktok-pixel') const result: any = useScriptTikTokPixel({ id: 'CA123' }) result._opts.clientInit() - ;(window as any).ttq.queue = [] + // TikTok's array protocol: window.ttq is the queue itself. Clear the + // clientInit entries (page view + scaffolding) before asserting. + ;(window as any).ttq.length = 0 result.consent.grant() result.consent.revoke() result.consent.hold() - const queue = (window as any).ttq.queue as any[] + const queue = (window as any).ttq as any[] expect(queue.map(e => e[0])).toEqual(['grantConsent', 'revokeConsent', 'holdConsent']) }) diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 88f02289..4b9c1163 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -162,22 +162,31 @@ describe('#nuxt-scripts/types exports', () => { describe('tiktok pixel ttq', () => { type Ttq = TikTokPixelApi['ttq'] - it('track accepts the 4th options arg with event_id', () => { + it('legacy callable form: track accepts the 4th options arg with event_id', () => { expectTypeOf().toBeCallableWith('track', 'Purchase', { value: 10 }, { event_id: 'abc' }) }) - it('track accepts standard events including Purchase and StartTrial', () => { + it('legacy callable form: track accepts standard and custom events', () => { expectTypeOf().toBeCallableWith('track', 'Purchase') expectTypeOf().toBeCallableWith('track', 'StartTrial') + expectTypeOf().toBeCallableWith('track', 'CustomEvent') }) - it('track still accepts arbitrary custom event names', () => { - expectTypeOf().toBeCallableWith('track', 'CustomEvent') + it('method form: track accepts the 3rd options arg with event_id', () => { + expectTypeOf().toBeCallableWith('Purchase', { value: 10 }, { event_id: 'abc' }) + expectTypeOf().toBeCallableWith('StartTrial') + expectTypeOf().toBeCallableWith('CustomEvent') + }) + + it('method form: ttq exposes the array-protocol deferred methods', () => { + expectTypeOf().toBeCallableWith() + expectTypeOf().toBeCallableWith({ email: 'abc' }) + expectTypeOf().toBeCallableWith('PIXEL_ID') }) - it('proxy.ttq preserves the track overload with event_id', () => { + it('proxy.ttq preserves both call forms', () => { type ProxyTtq = ReturnType['proxy']['ttq'] expectTypeOf().toBeCallableWith('track', 'Purchase', { value: 10 }, { event_id: 'abc' }) - expectTypeOf().toBeCallableWith('track', 'StartTrial') + expectTypeOf().toBeCallableWith('StartTrial') }) })