From e1a4a9d41c86cf3233d1b93cbb27d254a064de3a Mon Sep 17 00:00:00 2001 From: arlo Date: Thu, 15 Oct 2026 16:19:37 +0800 Subject: [PATCH] fix(kit): scope plugin hooks per app Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/kit/src/plugin/api.ts | 51 ++++-------- packages/kit/src/plugin/registry.ts | 122 ++++++++++++++++++---------- packages/kit/src/plugin/settings.ts | 59 ++++++++++++++ tests/unit/plugin/v6-compat.test.ts | 112 +++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 79 deletions(-) create mode 100644 packages/kit/src/plugin/settings.ts diff --git a/packages/kit/src/plugin/api.ts b/packages/kit/src/plugin/api.ts index 9055531c0..72ea72dd6 100644 --- a/packages/kit/src/plugin/api.ts +++ b/packages/kit/src/plugin/api.ts @@ -1,4 +1,5 @@ import type { ComponentId, DevtoolsRuntime, InspectorId, MaybePromise, PluginId } from '../runtime' +import { PluginSettings } from './settings' export interface PluginDescriptor { id: PluginId @@ -208,9 +209,20 @@ export function createPluginApiAdapter( descriptor: PluginDescriptor, runtime: DevtoolsRuntime, resolvePluginSettings?: (pluginId: string) => Record | undefined, + settings: PluginSettings = new PluginSettings(descriptor), ): PluginApiAdapter { const handlers = new Map>() - let settingsValues = readSettingsDefaults(descriptor.settings) + settings.subscribe(async ({ key, value, oldValue, settings: snapshot }) => { + await callHook('setPluginSettings', { + app: descriptor.app, + pluginId: descriptor.id, + key, + newValue: value, + oldValue, + value, + settings: snapshot, + }) + }) function addHook( key: K, @@ -309,32 +321,15 @@ export function createPluginApiAdapter( function getSettingsSnapshot(pluginId?: string): Record { if (pluginId && pluginId !== descriptor.id) return resolvePluginSettings?.(pluginId) ?? {} - return { ...settingsValues } + return settings.get() } function syncSettings() { - settingsValues = readSettingsDefaults(descriptor.settings, settingsValues) + settings.sync() } async function updateSetting(key: string, value: unknown) { - if (!descriptor.settings || !(key in descriptor.settings)) return - - const oldValue = settingsValues[key] - - settingsValues = { - ...settingsValues, - [key]: value, - } - - await callHook('setPluginSettings', { - app: descriptor.app, - pluginId: descriptor.id, - key, - newValue: value, - oldValue, - value, - settings: getSettingsSnapshot(), - }) + await settings.update(key, value) } async function callHook( @@ -402,17 +397,3 @@ function createHookRegistrarProxy( }, }) } - -function readSettingsDefaults( - settings: PluginDescriptor['settings'] | undefined, - current: Record = {}, -): Record { - const result: Record = {} - if (!settings) return result - - for (const [key, value] of Object.entries(settings)) { - result[key] = key in current ? current[key] : value.defaultValue - } - - return result -} diff --git a/packages/kit/src/plugin/registry.ts b/packages/kit/src/plugin/registry.ts index 51e8c16f4..c8a6da59b 100644 --- a/packages/kit/src/plugin/registry.ts +++ b/packages/kit/src/plugin/registry.ts @@ -6,6 +6,7 @@ import type { PluginSetupFunction, } from './api' import { createPluginApiAdapter, reportPluginError } from './api' +import { PluginSettings } from './settings' type PluginEntry = [PluginDescriptor, PluginSetupFunction] type PluginListener = (entry: PluginEntry) => void @@ -38,42 +39,66 @@ export interface PluginController { dispose(): void } +/** + * Everything registered under one plugin id. Plugins such as Pinia call setup once per app + * with the same id; each app gets its own adapter, so hooks and commands stay scoped to it, + * while settings stay shared. + */ +class RegisteredPlugin { + readonly adapters: PluginApiAdapter[] = [] + private readonly settings: PluginSettings + + constructor( + descriptor: PluginDescriptor, + private readonly runtime: DevtoolsRuntime, + private readonly resolvePluginSettings: ( + pluginId: string, + ) => Record | undefined, + ) { + this.settings = new PluginSettings(descriptor) + } + + adapterFor(descriptor: PluginDescriptor): PluginApiAdapter { + if (this.adapters.length) { + for (const adapter of this.adapters) mergeDescriptor(adapter.descriptor, descriptor) + this.settings.sync() + } + // A setup without an app joins the first adapter. + const existing = + descriptor.app == null + ? this.adapters[0] + : this.adapters.find((adapter) => adapter.descriptor.app === descriptor.app) + if (existing) return existing + + const adapter = createPluginApiAdapter( + descriptor, + this.runtime, + this.resolvePluginSettings, + this.settings, + ) + this.adapters.push(adapter) + return adapter + } +} + export function createPluginController(runtime: DevtoolsRuntime): PluginController { const state = getPluginRegistryState() + const plugins = new Map() + // First adapter per plugin id: what the plugin snapshot and settings commands use. const adapters = new Map() + const resolvePluginSettings = (pluginId: string) => adapters.get(pluginId)?.getSettings() async function register( descriptor: PluginDescriptor, setupFn: PluginSetupFunction, ): Promise { - const registeredAdapter = adapters.get(descriptor.id) - if (registeredAdapter) { - registeredAdapter.descriptor.label = descriptor.label ?? registeredAdapter.descriptor.label - registeredAdapter.descriptor.logo = descriptor.logo ?? registeredAdapter.descriptor.logo - registeredAdapter.descriptor.packageName = - descriptor.packageName ?? registeredAdapter.descriptor.packageName - registeredAdapter.descriptor.homepage = - descriptor.homepage ?? registeredAdapter.descriptor.homepage - registeredAdapter.descriptor.app = descriptor.app ?? registeredAdapter.descriptor.app - registeredAdapter.descriptor.componentStateTypes = - descriptor.componentStateTypes ?? registeredAdapter.descriptor.componentStateTypes - registeredAdapter.descriptor.disableAppScope = - descriptor.disableAppScope ?? registeredAdapter.descriptor.disableAppScope - registeredAdapter.descriptor.disablePluginScope = - descriptor.disablePluginScope ?? registeredAdapter.descriptor.disablePluginScope - registeredAdapter.descriptor.enableEarlyProxy = - descriptor.enableEarlyProxy ?? registeredAdapter.descriptor.enableEarlyProxy - registeredAdapter.descriptor.settings = - descriptor.settings ?? registeredAdapter.descriptor.settings - registeredAdapter.syncSettings() - await runPluginSetup(descriptor.id, setupFn, registeredAdapter.api) - return registeredAdapter + let plugin = plugins.get(descriptor.id) + if (!plugin) { + plugin = new RegisteredPlugin(descriptor, runtime, resolvePluginSettings) + plugins.set(descriptor.id, plugin) } - - const adapter = createPluginApiAdapter(descriptor, runtime, (pluginId) => - adapters.get(pluginId)?.getSettings(), - ) - adapters.set(descriptor.id, adapter) + const adapter = plugin.adapterFor(descriptor) + if (!adapters.has(descriptor.id)) adapters.set(descriptor.id, adapter) await runPluginSetup(descriptor.id, setupFn, adapter.api) return adapter } @@ -84,37 +109,46 @@ export function createPluginController(runtime: DevtoolsRuntime): PluginControll state.listeners.add(listener) state.bufferedPlugins.forEach(listener) + async function callAdapterHook(adapter: PluginApiAdapter, key: string, payload: unknown) { + if (!acceptsApp(adapter.descriptor, payload)) return + try { + await adapter.callHook(key as never, payload as never) + } catch (error) { + reportPluginError(adapter.descriptor.id, `"${key}" hook`, error) + } + } + return { adapters, register, async callHook(key, payload) { - await Promise.all( - [...adapters.values()].map(async (adapter) => { - if (!acceptsApp(adapter.descriptor, payload)) return - try { - await adapter.callHook(key as never, payload as never) - } catch (error) { - reportPluginError(adapter.descriptor.id, `"${key}" hook`, error) - } - }), - ) + const all = [...plugins.values()].flatMap((plugin) => plugin.adapters) + await Promise.all(all.map((adapter) => callAdapterHook(adapter, key, payload))) }, async callHookFor(pluginId, key, payload) { - const adapter = adapters.get(pluginId) - if (!adapter || !acceptsApp(adapter.descriptor, payload)) return - try { - await adapter.callHook(key as never, payload as never) - } catch (error) { - reportPluginError(adapter.descriptor.id, `"${key}" hook`, error) - } + const all = plugins.get(pluginId)?.adapters ?? [] + await Promise.all(all.map((adapter) => callAdapterHook(adapter, key, payload))) }, dispose() { state.listeners.delete(listener) + plugins.clear() adapters.clear() }, } } +function mergeDescriptor(target: PluginDescriptor, descriptor: PluginDescriptor): void { + target.label = descriptor.label ?? target.label + target.logo = descriptor.logo ?? target.logo + target.packageName = descriptor.packageName ?? target.packageName + target.homepage = descriptor.homepage ?? target.homepage + target.componentStateTypes = descriptor.componentStateTypes ?? target.componentStateTypes + target.disableAppScope = descriptor.disableAppScope ?? target.disableAppScope + target.disablePluginScope = descriptor.disablePluginScope ?? target.disablePluginScope + target.enableEarlyProxy = descriptor.enableEarlyProxy ?? target.enableEarlyProxy + target.settings = descriptor.settings ?? target.settings +} + function acceptsApp(descriptor: PluginDescriptor, payload: unknown): boolean { if (descriptor.disableAppScope || descriptor.app == null) return true if (!payload || typeof payload !== 'object' || !('app' in payload)) return true diff --git a/packages/kit/src/plugin/settings.ts b/packages/kit/src/plugin/settings.ts new file mode 100644 index 000000000..bd617cd7a --- /dev/null +++ b/packages/kit/src/plugin/settings.ts @@ -0,0 +1,59 @@ +import type { PluginDescriptor } from './api' + +// Internal: not re-exported from the plugin barrel. + +export interface PluginSettingsChange { + key: string + value: unknown + oldValue: unknown + settings: Record +} + +/** + * Setting values for one plugin id. Adapters that register the same id for several apps + * share one instance, so a change reaches every app's `setPluginSettings` handlers. + */ +export class PluginSettings { + private values: Record + private readonly listeners = new Set<(change: PluginSettingsChange) => Promise>() + + constructor(private readonly descriptor: PluginDescriptor) { + this.values = readSettingsDefaults(descriptor.settings) + } + + get(): Record { + return { ...this.values } + } + + sync(): void { + this.values = readSettingsDefaults(this.descriptor.settings, this.values) + } + + subscribe(listener: (change: PluginSettingsChange) => Promise): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + async update(key: string, value: unknown): Promise { + if (!this.descriptor.settings || !(key in this.descriptor.settings)) return + + const oldValue = this.values[key] + this.values = { ...this.values, [key]: value } + const change = { key, value, oldValue, settings: this.get() } + await Promise.all([...this.listeners].map((listener) => listener(change))) + } +} + +function readSettingsDefaults( + settings: PluginDescriptor['settings'] | undefined, + current: Record = {}, +): Record { + const result: Record = {} + if (!settings) return result + + for (const [key, value] of Object.entries(settings)) { + result[key] = key in current ? current[key] : value.defaultValue + } + + return result +} diff --git a/tests/unit/plugin/v6-compat.test.ts b/tests/unit/plugin/v6-compat.test.ts index 2e2c8b2a5..56835936f 100644 --- a/tests/unit/plugin/v6-compat.test.ts +++ b/tests/unit/plugin/v6-compat.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { DevtoolsKit } from '../../../packages/kit/src/kit' +import type { DevToolsPluginAPI } from '../../../packages/kit/src/plugin' import type { EncodedValue } from '../../../packages/kit/src/codec' import { createDevtoolsKit } from '../../../packages/kit/src/kit' import { createComponentTreeFixture } from '../../fixtures' @@ -628,4 +629,115 @@ describe('v6/v7 plugin payload compatibility', () => { ]) expect(onSecond.inspectors.map((inspector) => inspector.id)).toEqual(['global-inspector']) }) + function initSecondApp(target: DevtoolsKit) { + const fixture = createComponentTreeFixture(1) + target.runtime.dispatch({ + type: 'app:init', + app: fixture.app, + time: 2, + version: '3.5.0', + vueTypes: {}, + }) + return fixture + } + + // Pinia registers the same plugin and inspector id once per app. + it('keeps hooks scoped to each app when one plugin id registers for several apps', async () => { + kit = createKit() + const first = initApp(kit) + const second = initSecondApp(kit) + + for (const [store, app] of [ + ['storeA', first.app], + ['storeB', second.app], + ] as const) { + await kit.plugins.register({ id: 'dev.esm.pinia', app }, (api) => { + api.addInspector({ id: 'pinia', label: 'Pinia' }) + api.on.getInspectorTree((payload) => { + if (payload.app === app && payload.inspectorId === 'pinia') + payload.rootNodes.push({ id: store, label: store }) + }) + }) + } + + const onFirst = await kit.runtime.query({ + appId: 'app:0', + type: 'inspectors:treeSnapshot', + payload: { inspectorId: 'pinia' }, + }) + const onSecond = await kit.runtime.query({ + appId: 'app:1', + type: 'inspectors:treeSnapshot', + payload: { inspectorId: 'pinia' }, + }) + expect(onFirst.rootNodes.map((node) => node.id)).toEqual(['storeA']) + expect(onSecond.rootNodes.map((node) => node.id)).toEqual(['storeB']) + }) + + it('runs setPluginSettings handlers from every app of a shared plugin id', async () => { + kit = createKit() + const first = initApp(kit) + const second = initSecondApp(kit) + const settings = { flag: { label: 'Flag', type: 'boolean' as const, defaultValue: false } } + const seen: string[] = [] + + for (const [name, app] of [ + ['first', first.app], + ['second', second.app], + ] as const) { + await kit.plugins.register({ id: 'plugin:shared', app, settings }, (api) => { + api.on.setPluginSettings(() => { + seen.push(name) + }) + }) + } + + await kit.plugins.adapters.get('plugin:shared')!.updateSetting('flag', true) + expect(seen).toEqual(['first', 'second']) + }) + + it('registers the same hook handler only once per app', async () => { + kit = createKit() + const fixture = initApp(kit) + const handler = vi.fn() + + await kit.plugins.register({ id: 'plugin:dedupe', app: fixture.app }, (api) => { + api.on.inspectComponent(handler) + api.on.inspectComponent(handler) + }) + + await kit.runtime.callPluginHook('inspectComponent', { + app: fixture.app, + componentInstance: fixture.instances[0], + instanceData: {}, + }) + expect(handler).toHaveBeenCalledOnce() + }) + + it('shares plugin settings across apps that register the same plugin id', async () => { + kit = createKit() + const first = initApp(kit) + const second = initSecondApp(kit) + const settings = { flag: { label: 'Flag', type: 'boolean' as const, defaultValue: false } } + let secondApi: DevToolsPluginAPI | undefined + + await kit.plugins.register({ id: 'plugin:shared', app: first.app, settings }, () => {}) + await kit.runtime.command({ + type: 'plugins:updateSetting', + payload: { pluginId: 'plugin:shared', key: 'flag', value: true }, + }) + await kit.plugins.register({ id: 'plugin:shared', app: second.app, settings }, (api) => { + secondApi = api + }) + expect(secondApi?.getSettings()).toEqual({ flag: true }) + + await kit.runtime.command({ + type: 'plugins:updateSetting', + payload: { pluginId: 'plugin:shared', key: 'flag', value: false }, + }) + expect(secondApi?.getSettings()).toEqual({ flag: false }) + + const snapshot = await kit.runtime.query({ type: 'plugins:snapshot' }) + expect(snapshot.plugins.map((plugin) => plugin.id)).toEqual(['plugin:shared']) + }) })