From 3e88640c100fb6bd252a3a20f3d2a0f0c4d39403 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 00:57:19 +0000 Subject: [PATCH 1/2] Refactor plugin client initialization as a class Extracts the closure-based `initialize()` implementation into a `PluginClient` class that implements `PluginInstance`. Internal state (pluginConfig, subscriptions, listeners) becomes private fields and the event helpers (on/off/emit/execPromise) become private methods. The existing `initialize()` function is preserved as a thin wrapper that constructs and returns a new instance, keeping all call sites and tests working without modification. https://claude.ai/code/session_01UWBW4nLCNZEzw48ujVC4oJ --- packages/plugin-sdk/src/client/initialize.ts | 365 ++++++++++--------- 1 file changed, 187 insertions(+), 178 deletions(-) diff --git a/packages/plugin-sdk/src/client/initialize.ts b/packages/plugin-sdk/src/client/initialize.ts index 8f9c9fe..82903e0 100644 --- a/packages/plugin-sdk/src/client/initialize.ts +++ b/packages/plugin-sdk/src/client/initialize.ts @@ -9,272 +9,281 @@ import { } from '../types'; import { validateConfigId } from '../utils/error'; -export function initialize(): PluginInstance { - const pluginConfig: Partial> = { - config: {} as T, - }; - - let subscribedInteractions: Record = {}; - let subscribedWorkbookVars: Record = {}; - let subscribedUrlParameters: Record = {}; - const registeredEffects: Record void> = {}; - - const listeners: { - [event: string]: Function[]; - } = {}; - - const location = new URL(document.location.href); - for (const [key, value] of location.searchParams.entries()) { - try { - pluginConfig[key] = JSON.parse(value); - } catch (_err: unknown) { - if (__VITEST_BROWSER__ && (key === 'iframeId' || key === 'sessionId')) { - // noop: vitest browser injects these into the test iframe URL - } else { - console.error( - `Failed to parse URL param ${key} with value ${value} as JSON.`, - ); - } - } - } +export class PluginClient implements PluginInstance { + private pluginConfig: Partial> = { config: {} as T }; - const listener = (e: PluginMessageResponse) => { - emit(e.data.type, e.data.result, e.data.error); - }; + private subscribedInteractions: Record = {}; + private subscribedWorkbookVars: Record = {}; + private subscribedUrlParameters: Record = {}; + private readonly registeredEffects: Record void> = {}; + private readonly listeners: { [event: string]: Function[] } = {}; + private readonly messageListener: (e: PluginMessageResponse) => void; - window.addEventListener('message', listener, false); - window.addEventListener('click', () => execPromise('wb:plugin:focus')); + readonly config: PluginInstance['config']; + readonly elements: PluginInstance['elements']; + readonly style: PluginInstance['style']; - on('wb:plugin:config:update', (config: PluginConfig) => { - Object.assign(pluginConfig, config); - emit('config', pluginConfig.config ?? {}); - }); + constructor() { + this.messageListener = e => + this.emit(e.data.type, e.data.result, e.data.error); - // send initialize event - void execPromise('wb:plugin:init', __VERSION__).then(config => { - Object.assign(pluginConfig, config); - emit('init', pluginConfig); - emit('config', pluginConfig.config); - }); + this.parseUrlParams(); - on( - 'wb:plugin:variable:update', - (updatedVariables: Record) => { - subscribedWorkbookVars = {}; - Object.assign(subscribedWorkbookVars, updatedVariables); - }, - ); + window.addEventListener('message', this.messageListener, false); + window.addEventListener('click', () => this.execPromise('wb:plugin:focus')); - on('wb:plugin:selection:update', (updatedInteractions: unknown) => { - subscribedInteractions = {}; - Object.assign(subscribedInteractions, updatedInteractions); - }); - - on( - 'wb:plugin:url-parameter:update', - (updatedUrlParameters: Record) => { - subscribedUrlParameters = {}; - Object.assign(subscribedUrlParameters, updatedUrlParameters); - }, - ); - - on('wb:plugin:action-effect:invoke', (configId: string) => { - const effect = registeredEffects[configId]; - if (!effect) { - throw new Error(`Unknown action effect with name: ${configId}`); - } - effect(); - }); - - function on(event: string, listener: Function) { - listeners[event] = listeners[event] || []; - listeners[event].push(listener); - } + this.on('wb:plugin:config:update', (config: PluginConfig) => { + Object.assign(this.pluginConfig, config); + this.emit('config', this.pluginConfig.config ?? {}); + }); - function off(event: string, listener: Function) { - if (listeners[event] == null) return; - listeners[event] = listeners[event].filter(a => a !== listener); - } + // send initialize event + void this.execPromise('wb:plugin:init', __VERSION__).then(config => { + Object.assign(this.pluginConfig, config); + this.emit('init', this.pluginConfig); + this.emit('config', this.pluginConfig.config); + }); - function emit(event: string, ...args: any) { - Object.values(listeners[event] || []).forEach(fn => fn(...args)); - } + this.on( + 'wb:plugin:variable:update', + (updatedVariables: Record) => { + this.subscribedWorkbookVars = {}; + Object.assign(this.subscribedWorkbookVars, updatedVariables); + }, + ); - function execPromise(event: string, ...args: any): Promise { - return new Promise((resolve, reject) => { - const callback = (data: R, error: any) => { - if (error) reject(error); - else resolve(data); - off(event, callback); - }; - on(event, callback); - window.parent.postMessage( - { type: event, args, elementId: pluginConfig.id }, - pluginConfig?.wbOrigin ?? '*', - ); + this.on('wb:plugin:selection:update', (updatedInteractions: unknown) => { + this.subscribedInteractions = {}; + Object.assign(this.subscribedInteractions, updatedInteractions); }); - } - return { - get sigmaEnv() { - return pluginConfig.sigmaEnv; - }, + this.on( + 'wb:plugin:url-parameter:update', + (updatedUrlParameters: Record) => { + this.subscribedUrlParameters = {}; + Object.assign(this.subscribedUrlParameters, updatedUrlParameters); + }, + ); - get isScreenshot() { - return pluginConfig.screenshot; - }, + this.on('wb:plugin:action-effect:invoke', (configId: string) => { + const effect = this.registeredEffects[configId]; + if (!effect) { + throw new Error(`Unknown action effect with name: ${configId}`); + } + effect(); + }); - config: { + this.config = { // @ts-ignore TODO: Fix - getKey(key) { - return pluginConfig?.config?.[key]; - }, - get() { - return pluginConfig.config; - }, - set(partialConfig) { - void execPromise('wb:plugin:config:update', partialConfig); + getKey: key => this.pluginConfig?.config?.[key], + get: () => this.pluginConfig.config, + set: partialConfig => { + void this.execPromise('wb:plugin:config:update', partialConfig); }, - setKey(key, value) { - void execPromise('wb:plugin:config:update', { + setKey: (key, value) => { + void this.execPromise('wb:plugin:config:update', { [key]: value, }); }, - subscribe(listener) { - on('config', listener); - return () => off('config', listener); + subscribe: listener => { + this.on('config', listener); + return () => this.off('config', listener); }, - getVariable(configId: string) { + getVariable: (configId: string) => { validateConfigId(configId, 'variable'); - return subscribedWorkbookVars[configId]; + return this.subscribedWorkbookVars[configId]; }, - setVariable(configId: string, ...values: unknown[]) { + setVariable: (configId: string, ...values: unknown[]) => { validateConfigId(configId, 'variable'); - void execPromise('wb:plugin:variable:set', configId, ...values); + void this.execPromise('wb:plugin:variable:set', configId, ...values); }, - getInteraction(configId: string) { + getInteraction: (configId: string) => { validateConfigId(configId, 'interaction'); - return subscribedInteractions[configId]; + return this.subscribedInteractions[configId]; }, - setInteraction( + setInteraction: ( configId: string, elementId: string, selection: | string[] | Array>, - ) { + ) => { validateConfigId(configId, 'interaction'); - void execPromise( + void this.execPromise( 'wb:plugin:selection:set', configId, elementId, selection, ); }, - triggerAction(configId: string) { + triggerAction: (configId: string) => { validateConfigId(configId, 'action-trigger'); - void execPromise('wb:plugin:action-trigger:invoke', configId); + void this.execPromise('wb:plugin:action-trigger:invoke', configId); }, - registerEffect(configId: string, effect: () => void) { + registerEffect: (configId: string, effect: () => void) => { validateConfigId(configId, 'action-effect'); - registeredEffects[configId] = effect; + this.registeredEffects[configId] = effect; return () => { - delete registeredEffects[configId]; + delete this.registeredEffects[configId]; }; }, - configureEditorPanel(options) { - void execPromise('wb:plugin:config:inspector', options); + configureEditorPanel: options => { + void this.execPromise('wb:plugin:config:inspector', options); }, - setLoadingState(loadingState) { - void execPromise('wb:plugin:config:loading-state', loadingState); + setLoadingState: loadingState => { + void this.execPromise('wb:plugin:config:loading-state', loadingState); }, - subscribeToWorkbookVariable(configId, callback) { + subscribeToWorkbookVariable: (configId, callback) => { validateConfigId(configId, 'variable'); const setValues = (values: Record) => { callback(values[configId]); }; - on('wb:plugin:variable:update', setValues); + this.on('wb:plugin:variable:update', setValues); return () => { - off('wb:plugin:variable:update', setValues); + this.off('wb:plugin:variable:update', setValues); }; }, - subscribeToWorkbookInteraction(configId, callback) { + subscribeToWorkbookInteraction: (configId, callback) => { validateConfigId(configId, 'interaction'); const setValues = (values: Record) => { callback(values[configId]); }; - on('wb:plugin:selection:update', setValues); + this.on('wb:plugin:selection:update', setValues); return () => { - off('wb:plugin:selection:update', setValues); + this.off('wb:plugin:selection:update', setValues); }; }, - subscribeToUrlParameter(configId, callback) { + subscribeToUrlParameter: (configId, callback) => { validateConfigId(configId, 'url-parameter'); const setValues = (values: Record) => { callback(values[configId]); }; - setValues(subscribedUrlParameters); - on('wb:plugin:url-parameter:update', setValues); + setValues(this.subscribedUrlParameters); + this.on('wb:plugin:url-parameter:update', setValues); return () => { - off('wb:plugin:url-parameter:update', setValues); + this.off('wb:plugin:url-parameter:update', setValues); }; }, - getUrlParameter(configId: string) { + getUrlParameter: (configId: string) => { validateConfigId(configId, 'url-parameter'); - return subscribedUrlParameters[configId]; + return this.subscribedUrlParameters[configId]; }, - setUrlParameter(configId: string, value: string) { + setUrlParameter: (configId: string, value: string) => { validateConfigId(configId, 'url-parameter'); - void execPromise('wb:plugin:url-parameter:set', configId, value); + void this.execPromise('wb:plugin:url-parameter:set', configId, value); }, - }, - elements: { - getElementColumns(configId) { + }; + + this.elements = { + getElementColumns: configId => { validateConfigId(configId, 'element'); - return execPromise('wb:plugin:element:columns:get', configId); + return this.execPromise('wb:plugin:element:columns:get', configId); }, - subscribeToElementColumns(configId, callback) { + subscribeToElementColumns: (configId, callback) => { validateConfigId(configId, 'element'); const eventName = `wb:plugin:element:${configId}:columns`; - on(eventName, callback); - void execPromise('wb:plugin:element:subscribe:columns', configId); + this.on(eventName, callback); + void this.execPromise('wb:plugin:element:subscribe:columns', configId); return () => { - off(eventName, callback); - void execPromise('wb:plugin:element:unsubscribe:columns', configId); + this.off(eventName, callback); + void this.execPromise( + 'wb:plugin:element:unsubscribe:columns', + configId, + ); }; }, - subscribeToElementData(configId, callback) { + subscribeToElementData: (configId, callback) => { validateConfigId(configId, 'element'); const eventName = `wb:plugin:element:${configId}:data`; - on(eventName, callback); - void execPromise('wb:plugin:element:subscribe:data', configId); + this.on(eventName, callback); + void this.execPromise('wb:plugin:element:subscribe:data', configId); return () => { - off(eventName, callback); - void execPromise('wb:plugin:element:unsubscribe:data', configId); + this.off(eventName, callback); + void this.execPromise( + 'wb:plugin:element:unsubscribe:data', + configId, + ); }; }, - fetchMoreElementData(configId) { + fetchMoreElementData: configId => { validateConfigId(configId, 'element'); - void execPromise('wb:plugin:element:fetch-more', configId); + void this.execPromise('wb:plugin:element:fetch-more', configId); }, - }, + }; - style: { - subscribe(callback: (style: PluginStyle) => void) { - on('wb:plugin:style:update', callback); - return () => off('wb:plugin:style:update', callback); + this.style = { + subscribe: (callback: (style: PluginStyle) => void) => { + this.on('wb:plugin:style:update', callback); + return () => this.off('wb:plugin:style:update', callback); }, - - get() { - return execPromise('wb:plugin:style:get'); + get: () => { + return this.execPromise('wb:plugin:style:get'); }, - }, + }; + } + + get sigmaEnv() { + return this.pluginConfig.sigmaEnv; + } + + get isScreenshot() { + return this.pluginConfig.screenshot; + } + + destroy() { + Object.keys(this.listeners).forEach(event => delete this.listeners[event]); + window.removeEventListener('message', this.messageListener, false); + } + + private parseUrlParams() { + const location = new URL(document.location.href); + for (const [key, value] of location.searchParams.entries()) { + try { + this.pluginConfig[key] = JSON.parse(value); + } catch (_err: unknown) { + if (__VITEST_BROWSER__ && (key === 'iframeId' || key === 'sessionId')) { + // noop: vitest browser injects these into the test iframe URL + } else { + console.error( + `Failed to parse URL param ${key} with value ${value} as JSON.`, + ); + } + } + } + } + + private on(event: string, listener: Function) { + this.listeners[event] = this.listeners[event] || []; + this.listeners[event].push(listener); + } - destroy() { - Object.keys(listeners).forEach(event => delete listeners[event]); - window.removeEventListener('message', listener, false); - }, - }; + private off(event: string, listener: Function) { + if (this.listeners[event] == null) return; + this.listeners[event] = this.listeners[event].filter(a => a !== listener); + } + + private emit(event: string, ...args: any) { + Object.values(this.listeners[event] || []).forEach(fn => fn(...args)); + } + + private execPromise(event: string, ...args: any): Promise { + return new Promise((resolve, reject) => { + const callback = (data: R, error: any) => { + if (error) reject(error); + else resolve(data); + this.off(event, callback); + }; + this.on(event, callback); + window.parent.postMessage( + { type: event, args, elementId: this.pluginConfig.id }, + this.pluginConfig?.wbOrigin ?? '*', + ); + }); + } +} + +export function initialize(): PluginInstance { + return new PluginClient(); } From f70dc9c0a5e642527f9491c86762ee09e942b0c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 01:02:54 +0000 Subject: [PATCH 2/2] Use PluginClient constructor directly in tests Replaces `initialize()` call sites in the client test suite with `new PluginClient()` so the tests reflect the class as the primary surface. The `initialize()` function still exists as a thin wrapper, but the tests now exercise the constructor directly. https://claude.ai/code/session_01UWBW4nLCNZEzw48ujVC4oJ --- .../src/client/__tests__/initialize.test.ts | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/plugin-sdk/src/client/__tests__/initialize.test.ts b/packages/plugin-sdk/src/client/__tests__/initialize.test.ts index aa7ff0f..48c7594 100644 --- a/packages/plugin-sdk/src/client/__tests__/initialize.test.ts +++ b/packages/plugin-sdk/src/client/__tests__/initialize.test.ts @@ -1,7 +1,7 @@ import type { MockInstance } from 'vitest'; import { PluginInstance } from '../../types'; -import { initialize } from '../initialize'; +import { PluginClient } from '../initialize'; interface PluginMessage { type: string; @@ -13,7 +13,7 @@ type PostMessageFn = (message: PluginMessage, targetOrigin: string) => void; // `window.postMessage` has multiple overloads in lib.dom, which makes the // inferred `MockInstance` lose its `calls` arg types. We narrow to the exact -// shape `initialize.ts` always passes (`{ type, args, elementId }`, targetOrigin) +// shape `PluginClient` always passes (`{ type, args, elementId }`, targetOrigin) // so `spy.mock.calls` is properly typed at use sites. type PostMessageSpy = MockInstance; @@ -33,11 +33,11 @@ function findPostMessage(spy: PostMessageSpy, type: string) { return postMessages(spy).find(message => message.data.type === type); } -// Initializes a client while capturing the source's `message` listener so +// Constructs a client while capturing the source's `message` listener so // tests can invoke it directly. Direct invocation lets thrown errors propagate // synchronously to `expect().toThrow` instead of bubbling out as uncaught // errors (which Vite + Vitest each log to the console). -function initializeAndCaptureMessageListener() { +function createClientAndCaptureMessageListener() { let messageListener: ((event: unknown) => void) | undefined; const original = window.addEventListener.bind(window); const spy = vi @@ -54,7 +54,7 @@ function initializeAndCaptureMessageListener() { return original(type, listener, options); }, ); - const client = initialize(); + const client = new PluginClient(); spy.mockRestore(); if (!messageListener) { throw new Error('Failed to capture message listener'); @@ -62,7 +62,7 @@ function initializeAndCaptureMessageListener() { return { client, messageListener }; } -describe('initialize', () => { +describe('PluginClient', () => { let postMessageSpy: PostMessageSpy; let originalUrl: string; @@ -80,7 +80,7 @@ describe('initialize', () => { describe('lifecycle', () => { it('returns a client with the expected shape', () => { - const client = initialize(); + const client = new PluginClient(); expect(client.config).toBeDefined(); expect(client.elements).toBeDefined(); expect(client.style).toBeDefined(); @@ -92,7 +92,7 @@ describe('initialize', () => { const addSpy = vi.spyOn(window, 'addEventListener'); const removeSpy = vi.spyOn(window, 'removeEventListener'); - const client = initialize(); + const client = new PluginClient(); const messageAdd = addSpy.mock.calls.find(call => call[0] === 'message'); expect(messageAdd).toBeDefined(); @@ -109,7 +109,7 @@ describe('initialize', () => { }); it('sends an initial wb:plugin:init message including the SDK version', () => { - const client = initialize(); + const client = new PluginClient(); const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); expect(init).toBeDefined(); expect(Array.isArray(init?.data.args)).toBe(true); @@ -118,7 +118,7 @@ describe('initialize', () => { }); it('sends a focus event on window click', () => { - const client = initialize(); + const client = new PluginClient(); postMessageSpy.mockClear(); window.dispatchEvent(new MouseEvent('click')); const focus = findPostMessage(postMessageSpy, 'wb:plugin:focus'); @@ -130,7 +130,7 @@ describe('initialize', () => { describe('URL param parsing', () => { it('parses JSON-encoded URL params into the plugin config', () => { window.history.replaceState({}, '', '/?id=%22abc%22'); - const client = initialize(); + const client = new PluginClient(); const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); expect(init?.data.elementId).toBe('abc'); client.destroy(); @@ -143,7 +143,7 @@ describe('initialize', () => { '', '/?wbOrigin=' + encodeURIComponent(JSON.stringify(origin)), ); - const client = initialize(); + const client = new PluginClient(); const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); expect(init?.origin).toBe(origin); client.destroy(); @@ -151,7 +151,7 @@ describe('initialize', () => { it('falls back to "*" when wbOrigin is not provided', () => { window.history.replaceState({}, '', '/'); - const client = initialize(); + const client = new PluginClient(); const init = findPostMessage(postMessageSpy, 'wb:plugin:init'); expect(init?.origin).toBe('*'); client.destroy(); @@ -160,7 +160,7 @@ describe('initialize', () => { it('logs an error for malformed JSON params', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); window.history.replaceState({}, '', '/?bad=notJson'); - const client = initialize(); + const client = new PluginClient(); expect(errorSpy).toHaveBeenCalled(); expect(errorSpy.mock.calls[0][0]).toContain( 'Failed to parse URL param bad', @@ -176,7 +176,7 @@ describe('initialize', () => { '', '/?iframeId=notJson&sessionId=alsoNotJson', ); - const client = initialize(); + const client = new PluginClient(); expect(errorSpy).not.toHaveBeenCalled(); errorSpy.mockRestore(); client.destroy(); @@ -185,7 +185,7 @@ describe('initialize', () => { describe('init response', () => { it('updates pluginConfig and emits config when init resolves', async () => { - const client = initialize(); + const client = new PluginClient(); const configListener = vi.fn(); client.config.subscribe(configListener); @@ -196,7 +196,7 @@ describe('initialize', () => { }); // Flush the microtask queue so the `.then` chained onto `execPromise` - // inside initialize() (which copies the result onto pluginConfig and + // inside the constructor (which copies the result onto pluginConfig and // emits 'config') runs before we assert on the resulting state. await Promise.resolve(); @@ -207,7 +207,7 @@ describe('initialize', () => { }); it('exposes isScreenshot from the init response', async () => { - const client = initialize(); + const client = new PluginClient(); sendWindowMessage({ type: 'wb:plugin:init', result: { screenshot: true }, @@ -226,7 +226,7 @@ describe('initialize', () => { let client: PluginInstance; beforeEach(async () => { - client = initialize(); + client = new PluginClient(); sendWindowMessage({ type: 'wb:plugin:init', result: { config: { initial: 'x' } }, @@ -417,7 +417,7 @@ describe('initialize', () => { // throw needs to propagate synchronously into `expect().toThrow` rather // than escape as an uncaught error via `window.dispatchEvent`. client.destroy(); - const captured = initializeAndCaptureMessageListener(); + const captured = createClientAndCaptureMessageListener(); client = captured.client; const fn = vi.fn(); const unreg = client.config.registerEffect('e1', fn); @@ -436,7 +436,7 @@ describe('initialize', () => { it('throws when an unknown action effect is invoked', () => { client.destroy(); - const captured = initializeAndCaptureMessageListener(); + const captured = createClientAndCaptureMessageListener(); client = captured.client; expect(() => { captured.messageListener({ @@ -527,7 +527,7 @@ describe('initialize', () => { let client: PluginInstance; beforeEach(async () => { - client = initialize(); + client = new PluginClient(); sendWindowMessage({ type: 'wb:plugin:init', result: {}, error: null }); await Promise.resolve(); postMessageSpy.mockClear(); @@ -659,7 +659,7 @@ describe('initialize', () => { let client: PluginInstance; beforeEach(async () => { - client = initialize(); + client = new PluginClient(); sendWindowMessage({ type: 'wb:plugin:init', result: {}, error: null }); await Promise.resolve(); postMessageSpy.mockClear(); @@ -710,7 +710,7 @@ describe('initialize', () => { describe('destroy', () => { it('clears listeners so further messages do not trigger callbacks', async () => { - const client = initialize(); + const client = new PluginClient(); sendWindowMessage({ type: 'wb:plugin:init', result: {}, error: null }); await Promise.resolve();