diff --git a/.changeset/rare-owls-find.md b/.changeset/rare-owls-find.md new file mode 100644 index 00000000..40dc6276 --- /dev/null +++ b/.changeset/rare-owls-find.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools-utils': patch +--- + +initial release of utils diff --git a/knip.json b/knip.json index ff4cd666..e8af5e1d 100644 --- a/knip.json +++ b/knip.json @@ -1,8 +1,13 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "ignoreDependencies": ["@size-limit/preset-small-lib", "@faker-js/faker"], + "ignoreDependencies": ["@faker-js/faker"], "ignoreWorkspaces": ["examples/**"], "workspaces": { + "packages/devtools-utils": { + "ignoreDependencies": ["react", "solid-js", "@types/react"], + "entry": ["**/vite.config.solid.ts", "**/src/solid/**"], + "project": ["**/vite.config.solid.ts", "**/src/solid/**"] + }, "packages/solid-devtools": { "ignore": ["**/core.tsx"] } diff --git a/packages/devtools-utils/CHANGELOG.md b/packages/devtools-utils/CHANGELOG.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/devtools-utils/README.md b/packages/devtools-utils/README.md new file mode 100644 index 00000000..1dfd3ab3 --- /dev/null +++ b/packages/devtools-utils/README.md @@ -0,0 +1,3 @@ +# @tanstack/devtools-utils + +This package is still under active development and might have breaking changes in the future. Please use it with caution. diff --git a/packages/devtools-utils/eslint.config.js b/packages/devtools-utils/eslint.config.js new file mode 100644 index 00000000..e472c69e --- /dev/null +++ b/packages/devtools-utils/eslint.config.js @@ -0,0 +1,10 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +export default [ + ...rootConfig, + { + rules: {}, + }, +] diff --git a/packages/devtools-utils/package.json b/packages/devtools-utils/package.json new file mode 100644 index 00000000..518f3e29 --- /dev/null +++ b/packages/devtools-utils/package.json @@ -0,0 +1,76 @@ +{ + "name": "@tanstack/devtools-utils", + "version": "0.0.0", + "description": "TanStack Devtools utilities for creating your own devtools.", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/devtools.git", + "directory": "packages/devtools" + }, + "homepage": "https://tanstack.com/devtools", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "devtools" + ], + "type": "module", + "exports": { + "./react": { + "import": { + "types": "./dist/react/esm/index.d.ts", + "default": "./dist/react/esm/index.js" + } + }, + "./solid": { + "import": { + "types": "./dist/solid/esm/index.d.ts", + "default": "./dist/solid/esm/index.js" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@tanstack/devtools-ui": "workspace:^" + }, + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "solid-js": ">=1.9.7" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "solid-js": { + "optional": true + } + }, + "files": [ + "dist/", + "src" + ], + "scripts": { + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:eslint": "eslint ./src", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "test:build": "publint --strict", + "build": "vite build && vite build --config ./vite.config.solid.ts " + }, + "devDependencies": { + "vite-plugin-solid": "^2.11.8" + } +} diff --git a/packages/devtools-utils/src/react/index.ts b/packages/devtools-utils/src/react/index.ts new file mode 100644 index 00000000..37bc5ffd --- /dev/null +++ b/packages/devtools-utils/src/react/index.ts @@ -0,0 +1,2 @@ +export * from './panel' +export * from './plugin' diff --git a/packages/devtools-utils/src/react/panel.tsx b/packages/devtools-utils/src/react/panel.tsx new file mode 100644 index 00000000..c2353564 --- /dev/null +++ b/packages/devtools-utils/src/react/panel.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react' + +export interface DevtoolsPanelProps { + theme?: 'light' | 'dark' +} + +/** + * Creates a React component that dynamically imports and mounts a devtools panel. SSR friendly. + * @param devtoolsPackageName The name of the devtools package to be imported, e.g., '@tanstack/devtools-react' + * @param importName The name of the export to be imported from the devtools package (e.g., 'default' or 'DevtoolsCore') + * @returns A React component that mounts the devtools + * @example + * ```tsx + * // if the export is default + * const [ReactDevtoolsPanel, NoOpReactDevtoolsPanel] = createReactPanel('@tanstack/devtools-react') + * ``` + * + * @example + * ```tsx + * // if the export is named differently + * const [ReactDevtoolsPanel, NoOpReactDevtoolsPanel] = createReactPanel('@tanstack/devtools-react', 'DevtoolsCore') + * ``` + */ +export function createReactPanel< + TComponentProps extends DevtoolsPanelProps | undefined, + TCoreDevtoolsClass extends { + mount: (el: HTMLElement, theme: 'light' | 'dark') => void + unmount: () => void + }, +>(CoreClass: new () => TCoreDevtoolsClass) { + function Panel(props: TComponentProps) { + const devToolRef = useRef(null) + const devtools = useRef(null) + useEffect(() => { + if (devtools.current) return + + devtools.current = new CoreClass() + + if (devToolRef.current) { + devtools.current.mount(devToolRef.current, props?.theme ?? 'dark') + } + + return () => { + devtools.current?.unmount() + } + }, [props?.theme]) + + return
+ } + + function NoOpPanel(_props: TComponentProps) { + return <> + } + return [Panel, NoOpPanel] as const +} diff --git a/packages/devtools-utils/src/react/plugin.tsx b/packages/devtools-utils/src/react/plugin.tsx new file mode 100644 index 00000000..d911bdce --- /dev/null +++ b/packages/devtools-utils/src/react/plugin.tsx @@ -0,0 +1,23 @@ +import type { JSX } from 'react' +import type { DevtoolsPanelProps } from './panel' + +export function createReactPlugin( + name: string, + Component: (props: DevtoolsPanelProps) => JSX.Element, +) { + function Plugin() { + return { + name: name, + render: (_el: HTMLElement, theme: 'light' | 'dark') => ( + + ), + } + } + function NoOpPlugin() { + return { + name: name, + render: (_el: HTMLElement, _theme: 'light' | 'dark') => <>, + } + } + return [Plugin, NoOpPlugin] as const +} diff --git a/packages/devtools-utils/src/solid/class.test.tsx b/packages/devtools-utils/src/solid/class.test.tsx new file mode 100644 index 00000000..80ce341a --- /dev/null +++ b/packages/devtools-utils/src/solid/class.test.tsx @@ -0,0 +1,118 @@ +/** @jsxImportSource solid-js - we use Solid.js as JSX here */ +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}
) + +vi.mock('solid-js', async () => { + const actual = await vi.importActual('solid-js') + return { + ...actual, + lazy: lazyImportMock, + } +}) + +vi.mock('solid-js/web', async () => { + const actual = await vi.importActual('solid-js/web') + return { + ...actual, + render: renderMock, + Portal: portalMock, + } +}) + +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
+ )) + expect(DevtoolsCore).toBeDefined() + expect(NoOpDevtoolsCore).toBeDefined() + expect(lazyImportMock).not.toHaveBeenCalled() + }) + + it('DevtoolsCore should call solid primitives when mount is called', async () => { + const [DevtoolsCore, _] = constructCoreClass(() => ( +
Test Component
+ )) + const instance = new DevtoolsCore() + await instance.mount(document.createElement('div'), 'dark') + expect(renderMock).toHaveBeenCalled() + }) + + it('DevtoolsCore should throw if mount is called twice without unmounting', async () => { + const [DevtoolsCore, _] = constructCoreClass(() => ( +
Test Component
+ )) + const instance = new DevtoolsCore() + await instance.mount(document.createElement('div'), 'dark') + await expect( + instance.mount(document.createElement('div'), 'dark'), + ).rejects.toThrow('Devtools is already mounted') + }) + + it('DevtoolsCore should throw if unmount is called before mount', () => { + const [DevtoolsCore, _] = constructCoreClass(() => ( +
Test Component
+ )) + 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 instance = new DevtoolsCore() + await instance.mount(document.createElement('div'), 'dark') + instance.unmount() + await expect( + instance.mount(document.createElement('div'), 'dark'), + ).resolves.not.toThrow() + }) + + it('NoOpDevtoolsCore should not call any solid primitives when mount is called', async () => { + const [_, NoOpDevtoolsCore] = constructCoreClass(() => ( +
Test Component
+ )) + const noOpInstance = new NoOpDevtoolsCore() + await noOpInstance.mount(document.createElement('div'), 'dark') + + expect(lazyImportMock).not.toHaveBeenCalled() + expect(renderMock).not.toHaveBeenCalled() + expect(portalMock).not.toHaveBeenCalled() + }) + + it('NoOpDevtoolsCore should not throw if mount is called multiple times', async () => { + const [_, NoOpDevtoolsCore] = constructCoreClass(() => ( +
Test Component
+ )) + const noOpInstance = new NoOpDevtoolsCore() + await noOpInstance.mount(document.createElement('div'), 'dark') + await expect( + noOpInstance.mount(document.createElement('div'), 'dark'), + ).resolves.not.toThrow() + }) + + it('NoOpDevtoolsCore should not throw if unmount is called before mount', () => { + const [_, NoOpDevtoolsCore] = constructCoreClass(() => ( +
Test Component
+ )) + 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 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.tsx new file mode 100644 index 00000000..bda0ac56 --- /dev/null +++ b/packages/devtools-utils/src/solid/class.tsx @@ -0,0 +1,74 @@ +/** @jsxImportSource solid-js - we use Solid.js as JSX here */ + +import type { JSX } from 'solid-js' + +/** + * Constructs the core class for the Devtools. + * This utility is used to construct a lazy loaded Solid component for the Devtools. + * 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 + * @returns Tuple containing the DevtoolsCore class and a NoOpDevtoolsCore class + */ +export function constructCoreClass(Component: () => JSX.Element) { + class DevtoolsCore { + #isMounted = false + #dispose?: () => void + #Component: any + #ThemeProvider: any + + constructor() {} + + async mount(el: T, theme: 'light' | 'dark') { + const { lazy } = await import('solid-js') + const { render, Portal } = await import('solid-js/web') + if (this.#isMounted) { + throw new Error('Devtools is already mounted') + } + const mountTo = el + const dispose = render(() => { + // eslint-disable-next-line @typescript-eslint/require-await + this.#Component = lazy(async () => ({ default: Component })) + + this.#ThemeProvider = lazy(() => + import('@tanstack/devtools-ui').then((mod) => ({ + default: mod.ThemeContextProvider, + })), + ) + const Devtools = this.#Component + const ThemeProvider = this.#ThemeProvider + + return ( + +
+ + + +
+
+ ) + }, mountTo) + this.#isMounted = true + this.#dispose = dispose + } + + unmount() { + if (!this.#isMounted) { + throw new Error('Devtools is not mounted') + } + this.#dispose?.() + this.#isMounted = false + } + } + class NoOpDevtoolsCore extends DevtoolsCore { + constructor() { + super() + } + async mount(_el: T, _theme: 'light' | 'dark') {} + unmount() {} + } + return [DevtoolsCore, NoOpDevtoolsCore] as const +} + +export type ClassType = ReturnType[0] diff --git a/packages/devtools-utils/src/solid/index.ts b/packages/devtools-utils/src/solid/index.ts new file mode 100644 index 00000000..ca6ccadc --- /dev/null +++ b/packages/devtools-utils/src/solid/index.ts @@ -0,0 +1,3 @@ +export * from './class' +export * from './panel' +export * from './plugin' diff --git a/packages/devtools-utils/src/solid/panel.tsx b/packages/devtools-utils/src/solid/panel.tsx new file mode 100644 index 00000000..89cdf20a --- /dev/null +++ b/packages/devtools-utils/src/solid/panel.tsx @@ -0,0 +1,36 @@ +/** @jsxImportSource solid-js - we use Solid.js as JSX here */ + +import { onCleanup, onMount } from 'solid-js' +import type { ClassType } from './class' + +export interface DevtoolsPanelProps { + theme?: 'light' | 'dark' +} + +export function createSolidPanel< + TComponentProps extends DevtoolsPanelProps | undefined, +>(CoreClass: ClassType) { + function Panel(props: TComponentProps) { + let devToolRef: HTMLDivElement | undefined + + onMount(() => { + const devtools = new CoreClass() + + if (devToolRef) { + devtools.mount(devToolRef, props?.theme ?? 'dark') + + onCleanup(() => { + devtools.unmount() + }) + } + }) + + return
+ } + + function NoOpPanel(_props: TComponentProps) { + return <> + } + + return [Panel, NoOpPanel] as const +} diff --git a/packages/devtools-utils/src/solid/plugin.tsx b/packages/devtools-utils/src/solid/plugin.tsx new file mode 100644 index 00000000..967dfac4 --- /dev/null +++ b/packages/devtools-utils/src/solid/plugin.tsx @@ -0,0 +1,25 @@ +/** @jsxImportSource solid-js - we use Solid.js as JSX here */ + +import type { JSX } from 'solid-js' +import type { DevtoolsPanelProps } from './panel' + +export function createSolidPlugin( + name: string, + Component: (props: DevtoolsPanelProps) => JSX.Element, +) { + function Plugin() { + return { + name: name, + render: (_el: HTMLElement, theme: 'light' | 'dark') => { + return + }, + } + } + function NoOpPlugin() { + return { + name: name, + render: (_el: HTMLElement, _theme: 'light' | 'dark') => <>, + } + } + return [Plugin, NoOpPlugin] as const +} diff --git a/packages/devtools-utils/tests/test-setup.ts b/packages/devtools-utils/tests/test-setup.ts new file mode 100644 index 00000000..a9d0dd31 --- /dev/null +++ b/packages/devtools-utils/tests/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/packages/devtools-utils/tsconfig.docs.json b/packages/devtools-utils/tsconfig.docs.json new file mode 100644 index 00000000..2880b4df --- /dev/null +++ b/packages/devtools-utils/tsconfig.docs.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["tests", "src"] +} diff --git a/packages/devtools-utils/tsconfig.json b/packages/devtools-utils/tsconfig.json new file mode 100644 index 00000000..179e0a51 --- /dev/null +++ b/packages/devtools-utils/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + }, + "extends": "../../tsconfig.json", + "include": [ + "src", + "eslint.config.js", + "vite.config.ts", + "tests", + "vite.config.solid.ts" + ] +} diff --git a/packages/devtools-utils/tsconfig.solid.json b/packages/devtools-utils/tsconfig.solid.json new file mode 100644 index 00000000..dd4f3ba9 --- /dev/null +++ b/packages/devtools-utils/tsconfig.solid.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js" + }, + "extends": "../../tsconfig.json", + "include": ["src/solid/**"] +} diff --git a/packages/devtools-utils/vite.config.solid.ts b/packages/devtools-utils/vite.config.solid.ts new file mode 100644 index 00000000..b19485cd --- /dev/null +++ b/packages/devtools-utils/vite.config.solid.ts @@ -0,0 +1,35 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import solid from 'vite-plugin-solid' +import packageJson from './package.json' +import tsconfig from './tsconfig.solid.json' + +const config = defineConfig({ + plugins: [ + solid({ + ssr: true, + }), + ], + test: { + name: packageJson.name, + dir: './', + watch: false, + environment: 'jsdom', + setupFiles: ['./tests/test-setup.ts'], + globals: true, + }, + esbuild: { + tsconfigRaw: JSON.stringify(tsconfig), + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/solid/index.ts'], + srcDir: './src/solid', + tsconfigPath: './tsconfig.solid.json', + outDir: './dist/solid', + cjs: false, + }), +) diff --git a/packages/devtools-utils/vite.config.ts b/packages/devtools-utils/vite.config.ts new file mode 100644 index 00000000..8a74dc1e --- /dev/null +++ b/packages/devtools-utils/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import packageJson from './package.json' + +const config = defineConfig({ + plugins: [], + test: { + name: packageJson.name, + dir: './', + watch: false, + environment: 'jsdom', + setupFiles: ['./tests/test-setup.ts'], + globals: true, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/react/index.ts'], + srcDir: './src/react', + outDir: './dist/react', + cjs: false, + }), +) diff --git a/packages/event-bus-client/src/plugin.ts b/packages/event-bus-client/src/plugin.ts index ff155900..22e59fd3 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -4,7 +4,6 @@ interface TanStackDevtoolsEvent { pluginId?: string // Optional pluginId to filter events by plugin } declare global { - // eslint-disable-next-line no-var var __TANSTACK_EVENT_TARGET__: EventTarget | null } diff --git a/packages/event-bus/src/server/server.ts b/packages/event-bus/src/server/server.ts index 1cd35878..21afa8d3 100644 --- a/packages/event-bus/src/server/server.ts +++ b/packages/event-bus/src/server/server.ts @@ -12,11 +12,10 @@ export interface TanStackDevtoolsEvent< } // Used so no new server starts up when HMR happens declare global { - // eslint-disable-next-line no-var var __TANSTACK_DEVTOOLS_SERVER__: http.Server | null - // eslint-disable-next-line no-var + var __TANSTACK_DEVTOOLS_WSS_SERVER__: WebSocketServer | null - // eslint-disable-next-line no-var + var __TANSTACK_EVENT_TARGET__: EventTarget | null } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd7fa14b..3d05b6c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,6 +468,8 @@ importers: specifier: ^4.2.4 version: 4.2.4 + examples/react/start/generated/prisma: {} + examples/react/time-travel: dependencies: '@tanstack/devtools-event-client': @@ -610,6 +612,25 @@ importers: specifier: ^2.11.8 version: 2.11.8(@testing-library/jest-dom@6.8.0)(solid-js@1.9.9)(vite@7.1.7(@types/node@22.15.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + packages/devtools-utils: + dependencies: + '@tanstack/devtools-ui': + specifier: workspace:^ + version: link:../devtools-ui + '@types/react': + specifier: '>=19.0.0' + version: 19.1.13 + react: + specifier: '>=19.0.0' + version: 19.1.1 + solid-js: + specifier: '>=1.9.7' + version: 1.9.9 + devDependencies: + vite-plugin-solid: + specifier: ^2.11.8 + version: 2.11.8(@testing-library/jest-dom@6.8.0)(solid-js@1.9.9)(vite@7.1.7(@types/node@22.15.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + packages/devtools-vite: dependencies: '@babel/core':