Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/panel-unmount-cleanup.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
61 changes: 61 additions & 0 deletions packages/devtools-utils/src/preact/panel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>) => void>()
class Core {
mount = coreMount
unmount = coreUnmount
constructor(...args: Array<unknown>) {
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(<Panel theme="dark" devtoolsOpen />, 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)
})
})
9 changes: 6 additions & 3 deletions packages/devtools-utils/src/preact/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 6 additions & 3 deletions packages/devtools-utils/src/react/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down