From f97cd3b9a28eb48482b0f1405265679a9db5aae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Thu, 5 Nov 2026 21:03:22 +0100 Subject: [PATCH] fix(sdk): window.op keeps working when compiled to window.op.call Next.js with Turbopack compiles window.op?.('track', ...) to _window_op.call(...), which every helper in @openpanel/nextjs and @openpanel/astro goes through. Once op1.js had loaded, the window.op Proxy only read from the SDK instance, so call was undefined and tracking threw "_window_op.call is not a function". Before it had loaded, the snippet's Proxy queued ['call', ...], which the SDK dropped on replay, so the event was lost without an error. The loaded Proxy now falls back to its function target, and the snippet returns the native call, apply and bind. The onboarding script tag carries the same snippet. Sites that pasted the snippet by hand need the new one to keep events sent before the script loads. Closes #240 Co-authored-by: Kingsley --- .../components/onboarding/install-snippets.ts | 2 +- apps/testbed/e2e/sdk.spec.ts | 42 +++++++ apps/testbed/script-tag.html | 10 ++ apps/testbed/src/script-tag.ts | 21 ++++ packages/sdks/web/src/init-snippet.ts | 7 +- packages/sdks/web/src/tracker.test.ts | 117 ++++++++++++++++++ packages/sdks/web/src/tracker.ts | 5 + 7 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 apps/testbed/script-tag.html create mode 100644 apps/testbed/src/script-tag.ts create mode 100644 packages/sdks/web/src/tracker.test.ts 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; },