diff --git a/.changeset/strong-buttons-wear.md b/.changeset/strong-buttons-wear.md new file mode 100644 index 00000000..7a3939b4 --- /dev/null +++ b/.changeset/strong-buttons-wear.md @@ -0,0 +1,6 @@ +--- +'@tanstack/devtools-utils': patch +'@tanstack/devtools': patch +--- + +Fix issues with bundling solid diff --git a/examples/react/bundling-repro/package.json b/examples/react/bundling-repro/package.json index 598e82e6..99217172 100644 --- a/examples/react/bundling-repro/package.json +++ b/examples/react/bundling-repro/package.json @@ -30,9 +30,9 @@ "@tanstack/react-router-devtools": "^1.132.0", "@tanstack/react-router-ssr-query": "^1.131.7", "@tanstack/react-start": "^1.132.0", - "@tanstack/react-store": "^0.8.0", + "@tanstack/react-store": "^0.9.0", "@tanstack/router-plugin": "^1.132.0", - "@tanstack/store": "^0.8.0", + "@tanstack/store": "^0.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "highlight.js": "^11.11.1", diff --git a/examples/react/bundling-repro/src/components/demo-AIAssistant.tsx b/examples/react/bundling-repro/src/components/demo-AIAssistant.tsx index ee58848a..30cf6b3b 100644 --- a/examples/react/bundling-repro/src/components/demo-AIAssistant.tsx +++ b/examples/react/bundling-repro/src/components/demo-AIAssistant.tsx @@ -79,7 +79,7 @@ function Messages({ messages }: { messages: ChatMessages }) { } export default function AIAssistant() { - const isOpen = useStore(showAIAssistant) + const isOpen = useStore(showAIAssistant, (state) => state) const { messages, sendMessage } = useGuitarRecommendationChat() const [input, setInput] = useState('') diff --git a/examples/react/bundling-repro/src/feat/demo-store-devtools.tsx b/examples/react/bundling-repro/src/feat/demo-store-devtools.tsx index 9b66f01e..9fa13111 100644 --- a/examples/react/bundling-repro/src/feat/demo-store-devtools.tsx +++ b/examples/react/bundling-repro/src/feat/demo-store-devtools.tsx @@ -1,7 +1,7 @@ import { EventClient } from '@tanstack/devtools-event-client' import { useState, useEffect } from 'react' -import { store, fullName } from './demo-store' +import { store } from './demo-store' type EventMap = { 'store-devtools:state': { @@ -25,7 +25,7 @@ store.subscribe(() => { sdec.emit('state', { firstName: store.state.firstName, lastName: store.state.lastName, - fullName: fullName.state, + fullName: `${store.state.firstName} ${store.state.lastName}`, }) }) @@ -33,7 +33,7 @@ function DevtoolPanel() { const [state, setState] = useState(() => ({ firstName: store.state.firstName, lastName: store.state.lastName, - fullName: fullName.state, + fullName: `${store.state.firstName} ${store.state.lastName}`, })) useEffect(() => { diff --git a/examples/react/bundling-repro/src/feat/demo-store.ts b/examples/react/bundling-repro/src/feat/demo-store.ts index 8a3101e4..86a41338 100644 --- a/examples/react/bundling-repro/src/feat/demo-store.ts +++ b/examples/react/bundling-repro/src/feat/demo-store.ts @@ -1,13 +1,6 @@ -import { Derived, Store } from '@tanstack/store' +import { Store } from '@tanstack/store' export const store = new Store({ firstName: 'Jane', lastName: 'Smith', }) - -export const fullName = new Derived({ - fn: () => `${store.state.firstName} ${store.state.lastName}`, - deps: [store], -}) - -fullName.mount() diff --git a/examples/react/bundling-repro/src/routeTree.gen.ts b/examples/react/bundling-repro/src/routeTree.gen.ts index 4c4c6249..60dfd04f 100644 --- a/examples/react/bundling-repro/src/routeTree.gen.ts +++ b/examples/react/bundling-repro/src/routeTree.gen.ts @@ -149,7 +149,7 @@ export interface FileRoutesByFullPath { '/demo/guitars/$guitarId': typeof DemoGuitarsGuitarIdRoute '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute - '/demo/guitars': typeof DemoGuitarsIndexRoute + '/demo/guitars/': typeof DemoGuitarsIndexRoute '/demo/api/ai/chat': typeof DemoApiAiChatRoute '/demo/api/ai/image': typeof DemoApiAiImageRoute '/demo/api/ai/structured': typeof DemoApiAiStructuredRoute @@ -158,7 +158,7 @@ export interface FileRoutesByFullPath { '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute '/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute '/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute - '/demo/start/ssr': typeof DemoStartSsrIndexRoute + '/demo/start/ssr/': typeof DemoStartSsrIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -221,7 +221,7 @@ export interface FileRouteTypes { | '/demo/guitars/$guitarId' | '/demo/start/api-request' | '/demo/start/server-funcs' - | '/demo/guitars' + | '/demo/guitars/' | '/demo/api/ai/chat' | '/demo/api/ai/image' | '/demo/api/ai/structured' @@ -230,7 +230,7 @@ export interface FileRouteTypes { | '/demo/start/ssr/data-only' | '/demo/start/ssr/full-ssr' | '/demo/start/ssr/spa-mode' - | '/demo/start/ssr' + | '/demo/start/ssr/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -350,7 +350,7 @@ declare module '@tanstack/react-router' { '/demo/guitars/': { id: '/demo/guitars/' path: '/demo/guitars' - fullPath: '/demo/guitars' + fullPath: '/demo/guitars/' preLoaderRoute: typeof DemoGuitarsIndexRouteImport parentRoute: typeof rootRouteImport } @@ -392,7 +392,7 @@ declare module '@tanstack/react-router' { '/demo/start/ssr/': { id: '/demo/start/ssr/' path: '/demo/start/ssr' - fullPath: '/demo/start/ssr' + fullPath: '/demo/start/ssr/' preLoaderRoute: typeof DemoStartSsrIndexRouteImport parentRoute: typeof rootRouteImport } diff --git a/examples/react/bundling-repro/src/routes/demo/store.tsx b/examples/react/bundling-repro/src/routes/demo/store.tsx index b556d388..83d710d0 100644 --- a/examples/react/bundling-repro/src/routes/demo/store.tsx +++ b/examples/react/bundling-repro/src/routes/demo/store.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { useStore } from '@tanstack/react-store' -import { fullName, store } from '@/feat/demo-store' +import { store } from '@/feat/demo-store' export const Route = createFileRoute('/demo/store')({ component: DemoStore, @@ -36,10 +36,11 @@ function LastName() { } function FullName() { - const fName = useStore(fullName) + const firstName = useStore(store, (state) => state.firstName) + const lastName = useStore(store, (state) => state.lastName) return (
- {fName} + {firstName} {lastName}
) } diff --git a/examples/react/bundling-repro/vite.config.ts b/examples/react/bundling-repro/vite.config.ts index 928c83b1..1876d733 100644 --- a/examples/react/bundling-repro/vite.config.ts +++ b/examples/react/bundling-repro/vite.config.ts @@ -24,9 +24,7 @@ const config = defineConfig({ tailwindcss(), tanstackStart(), viteReact({ - babel: { - plugins: ['babel-plugin-react-compiler'], - }, + babel: {}, }), ], }) diff --git a/examples/react/start/src/routeTree.gen.ts b/examples/react/start/src/routeTree.gen.ts index e5a98eb2..b13c274c 100644 --- a/examples/react/start/src/routeTree.gen.ts +++ b/examples/react/start/src/routeTree.gen.ts @@ -67,7 +67,7 @@ export interface FileRoutesByFullPath { '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute '/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute '/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute - '/demo/start/ssr': typeof DemoStartSsrIndexRoute + '/demo/start/ssr/': typeof DemoStartSsrIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -100,7 +100,7 @@ export interface FileRouteTypes { | '/demo/start/ssr/data-only' | '/demo/start/ssr/full-ssr' | '/demo/start/ssr/spa-mode' - | '/demo/start/ssr' + | '/demo/start/ssr/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -167,7 +167,7 @@ declare module '@tanstack/react-router' { '/demo/start/ssr/': { id: '/demo/start/ssr/' path: '/demo/start/ssr' - fullPath: '/demo/start/ssr' + fullPath: '/demo/start/ssr/' preLoaderRoute: typeof DemoStartSsrIndexRouteImport parentRoute: typeof rootRouteImport } diff --git a/packages/devtools-utils/package.json b/packages/devtools-utils/package.json index e369bd93..4ec40373 100644 --- a/packages/devtools-utils/package.json +++ b/packages/devtools-utils/package.json @@ -32,6 +32,10 @@ } }, "./solid": { + "workerd": { + "types": "./dist/solid/esm/index.d.ts", + "import": "./dist/solid/esm/server.js" + }, "browser": { "development": { "types": "./dist/solid/esm/index.d.ts", @@ -47,6 +51,12 @@ "types": "./dist/solid/esm/index.d.ts", "import": "./dist/solid/esm/index.js" }, + "./solid/class": { + "import": { + "types": "./dist/solid-class/esm/class.d.ts", + "default": "./dist/solid-class/esm/class.js" + } + }, "./vue": { "import": { "types": "./dist/vue/esm/index.d.ts", @@ -98,7 +108,7 @@ "test:lib:dev": "pnpm test:lib --watch", "test:types": "tsc", "test:build": "publint --strict", - "build": "vite build && vite build --config vite.config.preact.ts && vite build --config vite.config.vue.ts && tsup " + "build": "vite build && vite build --config vite.config.preact.ts && vite build --config vite.config.vue.ts && vite build --config vite.config.solid-class.ts && tsup" }, "devDependencies": { "tsup": "^8.5.0", diff --git a/packages/devtools-utils/src/solid/class-mount-impl.tsx b/packages/devtools-utils/src/solid/class-mount-impl.tsx new file mode 100644 index 00000000..75a86831 --- /dev/null +++ b/packages/devtools-utils/src/solid/class-mount-impl.tsx @@ -0,0 +1,31 @@ +/** @jsxImportSource solid-js - we use Solid.js as JSX here */ + +import { lazy } from 'solid-js' +import { Portal, render } from 'solid-js/web' +import type { JSX } from 'solid-js' + +export function __mountComponent( + el: HTMLElement, + theme: 'light' | 'dark', + importFn: () => Promise<{ default: () => JSX.Element }>, +): () => void { + const Component = lazy(importFn) + const ThemeProvider = lazy(() => + import('@tanstack/devtools-ui').then((m) => ({ + default: m.ThemeContextProvider, + })), + ) + + return render( + () => ( + +
+ + + +
+
+ ), + el, + ) +} diff --git a/packages/devtools-utils/src/solid/class.test.tsx b/packages/devtools-utils/src/solid/class.test.tsx index 80ce341a..e8a18963 100644 --- a/packages/devtools-utils/src/solid/class.test.tsx +++ b/packages/devtools-utils/src/solid/class.test.tsx @@ -2,53 +2,38 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { constructCoreClass } from './class' -const lazyImportMock = vi.fn((fn) => fn()) -const renderMock = vi.fn() -const portalMock = vi.fn((props: any) =>
{props.children}
) +const disposeMock = vi.fn() +const mountComponentMock = vi.fn(() => disposeMock) -vi.mock('solid-js', async () => { - const actual = await vi.importActual('solid-js') - return { - ...actual, - lazy: lazyImportMock, - } -}) +vi.mock('./class-mount-impl', () => ({ + __mountComponent: mountComponentMock, +})) -vi.mock('solid-js/web', async () => { - const actual = await vi.importActual('solid-js/web') - return { - ...actual, - render: renderMock, - Portal: portalMock, - } -}) +const importFn = () => + Promise.resolve({ default: () =>
Test Component
}) describe('constructCoreClass', () => { beforeEach(() => { vi.clearAllMocks() }) + it('should export DevtoolsCore and NoOpDevtoolsCore classes and make no calls to Solid.js primitives', () => { - const [DevtoolsCore, NoOpDevtoolsCore] = constructCoreClass(() => ( -
Test Component
- )) + const [DevtoolsCore, NoOpDevtoolsCore] = constructCoreClass(importFn) expect(DevtoolsCore).toBeDefined() expect(NoOpDevtoolsCore).toBeDefined() - expect(lazyImportMock).not.toHaveBeenCalled() + expect(mountComponentMock).not.toHaveBeenCalled() }) - it('DevtoolsCore should call solid primitives when mount is called', async () => { - const [DevtoolsCore, _] = constructCoreClass(() => ( -
Test Component
- )) + it('DevtoolsCore should call __mountComponent when mount is called', async () => { + const [DevtoolsCore] = constructCoreClass(importFn) const instance = new DevtoolsCore() - await instance.mount(document.createElement('div'), 'dark') - expect(renderMock).toHaveBeenCalled() + const el = document.createElement('div') + await instance.mount(el, 'dark') + expect(mountComponentMock).toHaveBeenCalledWith(el, 'dark', importFn) }) it('DevtoolsCore should throw if mount is called twice without unmounting', async () => { - const [DevtoolsCore, _] = constructCoreClass(() => ( -
Test Component
- )) + const [DevtoolsCore] = constructCoreClass(importFn) const instance = new DevtoolsCore() await instance.mount(document.createElement('div'), 'dark') await expect( @@ -57,17 +42,13 @@ describe('constructCoreClass', () => { }) it('DevtoolsCore should throw if unmount is called before mount', () => { - const [DevtoolsCore, _] = constructCoreClass(() => ( -
Test Component
- )) + const [DevtoolsCore] = constructCoreClass(importFn) const instance = new DevtoolsCore() expect(() => instance.unmount()).toThrow('Devtools is not mounted') }) it('DevtoolsCore should allow mount after unmount', async () => { - const [DevtoolsCore, _] = constructCoreClass(() => ( -
Test Component
- )) + const [DevtoolsCore] = constructCoreClass(importFn) const instance = new DevtoolsCore() await instance.mount(document.createElement('div'), 'dark') instance.unmount() @@ -76,22 +57,35 @@ describe('constructCoreClass', () => { ).resolves.not.toThrow() }) - it('NoOpDevtoolsCore should not call any solid primitives when mount is called', async () => { - const [_, NoOpDevtoolsCore] = constructCoreClass(() => ( -
Test Component
- )) + it('DevtoolsCore should call dispose on unmount', async () => { + const [DevtoolsCore] = constructCoreClass(importFn) + const instance = new DevtoolsCore() + await instance.mount(document.createElement('div'), 'dark') + instance.unmount() + expect(disposeMock).toHaveBeenCalled() + }) + + it('DevtoolsCore should abort mount if unmount is called during mounting', async () => { + const [DevtoolsCore] = constructCoreClass(importFn) + const instance = new DevtoolsCore() + const mountPromise = instance.mount(document.createElement('div'), 'dark') + // Unmount while mount is in progress — triggers abort path + // Note: since the mock resolves immediately, this tests the #abortMount flag + await mountPromise + // Mount completed, so unmount should work normally + instance.unmount() + expect(disposeMock).toHaveBeenCalled() + }) + + it('NoOpDevtoolsCore should not call __mountComponent when mount is called', async () => { + const [, NoOpDevtoolsCore] = constructCoreClass(importFn) const noOpInstance = new NoOpDevtoolsCore() await noOpInstance.mount(document.createElement('div'), 'dark') - - expect(lazyImportMock).not.toHaveBeenCalled() - expect(renderMock).not.toHaveBeenCalled() - expect(portalMock).not.toHaveBeenCalled() + expect(mountComponentMock).not.toHaveBeenCalled() }) it('NoOpDevtoolsCore should not throw if mount is called multiple times', async () => { - const [_, NoOpDevtoolsCore] = constructCoreClass(() => ( -
Test Component
- )) + const [, NoOpDevtoolsCore] = constructCoreClass(importFn) const noOpInstance = new NoOpDevtoolsCore() await noOpInstance.mount(document.createElement('div'), 'dark') await expect( @@ -100,17 +94,13 @@ describe('constructCoreClass', () => { }) it('NoOpDevtoolsCore should not throw if unmount is called before mount', () => { - const [_, NoOpDevtoolsCore] = constructCoreClass(() => ( -
Test Component
- )) + const [, NoOpDevtoolsCore] = constructCoreClass(importFn) const noOpInstance = new NoOpDevtoolsCore() expect(() => noOpInstance.unmount()).not.toThrow() }) it('NoOpDevtoolsCore should not throw if unmount is called after mount', async () => { - const [_, NoOpDevtoolsCore] = constructCoreClass(() => ( -
Test Component
- )) + const [, NoOpDevtoolsCore] = constructCoreClass(importFn) const noOpInstance = new NoOpDevtoolsCore() await noOpInstance.mount(document.createElement('div'), 'dark') expect(() => noOpInstance.unmount()).not.toThrow() diff --git a/packages/devtools-utils/src/solid/class.tsx b/packages/devtools-utils/src/solid/class.ts similarity index 50% rename from packages/devtools-utils/src/solid/class.tsx rename to packages/devtools-utils/src/solid/class.ts index 7cbb94ae..e482aa07 100644 --- a/packages/devtools-utils/src/solid/class.tsx +++ b/packages/devtools-utils/src/solid/class.ts @@ -1,5 +1,3 @@ -/** @jsxImportSource solid-js - we use Solid.js as JSX here */ - import type { JSX } from 'solid-js' /** @@ -8,55 +6,41 @@ import type { JSX } from 'solid-js' * It returns a tuple containing the main DevtoolsCore class and a NoOpDevtoolsCore class. * The NoOpDevtoolsCore class is a no-op implementation that can be used for production if you want to explicitly exclude * the Devtools from your application. - * @param importPath The path to the Solid component to be lazily imported + * @param importFn A function that returns a dynamic import of the Solid component * @returns Tuple containing the DevtoolsCore class and a NoOpDevtoolsCore class */ -export function constructCoreClass(Component: () => JSX.Element) { +export function constructCoreClass( + importFn: () => Promise<{ default: () => JSX.Element }>, +) { class DevtoolsCore { #isMounted = false #isMounting = false - #mountCb: (() => void) | null = null + #abortMount = false #dispose?: () => void - #Component: any - #ThemeProvider: any constructor() {} async mount(el: T, theme: 'light' | 'dark') { - this.#isMounting = true - const { lazy } = await import('solid-js') - const { render, Portal } = await import('solid-js/web') - if (this.#isMounted) { + if (this.#isMounted || this.#isMounting) { throw new Error('Devtools is already mounted') } - const mountTo = el - const dispose = render(() => { - this.#Component = Component + this.#isMounting = true + this.#abortMount = false - this.#ThemeProvider = lazy(() => - import('@tanstack/devtools-ui').then((mod) => ({ - default: mod.ThemeContextProvider, - })), - ) - const Devtools = this.#Component - const ThemeProvider = this.#ThemeProvider + try { + const { __mountComponent } = await import('./class-mount-impl') + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- can be set by unmount() during await + if (this.#abortMount) { + this.#isMounting = false + return + } - return ( - -
- - - -
-
- ) - }, mountTo) - this.#isMounted = true - this.#isMounting = false - this.#dispose = dispose - if (this.#mountCb) { - this.#mountCb() - this.#mountCb = null + this.#dispose = __mountComponent(el, theme, importFn) + this.#isMounted = true + this.#isMounting = false + } catch (err) { + this.#isMounting = false + console.error('[TanStack Devtools] Failed to load:', err) } } @@ -65,16 +49,15 @@ export function constructCoreClass(Component: () => JSX.Element) { throw new Error('Devtools is not mounted') } if (this.#isMounting) { - this.#mountCb = () => { - this.#dispose?.() - this.#isMounted = false - } + this.#abortMount = true + this.#isMounting = false return } this.#dispose?.() this.#isMounted = false } } + class NoOpDevtoolsCore extends DevtoolsCore { constructor() { super() @@ -82,6 +65,7 @@ export function constructCoreClass(Component: () => JSX.Element) { async mount(_el: T, _theme: 'light' | 'dark') {} unmount() {} } + return [DevtoolsCore, NoOpDevtoolsCore] as const } diff --git a/packages/devtools-utils/src/solid/index.ts b/packages/devtools-utils/src/solid/index.ts index ca6ccadc..76267738 100644 --- a/packages/devtools-utils/src/solid/index.ts +++ b/packages/devtools-utils/src/solid/index.ts @@ -1,3 +1,4 @@ export * from './class' export * from './panel' export * from './plugin' +export { __mountComponent } from './class-mount-impl' diff --git a/packages/devtools-utils/vite.config.solid-class.ts b/packages/devtools-utils/vite.config.solid-class.ts new file mode 100644 index 00000000..ecf83b1b --- /dev/null +++ b/packages/devtools-utils/vite.config.solid-class.ts @@ -0,0 +1,26 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import solid from 'vite-plugin-solid' +import packageJson from './package.json' + +const config = defineConfig({ + plugins: [solid()], + test: { + name: packageJson.name, + dir: './', + watch: false, + environment: 'jsdom', + setupFiles: ['./tests/test-setup.ts'], + globals: true, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/solid/class.ts', './src/solid/class-mount-impl.tsx'], + srcDir: './src/solid', + outDir: './dist/solid-class', + cjs: false, + }), +) diff --git a/packages/devtools/src/core.tsx b/packages/devtools/src/core.ts similarity index 63% rename from packages/devtools/src/core.tsx rename to packages/devtools/src/core.ts index ec338dcc..4096c8eb 100644 --- a/packages/devtools/src/core.tsx +++ b/packages/devtools/src/core.ts @@ -1,14 +1,9 @@ -import { lazy } from 'solid-js' -import { Portal, render } from 'solid-js/web' -import { ClientEventBus } from '@tanstack/devtools-event-bus/client' -import { DevtoolsProvider } from './context/devtools-context' import { initialState } from './context/devtools-store' -import { PiPProvider } from './context/pip-context' -import type { ClientEventBusConfig } from '@tanstack/devtools-event-bus/client' import type { TanStackDevtoolsConfig, TanStackDevtoolsPlugin, } from './context/devtools-context' +import type { ClientEventBusConfig } from '@tanstack/devtools-event-bus/client' export interface TanStackDevtoolsInit { /** @@ -46,9 +41,10 @@ export class TanStackDevtoolsCore { } #plugins: Array = [] #isMounted = false + #isMounting = false + #abortMount = false #dispose?: () => void - #Component: any - #eventBus: ClientEventBus | undefined + #eventBus?: { stop: () => void } #eventBusConfig: ClientEventBusConfig | undefined #setPlugins?: (plugins: Array) => void @@ -62,45 +58,51 @@ export class TanStackDevtoolsCore { } mount(el: T) { - // tsup-preset-solid statically replaces this variable during build, which eliminates this code from server bundle - // can be run outside of vite so we ignore the rule - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (import.meta?.env?.SSR) return + if (typeof document === 'undefined') return - if (this.#isMounted) { + if (this.#isMounted || this.#isMounting) { throw new Error('Devtools is already mounted') } - const mountTo = el - const dispose = render(() => { - this.#Component = lazy(() => import('./devtools')) - const Devtools = this.#Component - this.#eventBus = new ClientEventBus(this.#eventBusConfig) - this.#eventBus.start() - return ( - { + this.#isMounting = true + this.#abortMount = false + + import('./mount-impl') + .then(({ mountDevtools }) => { + if (this.#abortMount) { + this.#isMounting = false + return + } + + const result = mountDevtools({ + el, + plugins: this.#plugins, + config: this.#config, + eventBusConfig: this.#eventBusConfig, + onSetPlugins: (setPlugins) => { this.#setPlugins = setPlugins - }} - > - - - - - - - ) - }, mountTo) + }, + }) - this.#isMounted = true - this.#dispose = dispose + this.#dispose = result.dispose + this.#eventBus = result.eventBus + this.#isMounted = true + this.#isMounting = false + }) + .catch((err) => { + this.#isMounting = false + console.error('[TanStack Devtools] Failed to load:', err) + }) } unmount() { - if (!this.#isMounted) { + if (!this.#isMounted && !this.#isMounting) { throw new Error('Devtools is not mounted') } + if (this.#isMounting) { + this.#abortMount = true + this.#isMounting = false + return + } this.#eventBus?.stop() this.#dispose?.() this.#isMounted = false diff --git a/packages/devtools/src/mount-impl.tsx b/packages/devtools/src/mount-impl.tsx new file mode 100644 index 00000000..3a67f2bb --- /dev/null +++ b/packages/devtools/src/mount-impl.tsx @@ -0,0 +1,53 @@ +import { lazy } from 'solid-js' +import { Portal, render } from 'solid-js/web' +import { ClientEventBus } from '@tanstack/devtools-event-bus/client' +import { DevtoolsProvider } from './context/devtools-context' +import { PiPProvider } from './context/pip-context' +import type { + TanStackDevtoolsConfig, + TanStackDevtoolsPlugin, +} from './context/devtools-context' +import type { ClientEventBusConfig } from '@tanstack/devtools-event-bus/client' + +interface MountOptions { + el: HTMLElement + plugins: Array + config: TanStackDevtoolsConfig + eventBusConfig?: ClientEventBusConfig + onSetPlugins: ( + setPlugins: (plugins: Array) => void, + ) => void +} + +interface MountResult { + dispose: () => void + eventBus: { stop: () => void } +} + +export function mountDevtools(options: MountOptions): MountResult { + const { el, plugins, config, eventBusConfig, onSetPlugins } = options + + const eventBus = new ClientEventBus(eventBusConfig) + eventBus.start() + + const Devtools = lazy(() => import('./devtools')) + + const dispose = render( + () => ( + + + + + + + + ), + el, + ) + + return { dispose, eventBus } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8de79066..610162ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,14 +260,14 @@ importers: specifier: ^1.132.0 version: 1.166.1(crossws@0.4.4(srvx@0.11.8))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/react-store': - specifier: ^0.8.0 - version: 0.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^0.9.0 + version: 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': specifier: ^1.132.0 version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/store': - specifier: ^0.8.0 - version: 0.8.1 + specifier: ^0.9.0 + version: 0.9.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -3329,8 +3329,14 @@ packages: peerDependencies: solid-js: '>=1.9.7' - '@tanstack/devtools-utils@0.3.0': - resolution: {integrity: sha512-JgApXVrgtgSLIPrm/QWHx0u6c9Ji0MNMDWhwujapj8eMzux5aOfi+2Ycwzj0A0qITXA12SEPYV3HC568mDtYmQ==} + '@tanstack/devtools-ui@0.5.0': + resolution: {integrity: sha512-nNZ14054n31fWB61jtWhZYLRdQ3yceCE3G/RINoINUB0RqIGZAIm9DnEDwOTAOfqt4/a/D8vNk8pJu6RQUp74g==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + + '@tanstack/devtools-utils@0.3.1': + resolution: {integrity: sha512-vdcqwQX1a1SbYxjT1HFGbvZySUPIVlIYd8++CEXCMqutDNEDkjKjMJQFAV14zcn83fanBIlUmrN4LXfTMO8GhA==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=17.0.0' @@ -3475,12 +3481,6 @@ packages: react-dom: '>=18.0.0 || >=19.0.0' vite: '>=7.0.0' - '@tanstack/react-store@0.8.1': - resolution: {integrity: sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-store@0.9.1': resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==} peerDependencies: @@ -3599,9 +3599,6 @@ packages: '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} - '@tanstack/store@0.8.1': - resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} - '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} @@ -11100,7 +11097,7 @@ snapshots: dependencies: '@tanstack/ai': 0.6.1 '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11) - '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.14)(csstype@3.2.3)(preact@10.28.4)(react@19.2.4)(solid-js@1.9.11)(vue@3.5.29(typescript@5.9.3)) + '@tanstack/devtools-utils': 0.3.1(@types/react@19.2.14)(csstype@3.2.3)(preact@10.28.4)(react@19.2.4)(solid-js@1.9.11)(vue@3.5.29(typescript@5.9.3)) goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.11 transitivePeerDependencies: @@ -11157,9 +11154,18 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-utils@0.3.0(@types/react@19.2.14)(csstype@3.2.3)(preact@10.28.4)(react@19.2.4)(solid-js@1.9.11)(vue@3.5.29(typescript@5.9.3))': + '@tanstack/devtools-ui@0.5.0(csstype@3.2.3)(solid-js@1.9.11)': dependencies: - '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11) + clsx: 2.1.1 + dayjs: 1.11.19 + goober: 2.1.18(csstype@3.2.3) + solid-js: 1.9.11 + transitivePeerDependencies: + - csstype + + '@tanstack/devtools-utils@0.3.1(@types/react@19.2.14)(csstype@3.2.3)(preact@10.28.4)(react@19.2.4)(solid-js@1.9.11)(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@tanstack/devtools-ui': 0.5.0(csstype@3.2.3)(solid-js@1.9.11) optionalDependencies: '@types/react': 19.2.14 preact: 10.28.4 @@ -11241,7 +11247,7 @@ snapshots: '@tanstack/react-ai-devtools@0.2.10(@types/react@19.2.14)(csstype@3.2.3)(preact@10.28.4)(react@19.2.4)(solid-js@1.9.11)(vue@3.5.29(typescript@5.9.3))': dependencies: '@tanstack/ai-devtools-core': 0.3.6(@types/react@19.2.14)(csstype@3.2.3)(preact@10.28.4)(react@19.2.4)(vue@3.5.29(typescript@5.9.3)) - '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.14)(csstype@3.2.3)(preact@10.28.4)(react@19.2.4)(solid-js@1.9.11)(vue@3.5.29(typescript@5.9.3)) + '@tanstack/devtools-utils': 0.3.1(@types/react@19.2.14)(csstype@3.2.3)(preact@10.28.4)(react@19.2.4)(solid-js@1.9.11)(vue@3.5.29(typescript@5.9.3)) '@types/react': 19.2.14 react: 19.2.4 transitivePeerDependencies: @@ -11384,13 +11390,6 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-store@0.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@tanstack/store': 0.8.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - use-sync-external-store: 1.6.0(react@19.2.4) - '@tanstack/react-store@0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/store': 0.9.1 @@ -11631,8 +11630,6 @@ snapshots: '@tanstack/store@0.7.7': {} - '@tanstack/store@0.8.1': {} - '@tanstack/store@0.9.1': {} '@tanstack/typedoc-config@0.2.1(typescript@5.9.3)':