From 525bed623bf50085202fb1ad2bca8bfd9148207c Mon Sep 17 00:00:00 2001 From: arlo Date: Fri, 16 Oct 2026 09:55:44 +0800 Subject: [PATCH] fix(kit): guard reinstall and stale plugin replay Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/kit/src/kit.ts | 7 ++- packages/kit/src/plugin/registry.ts | 8 +++ tests/unit/plugin/kit-lifecycle.test.ts | 70 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/unit/plugin/kit-lifecycle.test.ts diff --git a/packages/kit/src/kit.ts b/packages/kit/src/kit.ts index 7a50926bf..25ddae9ca 100644 --- a/packages/kit/src/kit.ts +++ b/packages/kit/src/kit.ts @@ -112,6 +112,12 @@ export function createDevtoolsKit(options: DevtoolsKitOptions): DevtoolsKit { plugins, install() { if (installed) return + // dispose() clears the runtime handlers and RPC; reinstalling would give a hook + // feeding a runtime that answers nothing. Create a new kit instead. + if (disposed) { + console.warn('[vue-devtools] install() called on a disposed kit; create a new kit instead') + return + } const owners = ownerTarget[DEVTOOLS_KIT_OWNERS] ?? new Map() const previousOwner = owners.get(clientName) @@ -120,7 +126,6 @@ export function createDevtoolsKit(options: DevtoolsKitOptions): DevtoolsKit { ownerTarget[DEVTOOLS_KIT_OWNERS] = owners installed = true - disposed = false if (enabled && options.hook?.install !== false) { installedHook = installDevtoolsHook({ diff --git a/packages/kit/src/plugin/registry.ts b/packages/kit/src/plugin/registry.ts index c8a6da59b..189cdd8a7 100644 --- a/packages/kit/src/plugin/registry.ts +++ b/packages/kit/src/plugin/registry.ts @@ -108,6 +108,13 @@ export function createPluginController(runtime: DevtoolsRuntime): PluginControll } state.listeners.add(listener) state.bufferedPlugins.forEach(listener) + // An unmounted app never comes back, so a later kit must not replay its setups. + const disposeUnmount = runtime.subscribe('app:unmount', (event) => { + if (event.type !== 'app:unmount' || event.app == null) return + state.bufferedPlugins = state.bufferedPlugins.filter( + ([descriptor]) => descriptor.app !== event.app, + ) + }) async function callAdapterHook(adapter: PluginApiAdapter, key: string, payload: unknown) { if (!acceptsApp(adapter.descriptor, payload)) return @@ -131,6 +138,7 @@ export function createPluginController(runtime: DevtoolsRuntime): PluginControll }, dispose() { state.listeners.delete(listener) + disposeUnmount() plugins.clear() adapters.clear() }, diff --git a/tests/unit/plugin/kit-lifecycle.test.ts b/tests/unit/plugin/kit-lifecycle.test.ts new file mode 100644 index 000000000..a10978358 --- /dev/null +++ b/tests/unit/plugin/kit-lifecycle.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { DevtoolsKit } from '../../../packages/kit/src/kit' +import { createDevtoolsKit } from '../../../packages/kit/src/kit' +import { setupDevToolsPlugin } from '../../../packages/kit/src/plugin' +import { createComponentTreeFixture } from '../../fixtures' + +const kits: DevtoolsKit[] = [] + +afterEach(async () => { + for (const kit of kits.splice(0)) await kit.dispose() +}) + +function createKit(): DevtoolsKit { + const kit = createDevtoolsKit({ target: { name: 'Lifecycle Test' }, hook: { install: false } }) + kit.install() + kits.push(kit) + return kit +} + +it('does not reinstall a disposed kit', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const kit = createKit() + await kit.dispose() + + kit.install() + + expect(warn).toHaveBeenCalledOnce() + expect(kit.plugins.adapters.size).toBe(0) +}) + +it('does not replay plugin setups for an unmounted app into a later kit', async () => { + const first = createKit() + const fixture = createComponentTreeFixture(1) + first.runtime.dispatch({ + type: 'app:init', + app: fixture.app, + time: 1, + version: '3.5.0', + vueTypes: {}, + }) + const setup = vi.fn() + setupDevToolsPlugin({ id: 'plugin:replay', app: fixture.app }, setup) + await vi.waitFor(() => expect(setup).toHaveBeenCalledOnce()) + + first.runtime.dispatch({ type: 'app:unmount', app: fixture.app, time: 2 }) + await first.dispose() + createKit() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(setup).toHaveBeenCalledOnce() +}) + +it('still replays plugin setups for a mounted app into a later kit', async () => { + const first = createKit() + const fixture = createComponentTreeFixture(1) + first.runtime.dispatch({ + type: 'app:init', + app: fixture.app, + time: 1, + version: '3.5.0', + vueTypes: {}, + }) + const setup = vi.fn() + setupDevToolsPlugin({ id: 'plugin:kept', app: fixture.app }, setup) + await vi.waitFor(() => expect(setup).toHaveBeenCalledOnce()) + + await first.dispose() + createKit() + await vi.waitFor(() => expect(setup).toHaveBeenCalledTimes(2)) +})