From 3a9325ee847b0208d077c86d4e8dc40fa078d040 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 28 Sep 2026 08:47:14 +0200 Subject: [PATCH] fix(devtools-utils): unmount the devtools core when a Preact or React panel unmounts --- .changeset/panel-unmount-cleanup.md | 5 ++ knip.json | 8 ++- .../devtools-utils/src/preact/panel.test.tsx | 61 +++++++++++++++++++ packages/devtools-utils/src/preact/panel.tsx | 9 ++- packages/devtools-utils/src/react/panel.tsx | 9 ++- 5 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 .changeset/panel-unmount-cleanup.md create mode 100644 packages/devtools-utils/src/preact/panel.test.tsx diff --git a/.changeset/panel-unmount-cleanup.md b/.changeset/panel-unmount-cleanup.md new file mode 100644 index 000000000..3bddfb54d --- /dev/null +++ b/.changeset/panel-unmount-cleanup.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools-utils': patch +--- + +Unmount the devtools core when a Preact or React panel unmounts. The panel's ref is already detached when the effect cleanup runs (React 19, Preact 11), so the core was never unmounted. diff --git a/knip.json b/knip.json index f0703b2a0..e1e76b7bf 100644 --- a/knip.json +++ b/knip.json @@ -12,7 +12,13 @@ }, "packages/devtools-utils": { - "ignoreDependencies": ["react", "solid-js", "svelte", "@types/react"], + "ignoreDependencies": [ + "preact", + "react", + "solid-js", + "svelte", + "@types/react" + ], "entry": [ "**/vite.config.solid.ts", "**/src/solid/**", diff --git a/packages/devtools-utils/src/preact/panel.test.tsx b/packages/devtools-utils/src/preact/panel.test.tsx new file mode 100644 index 000000000..b51603a07 --- /dev/null +++ b/packages/devtools-utils/src/preact/panel.test.tsx @@ -0,0 +1,61 @@ +/** @jsxImportSource preact */ + +import { render } from 'preact' +import { act } from 'preact/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPreactPanel } from './panel' + +// Minimal stand-in for a class-based devtools core. +function makeCoreClass() { + const coreMount = vi.fn() + const coreUnmount = vi.fn() + const construct = vi.fn<(...args: Array) => void>() + class Core { + mount = coreMount + unmount = coreUnmount + constructor(...args: Array) { + construct(...args) + } + } + return { Core, construct, coreMount, coreUnmount } +} + +describe('createPreactPanel', () => { + let container: HTMLElement + + beforeEach(() => { + document.body.replaceChildren() + container = document.createElement('div') + document.body.appendChild(container) + }) + + it('returns a [Panel, NoOpPanel] tuple of component functions', () => { + const { Core } = makeCoreClass() + const [Panel, NoOpPanel] = createPreactPanel(Core as any) + expect(typeof Panel).toBe('function') + expect(typeof NoOpPanel).toBe('function') + }) + + it('Panel constructs the core, mounts it with plugin props, and tears it down', () => { + const { Core, construct, coreMount, coreUnmount } = makeCoreClass() + const [Panel] = createPreactPanel(Core as any) + + act(() => { + render(, container) + }) + + expect(construct).toHaveBeenCalledTimes(1) + expect(coreMount).toHaveBeenCalledTimes(1) + const call = coreMount.mock.calls[0]! + expect(call[0]).toBe(container.firstChild) + expect(call[1]).toEqual({ theme: 'dark', devtoolsOpen: true }) + + // The panel's ref is detached before its effect cleanup runs, so the core + // has to be unmounted using the element it was mounted into. + act(() => { + render(null, container) + }) + + expect(coreUnmount).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/devtools-utils/src/preact/panel.tsx b/packages/devtools-utils/src/preact/panel.tsx index 5c8fcd604..21e284d12 100644 --- a/packages/devtools-utils/src/preact/panel.tsx +++ b/packages/devtools-utils/src/preact/panel.tsx @@ -36,12 +36,15 @@ export function createPreactPanel< if (devtools.current) return devtools.current = new CoreClass() - if (devToolRef.current) { - devtools.current.mount(devToolRef.current, props) + // The ref is already detached when the cleanup runs on unmount, so keep + // the element we mounted into around for the cleanup. + const el = devToolRef.current + if (el) { + devtools.current.mount(el, props) } return () => { - if (devToolRef.current) { + if (el) { devtools.current?.unmount() devtools.current = null } diff --git a/packages/devtools-utils/src/react/panel.tsx b/packages/devtools-utils/src/react/panel.tsx index 548f27dec..3da7d0115 100644 --- a/packages/devtools-utils/src/react/panel.tsx +++ b/packages/devtools-utils/src/react/panel.tsx @@ -34,12 +34,15 @@ export function createReactPanel< if (devtools.current) return devtools.current = new CoreClass() - if (devToolRef.current) { - devtools.current.mount(devToolRef.current, props) + // The ref is already detached when the cleanup runs on unmount, so keep + // the element we mounted into around for the cleanup. + const el = devToolRef.current + if (el) { + devtools.current.mount(el, props) } return () => { - if (devToolRef.current) { + if (el) { devtools.current?.unmount() devtools.current = null }