Skip to content
Merged
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/rare-owls-find.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/devtools-utils': patch
---

initial release of utils
7 changes: 6 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Expand Down
Empty file.
3 changes: 3 additions & 0 deletions packages/devtools-utils/README.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/devtools-utils/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// @ts-check

import rootConfig from '../../eslint.config.js'

export default [
...rootConfig,
{
rules: {},
},
]
76 changes: 76 additions & 0 deletions packages/devtools-utils/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 2 additions & 0 deletions packages/devtools-utils/src/react/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './panel'
export * from './plugin'
55 changes: 55 additions & 0 deletions packages/devtools-utils/src/react/panel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const devtools = useRef<TCoreDevtoolsClass | null>(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 <div style={{ height: '100%' }} ref={devToolRef} />
}

function NoOpPanel(_props: TComponentProps) {
return <></>
}
return [Panel, NoOpPanel] as const
}
23 changes: 23 additions & 0 deletions packages/devtools-utils/src/react/plugin.tsx
Original file line number Diff line number Diff line change
@@ -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') => (
<Component theme={theme} />
),
}
}
function NoOpPlugin() {
return {
name: name,
render: (_el: HTMLElement, _theme: 'light' | 'dark') => <></>,
}
}
return [Plugin, NoOpPlugin] as const
}
118 changes: 118 additions & 0 deletions packages/devtools-utils/src/solid/class.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => <div>{props.children}</div>)

vi.mock('solid-js', async () => {
const actual = await vi.importActual<any>('solid-js')
return {
...actual,
lazy: lazyImportMock,
}
})

vi.mock('solid-js/web', async () => {
const actual = await vi.importActual<any>('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(() => (
<div>Test Component</div>
))
expect(DevtoolsCore).toBeDefined()
expect(NoOpDevtoolsCore).toBeDefined()
expect(lazyImportMock).not.toHaveBeenCalled()
})

it('DevtoolsCore should call solid primitives when mount is called', async () => {
const [DevtoolsCore, _] = constructCoreClass(() => (
<div>Test Component</div>
))
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(() => (
<div>Test Component</div>
))
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(() => (
<div>Test Component</div>
))
const instance = new DevtoolsCore()
expect(() => instance.unmount()).toThrow('Devtools is not mounted')
})

it('DevtoolsCore should allow mount after unmount', async () => {
const [DevtoolsCore, _] = constructCoreClass(() => (
<div>Test Component</div>
))
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(() => (
<div>Test Component</div>
))
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(() => (
<div>Test Component</div>
))
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(() => (
<div>Test Component</div>
))
const noOpInstance = new NoOpDevtoolsCore()
expect(() => noOpInstance.unmount()).not.toThrow()
})

it('NoOpDevtoolsCore should not throw if unmount is called after mount', async () => {
const [_, NoOpDevtoolsCore] = constructCoreClass(() => (
<div>Test Component</div>
))
const noOpInstance = new NoOpDevtoolsCore()
await noOpInstance.mount(document.createElement('div'), 'dark')
expect(() => noOpInstance.unmount()).not.toThrow()
})
})
74 changes: 74 additions & 0 deletions packages/devtools-utils/src/solid/class.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends HTMLElement>(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 (
<Portal mount={mountTo}>
<div style={{ height: '100%' }}>
<ThemeProvider theme={theme}>
<Devtools />
</ThemeProvider>
</div>
</Portal>
)
}, 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<T extends HTMLElement>(_el: T, _theme: 'light' | 'dark') {}
unmount() {}
}
return [DevtoolsCore, NoOpDevtoolsCore] as const
}

export type ClassType = ReturnType<typeof constructCoreClass>[0]
3 changes: 3 additions & 0 deletions packages/devtools-utils/src/solid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './class'
export * from './panel'
export * from './plugin'
Loading
Loading