diff --git a/apps/start/src/components/onboarding/install-snippets.ts b/apps/start/src/components/onboarding/install-snippets.ts index 5fe5d38ad..89e4821a6 100644 --- a/apps/start/src/components/onboarding/install-snippets.ts +++ b/apps/start/src/components/onboarding/install-snippets.ts @@ -25,7 +25,7 @@ const apiUrlLine = (context: SnippetContext, indent: string) => export function scriptTagSnippet(context: SnippetContext): string { return ` + + diff --git a/apps/testbed/src/script-tag.ts b/apps/testbed/src/script-tag.ts new file mode 100644 index 000000000..9b216bfee --- /dev/null +++ b/apps/testbed/src/script-tag.ts @@ -0,0 +1,21 @@ +import { getInitSnippet } from '@openpanel/web'; + +declare global { + interface Window { + loadTracker: () => Promise; + } +} + +// A customer site's two script tags, taken from source: public/op1.js is the +// last released build, not the code under test. The spec decides when the +// second one "arrives" so it can call window.op on both sides of it. +const snippet = document.createElement('script'); +snippet.textContent = getInitSnippet(); +document.head.append(snippet); + +window.op('init', { + clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID ?? 'testbed-client', + apiUrl: import.meta.env.VITE_OPENPANEL_API_URL ?? 'http://localhost:3333', +}); + +window.loadTracker = () => import('@openpanel/web/src/tracker'); diff --git a/packages/sdks/web/src/init-snippet.ts b/packages/sdks/web/src/init-snippet.ts index 81388180d..07802a596 100644 --- a/packages/sdks/web/src/init-snippet.ts +++ b/packages/sdks/web/src/init-snippet.ts @@ -6,10 +6,13 @@ // q.push(Array.prototype.slice.call(arguments)); // } // }, { -// get: function(_, prop) { +// get: function(target, prop) { // if (prop === 'q') { // return q; // } +// if (prop === 'call' || prop === 'apply' || prop === 'bind') { +// return target[prop]; +// } // return function() { // q.push([prop].concat(Array.prototype.slice.call(arguments))); // }; @@ -19,5 +22,5 @@ // })(); export function getInitSnippet(): string { - return `window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();`; + return `window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:"call"===r||"apply"===r||"bind"===r?t[r]:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();`; } diff --git a/packages/sdks/web/src/tracker.test.ts b/packages/sdks/web/src/tracker.test.ts new file mode 100644 index 000000000..505ea0ca1 --- /dev/null +++ b/packages/sdks/web/src/tracker.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { TrackHandlerPayload } from '@openpanel/sdk'; +import { JSDOM } from 'jsdom'; +import { getInitSnippet } from './init-snippet'; + +const PAGE_URL = 'https://example.com/pricing'; +const DOM_GLOBALS = [ + 'window', + 'document', + 'history', + 'sessionStorage', + 'HTMLElement', +] as const; + +let dom: JSDOM; +let sent: TrackHandlerPayload[]; +let trackerLoads = 0; + +function installSnippet() { + dom.window.eval(getInitSnippet()); + dom.window.op('init', { + clientId: 'test-client', + // Captures every payload before it reaches the network. + filter: (payload: TrackHandlerPayload) => { + sent.push(payload); + return false; + }, + }); +} + +// tracker.ts runs on import, so each load needs its own module instance. +async function loadTracker() { + trackerLoads += 1; + await import(`./tracker?load=${trackerLoads}`); +} + +function trackedNames() { + return sent.flatMap((item) => + item.type === 'track' ? [item.payload.name] : [] + ); +} + +beforeEach(() => { + dom = new JSDOM('', { + url: PAGE_URL, + runScripts: 'outside-only', + }); + for (const name of DOM_GLOBALS) { + (globalThis as Record)[name] = dom.window[name]; + } + sent = []; + installSnippet(); +}); + +afterEach(() => { + for (const name of DOM_GLOBALS) { + delete (globalThis as Record)[name]; + } + dom.window.close(); +}); + +describe('window.op before the SDK loads', () => { + test('call queues the method it was given', () => { + const { op } = dom.window; + + op.call(dom.window, 'track', 'user_login', { source: 'nextjs' }); + + expect(op.q?.at(-1)).toEqual(['track', 'user_login', { source: 'nextjs' }]); + }); + + test('events sent through call, apply and bind survive the SDK loading', async () => { + const { op } = dom.window; + + op.call(dom.window, 'track', 'through_call'); + op.apply(dom.window, ['track', 'through_apply']); + op.bind(dom.window)('track', 'through_bind'); + op.track('through_method'); + await loadTracker(); + + expect(trackedNames()).toEqual([ + 'through_call', + 'through_apply', + 'through_bind', + 'through_method', + ]); + }); +}); + +describe('window.op after the SDK loads', () => { + beforeEach(loadTracker); + + test('call reaches the SDK method, the way Turbopack compiles window.op?.()', () => { + dom.window.op.call(dom.window, 'track', 'user_login'); + + expect(trackedNames()).toEqual(['user_login']); + }); + + test('apply and bind reach the SDK method', () => { + const { op } = dom.window; + + op.apply(dom.window, ['track', 'through_apply']); + op.bind(dom.window)('track', 'through_bind'); + + expect(trackedNames()).toEqual(['through_apply', 'through_bind']); + }); + + test('the callable and method styles keep working', () => { + const { op } = dom.window; + + op('track', 'callable'); + op.track('method'); + + expect(trackedNames()).toEqual(['callable', 'method']); + expect(op.q).toBeUndefined(); + expect(dom.window.openpanel?.options.clientId).toBe('test-client'); + }); +}); diff --git a/packages/sdks/web/src/tracker.ts b/packages/sdks/web/src/tracker.ts index 5e789082a..472f774dc 100644 --- a/packages/sdks/web/src/tracker.ts +++ b/packages/sdks/web/src/tracker.ts @@ -35,6 +35,11 @@ import { OpenPanel } from './index'; if (typeof value === 'function') { return value.bind(op); } + // Turbopack compiles window.op?.(...) to window.op.call(...), and only + // the function target has call, apply and bind. + if (value === undefined) { + return Reflect.get(target, prop); + } // Otherwise return the property from op (for things like options, etc.) return value; },