Skip to content

Commit aaae542

Browse files
committed
fix(svelte-devtools): preserve plugin state on updates
1 parent e9fd01f commit aaae542

9 files changed

Lines changed: 89 additions & 30 deletions

File tree

‎.changeset/fresh-svelte-factories.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
'@tanstack/svelte-devtools': patch
44
---
55

6-
Align Svelte panel construction and plugin metadata with the other framework factories, use compiled Svelte components to own panel and no-op lifecycles, forward shared plugin props through the Svelte adapter, and cleanly replace mounted components when plugin props change.
6+
Align Svelte panel construction and plugin metadata with the other framework factories, use compiled Svelte components to own panel and no-op lifecycles, forward shared plugin props through the Svelte adapter, and update mounted component props without resetting their state.

‎docs/framework/svelte/adapter.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ type TanStackDevtoolsSveltePlugin = {
4747
4848
The Svelte adapter uses `component` (a Svelte component reference) instead of `render` (a JSX element) in plugin definitions. It passes `{ theme, devtoolsOpen, ...plugin.props }` to the component through Svelte's `mount()` API.
4949
50-
When the core renders a plugin again, the adapter unmounts the component associated with that container before mounting its replacement. This runs Svelte cleanup and leaves one component instance owning the container.
50+
When the core renders a plugin again, the adapter updates the existing Svelte host with the latest component and props. If the component identity is unchanged, its state and lifecycle remain intact. The adapter unmounts the host when the plugin is destroyed or the Devtools instance shuts down.
5151
5252
```svelte
5353
<!-- Svelte: pass component reference + props -->

‎docs/plugin-lifecycle.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ The Svelte adapter mounts the plugin component directly into the container and f
196196
<TanStackDevtools {plugins} />
197197
```
198198

199-
Internally, the adapter calls `mount(component, { target, props })` with `{ theme, devtoolsOpen, ...plugin.props }`. Before a repeated render, it unmounts the component associated with that container and then mounts the replacement. It also unmounts the active component when the plugin is destroyed. Both paths run the component's Svelte cleanup lifecycle.
199+
Internally, the adapter mounts a Svelte host component into the plugin container with `{ theme, devtoolsOpen, ...plugin.props }`. Repeated renders update that host's component and props, preserving the mounted plugin's state when its component identity is unchanged. The host is unmounted when the plugin is destroyed or the adapter shuts down, which runs the component's Svelte cleanup lifecycle.
200200

201201
### The Key Insight
202202

‎packages/svelte-devtools/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
},
5454
"devDependencies": {
5555
"@sveltejs/vite-plugin-svelte": "^6.0.0",
56-
"@tanstack/devtools-utils": "workspace:*",
5756
"svelte": "^5.0.0"
5857
},
5958
"peerDependencies": {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script lang="ts">
2+
import type { Component } from 'svelte'
3+
4+
interface Props {
5+
component: Component<any>
6+
componentProps: Record<string, unknown>
7+
}
8+
9+
let {
10+
component: CurrentComponent,
11+
componentProps: currentComponentProps,
12+
}: Props = $props()
13+
14+
export function update(
15+
component: Component<any>,
16+
componentProps: Record<string, unknown>,
17+
) {
18+
CurrentComponent = component
19+
currentComponentProps = componentProps
20+
}
21+
</script>
22+
23+
<CurrentComponent {...currentComponentProps} />

‎packages/svelte-devtools/src/devtools.svelte.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { flushSync, mount, unmount } from 'svelte'
22
import { TanStackDevtoolsCore } from '@tanstack/devtools'
3+
import ComponentHost from './ComponentHost.svelte'
34
import type { Component } from 'svelte'
45
import type { TanStackDevtoolsPlugin } from '@tanstack/devtools'
56
import type {
67
TanStackDevtoolsSvelteInit,
78
TanStackDevtoolsSveltePlugin,
89
} from './types'
910

10-
type MountedComponent = ReturnType<typeof mount>
11+
type MountedComponent = ReturnType<typeof ComponentHost>
1112

1213
export class TanStackDevtoolsSvelteAdapter {
1314
private devtools: TanStackDevtoolsCore | null = null
@@ -27,10 +28,6 @@ export class TanStackDevtoolsSvelteAdapter {
2728

2829
update(init: TanStackDevtoolsSvelteInit) {
2930
if (this.devtools) {
30-
// Tear down the previously mounted plugin components before re-applying
31-
// config. The core re-invokes `render`/`name` for the new plugin set, so
32-
// without this the old Svelte instances are orphaned and leak.
33-
this.destroyAllComponents()
3431
this.devtools.setConfig({
3532
config: init.config,
3633
eventBusConfig: init.eventBusConfig,
@@ -95,11 +92,15 @@ export class TanStackDevtoolsSvelteAdapter {
9592
container: HTMLElement,
9693
props: Record<string, unknown>,
9794
) {
98-
this.destroyComponentInContainer(container)
95+
const mounted = this.mountedComponents.get(container)
96+
if (mounted) {
97+
flushSync(() => mounted.update(component, props))
98+
return
99+
}
99100

100-
const instance = mount(component, {
101+
const instance = mount(ComponentHost, {
101102
target: container,
102-
props,
103+
props: { component, componentProps: props },
103104
})
104105
this.mountedComponents.set(container, instance)
105106
flushSync()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte'
3+
4+
interface Props {
5+
devtoolsOpen: boolean
6+
recordDestroy: () => void
7+
recordMount: () => void
8+
recordUpdate: (devtoolsOpen: boolean) => void
9+
}
10+
11+
let { devtoolsOpen, recordDestroy, recordMount, recordUpdate }: Props =
12+
$props()
13+
14+
onMount(() => {
15+
recordMount()
16+
return recordDestroy
17+
})
18+
19+
$effect(() => {
20+
recordUpdate(devtoolsOpen)
21+
})
22+
</script>
23+
24+
<div data-devtools-open={devtoolsOpen}></div>

‎packages/svelte-devtools/tests/devtools.test.ts‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
2-
import { createSveltePanel } from '@tanstack/devtools-utils/svelte'
32
import { TanStackDevtoolsSvelteAdapter } from '../src/devtools.svelte'
3+
import LifecyclePanel from './LifecyclePanel.svelte'
44
import type { TanStackDevtoolsPlugin } from '@tanstack/devtools'
55

66
const { capturePlugins } = vi.hoisted(() => ({ capturePlugins: vi.fn() }))
@@ -22,18 +22,20 @@ describe('TanStackDevtoolsSvelteAdapter', () => {
2222
capturePlugins.mockReset()
2323
})
2424

25-
it('unmounts a panel before rendering its replacement', () => {
26-
const coreMount = vi.fn()
27-
const coreUnmount = vi.fn()
28-
class PanelCore {
29-
mount = coreMount
30-
unmount = coreUnmount
31-
}
32-
const [Panel] = createSveltePanel(PanelCore)
33-
25+
it('updates panel props without replacing the mounted component', () => {
26+
const recordDestroy = vi.fn()
27+
const recordMount = vi.fn()
28+
const recordUpdate = vi.fn()
3429
const adapter = new TanStackDevtoolsSvelteAdapter()
3530
adapter.mount(document.createElement('div'), {
36-
plugins: [{ id: 'test', name: 'Test', component: Panel }],
31+
plugins: [
32+
{
33+
id: 'test',
34+
name: 'Test',
35+
component: LifecyclePanel,
36+
props: { recordDestroy, recordMount, recordUpdate },
37+
},
38+
],
3739
})
3840

3941
const plugins = capturePlugins.mock
@@ -44,12 +46,25 @@ describe('TanStackDevtoolsSvelteAdapter', () => {
4446
}
4547

4648
expect(container.children).toHaveLength(1)
47-
expect(coreMount).toHaveBeenCalledTimes(5)
48-
expect(coreUnmount).toHaveBeenCalledTimes(4)
49+
expect(
50+
container.firstElementChild?.getAttribute('data-devtools-open'),
51+
).toBe('true')
52+
expect(recordMount).toHaveBeenCalledOnce()
53+
expect(recordDestroy).not.toHaveBeenCalled()
54+
expect(recordUpdate.mock.calls.map(([open]) => open)).toEqual([
55+
true,
56+
false,
57+
true,
58+
false,
59+
true,
60+
])
4961

50-
adapter.destroy()
62+
plugins[0]!.destroy?.('test')
5163

5264
expect(container.children).toHaveLength(0)
53-
expect(coreUnmount).toHaveBeenCalledTimes(5)
65+
expect(recordDestroy).toHaveBeenCalledOnce()
66+
67+
adapter.destroy()
68+
expect(recordDestroy).toHaveBeenCalledOnce()
5469
})
5570
})

‎pnpm-lock.yaml‎

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)