Skip to content

Commit 0772a4c

Browse files
feat: add initial utils package (#166)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e7f7720 commit 0772a4c

23 files changed

+546
-5
lines changed

.changeset/rare-owls-find.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/devtools-utils': patch
3+
---
4+
5+
initial release of utils

knip.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
{
22
"$schema": "https://unpkg.com/knip@5/schema.json",
3-
"ignoreDependencies": ["@size-limit/preset-small-lib", "@faker-js/faker"],
3+
"ignoreDependencies": ["@faker-js/faker"],
44
"ignoreWorkspaces": ["examples/**"],
55
"workspaces": {
6+
"packages/devtools-utils": {
7+
"ignoreDependencies": ["react", "solid-js", "@types/react"],
8+
"entry": ["**/vite.config.solid.ts", "**/src/solid/**"],
9+
"project": ["**/vite.config.solid.ts", "**/src/solid/**"]
10+
},
611
"packages/solid-devtools": {
712
"ignore": ["**/core.tsx"]
813
}

packages/devtools-utils/CHANGELOG.md

Whitespace-only changes.

packages/devtools-utils/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @tanstack/devtools-utils
2+
3+
This package is still under active development and might have breaking changes in the future. Please use it with caution.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// @ts-check
2+
3+
import rootConfig from '../../eslint.config.js'
4+
5+
export default [
6+
...rootConfig,
7+
{
8+
rules: {},
9+
},
10+
]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"name": "@tanstack/devtools-utils",
3+
"version": "0.0.0",
4+
"description": "TanStack Devtools utilities for creating your own devtools.",
5+
"author": "Tanner Linsley",
6+
"license": "MIT",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/TanStack/devtools.git",
10+
"directory": "packages/devtools"
11+
},
12+
"homepage": "https://tanstack.com/devtools",
13+
"funding": {
14+
"type": "github",
15+
"url": "https://github.com/sponsors/tannerlinsley"
16+
},
17+
"keywords": [
18+
"devtools"
19+
],
20+
"type": "module",
21+
"exports": {
22+
"./react": {
23+
"import": {
24+
"types": "./dist/react/esm/index.d.ts",
25+
"default": "./dist/react/esm/index.js"
26+
}
27+
},
28+
"./solid": {
29+
"import": {
30+
"types": "./dist/solid/esm/index.d.ts",
31+
"default": "./dist/solid/esm/index.js"
32+
}
33+
},
34+
"./package.json": "./package.json"
35+
},
36+
"sideEffects": false,
37+
"engines": {
38+
"node": ">=18"
39+
},
40+
"dependencies": {
41+
"@tanstack/devtools-ui": "workspace:^"
42+
},
43+
"peerDependencies": {
44+
"@types/react": ">=19.0.0",
45+
"react": ">=19.0.0",
46+
"solid-js": ">=1.9.7"
47+
},
48+
"peerDependenciesMeta": {
49+
"react": {
50+
"optional": true
51+
},
52+
"@types/react": {
53+
"optional": true
54+
},
55+
"solid-js": {
56+
"optional": true
57+
}
58+
},
59+
"files": [
60+
"dist/",
61+
"src"
62+
],
63+
"scripts": {
64+
"clean": "premove ./build ./dist",
65+
"lint:fix": "eslint ./src --fix",
66+
"test:eslint": "eslint ./src",
67+
"test:lib": "vitest",
68+
"test:lib:dev": "pnpm test:lib --watch",
69+
"test:types": "tsc",
70+
"test:build": "publint --strict",
71+
"build": "vite build && vite build --config ./vite.config.solid.ts "
72+
},
73+
"devDependencies": {
74+
"vite-plugin-solid": "^2.11.8"
75+
}
76+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './panel'
2+
export * from './plugin'
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useEffect, useRef } from 'react'
2+
3+
export interface DevtoolsPanelProps {
4+
theme?: 'light' | 'dark'
5+
}
6+
7+
/**
8+
* Creates a React component that dynamically imports and mounts a devtools panel. SSR friendly.
9+
* @param devtoolsPackageName The name of the devtools package to be imported, e.g., '@tanstack/devtools-react'
10+
* @param importName The name of the export to be imported from the devtools package (e.g., 'default' or 'DevtoolsCore')
11+
* @returns A React component that mounts the devtools
12+
* @example
13+
* ```tsx
14+
* // if the export is default
15+
* const [ReactDevtoolsPanel, NoOpReactDevtoolsPanel] = createReactPanel('@tanstack/devtools-react')
16+
* ```
17+
*
18+
* @example
19+
* ```tsx
20+
* // if the export is named differently
21+
* const [ReactDevtoolsPanel, NoOpReactDevtoolsPanel] = createReactPanel('@tanstack/devtools-react', 'DevtoolsCore')
22+
* ```
23+
*/
24+
export function createReactPanel<
25+
TComponentProps extends DevtoolsPanelProps | undefined,
26+
TCoreDevtoolsClass extends {
27+
mount: (el: HTMLElement, theme: 'light' | 'dark') => void
28+
unmount: () => void
29+
},
30+
>(CoreClass: new () => TCoreDevtoolsClass) {
31+
function Panel(props: TComponentProps) {
32+
const devToolRef = useRef<HTMLDivElement>(null)
33+
const devtools = useRef<TCoreDevtoolsClass | null>(null)
34+
useEffect(() => {
35+
if (devtools.current) return
36+
37+
devtools.current = new CoreClass()
38+
39+
if (devToolRef.current) {
40+
devtools.current.mount(devToolRef.current, props?.theme ?? 'dark')
41+
}
42+
43+
return () => {
44+
devtools.current?.unmount()
45+
}
46+
}, [props?.theme])
47+
48+
return <div style={{ height: '100%' }} ref={devToolRef} />
49+
}
50+
51+
function NoOpPanel(_props: TComponentProps) {
52+
return <></>
53+
}
54+
return [Panel, NoOpPanel] as const
55+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { JSX } from 'react'
2+
import type { DevtoolsPanelProps } from './panel'
3+
4+
export function createReactPlugin(
5+
name: string,
6+
Component: (props: DevtoolsPanelProps) => JSX.Element,
7+
) {
8+
function Plugin() {
9+
return {
10+
name: name,
11+
render: (_el: HTMLElement, theme: 'light' | 'dark') => (
12+
<Component theme={theme} />
13+
),
14+
}
15+
}
16+
function NoOpPlugin() {
17+
return {
18+
name: name,
19+
render: (_el: HTMLElement, _theme: 'light' | 'dark') => <></>,
20+
}
21+
}
22+
return [Plugin, NoOpPlugin] as const
23+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/** @jsxImportSource solid-js - we use Solid.js as JSX here */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { constructCoreClass } from './class'
4+
5+
const lazyImportMock = vi.fn((fn) => fn())
6+
const renderMock = vi.fn()
7+
const portalMock = vi.fn((props: any) => <div>{props.children}</div>)
8+
9+
vi.mock('solid-js', async () => {
10+
const actual = await vi.importActual<any>('solid-js')
11+
return {
12+
...actual,
13+
lazy: lazyImportMock,
14+
}
15+
})
16+
17+
vi.mock('solid-js/web', async () => {
18+
const actual = await vi.importActual<any>('solid-js/web')
19+
return {
20+
...actual,
21+
render: renderMock,
22+
Portal: portalMock,
23+
}
24+
})
25+
26+
describe('constructCoreClass', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks()
29+
})
30+
it('should export DevtoolsCore and NoOpDevtoolsCore classes and make no calls to Solid.js primitives', () => {
31+
const [DevtoolsCore, NoOpDevtoolsCore] = constructCoreClass(() => (
32+
<div>Test Component</div>
33+
))
34+
expect(DevtoolsCore).toBeDefined()
35+
expect(NoOpDevtoolsCore).toBeDefined()
36+
expect(lazyImportMock).not.toHaveBeenCalled()
37+
})
38+
39+
it('DevtoolsCore should call solid primitives when mount is called', async () => {
40+
const [DevtoolsCore, _] = constructCoreClass(() => (
41+
<div>Test Component</div>
42+
))
43+
const instance = new DevtoolsCore()
44+
await instance.mount(document.createElement('div'), 'dark')
45+
expect(renderMock).toHaveBeenCalled()
46+
})
47+
48+
it('DevtoolsCore should throw if mount is called twice without unmounting', async () => {
49+
const [DevtoolsCore, _] = constructCoreClass(() => (
50+
<div>Test Component</div>
51+
))
52+
const instance = new DevtoolsCore()
53+
await instance.mount(document.createElement('div'), 'dark')
54+
await expect(
55+
instance.mount(document.createElement('div'), 'dark'),
56+
).rejects.toThrow('Devtools is already mounted')
57+
})
58+
59+
it('DevtoolsCore should throw if unmount is called before mount', () => {
60+
const [DevtoolsCore, _] = constructCoreClass(() => (
61+
<div>Test Component</div>
62+
))
63+
const instance = new DevtoolsCore()
64+
expect(() => instance.unmount()).toThrow('Devtools is not mounted')
65+
})
66+
67+
it('DevtoolsCore should allow mount after unmount', async () => {
68+
const [DevtoolsCore, _] = constructCoreClass(() => (
69+
<div>Test Component</div>
70+
))
71+
const instance = new DevtoolsCore()
72+
await instance.mount(document.createElement('div'), 'dark')
73+
instance.unmount()
74+
await expect(
75+
instance.mount(document.createElement('div'), 'dark'),
76+
).resolves.not.toThrow()
77+
})
78+
79+
it('NoOpDevtoolsCore should not call any solid primitives when mount is called', async () => {
80+
const [_, NoOpDevtoolsCore] = constructCoreClass(() => (
81+
<div>Test Component</div>
82+
))
83+
const noOpInstance = new NoOpDevtoolsCore()
84+
await noOpInstance.mount(document.createElement('div'), 'dark')
85+
86+
expect(lazyImportMock).not.toHaveBeenCalled()
87+
expect(renderMock).not.toHaveBeenCalled()
88+
expect(portalMock).not.toHaveBeenCalled()
89+
})
90+
91+
it('NoOpDevtoolsCore should not throw if mount is called multiple times', async () => {
92+
const [_, NoOpDevtoolsCore] = constructCoreClass(() => (
93+
<div>Test Component</div>
94+
))
95+
const noOpInstance = new NoOpDevtoolsCore()
96+
await noOpInstance.mount(document.createElement('div'), 'dark')
97+
await expect(
98+
noOpInstance.mount(document.createElement('div'), 'dark'),
99+
).resolves.not.toThrow()
100+
})
101+
102+
it('NoOpDevtoolsCore should not throw if unmount is called before mount', () => {
103+
const [_, NoOpDevtoolsCore] = constructCoreClass(() => (
104+
<div>Test Component</div>
105+
))
106+
const noOpInstance = new NoOpDevtoolsCore()
107+
expect(() => noOpInstance.unmount()).not.toThrow()
108+
})
109+
110+
it('NoOpDevtoolsCore should not throw if unmount is called after mount', async () => {
111+
const [_, NoOpDevtoolsCore] = constructCoreClass(() => (
112+
<div>Test Component</div>
113+
))
114+
const noOpInstance = new NoOpDevtoolsCore()
115+
await noOpInstance.mount(document.createElement('div'), 'dark')
116+
expect(() => noOpInstance.unmount()).not.toThrow()
117+
})
118+
})

0 commit comments

Comments
 (0)