snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
# @tanstack/query-devtools
## 5.102.8
## 5.102.7
## 5.102.6
## 5.102.5
### Patch Changes
- [#11300](https://github.com/TanStack/query/pull/11300) [`ef1eff1`](https://github.com/TanStack/query/commit/ef1eff1b62a657d6ff6f2af819c1cb8b73ad2af5) - Remove an unintended `solid-js` import from the generated declaration files.
## 5.102.4
## 5.102.3
## 5.102.2
## 5.102.1
## 5.102.0
## 5.101.4
## 5.101.3
## 5.101.2
### Patch Changes
- [#10813](https://github.com/TanStack/query/pull/10813) [`f5bf180`](https://github.com/TanStack/query/commit/f5bf180d933d8b8d9d9e7b845e55b26a3a413b07) - fix(query-devtools/PiPContext): reset 'pip_open' in 'localStore' from 'closePipWindow' so the auto-open createEffect does not reopen the window after a programmatic close
- [#10812](https://github.com/TanStack/query/pull/10812) [`25cdd97`](https://github.com/TanStack/query/commit/25cdd975fed4703d2ca5b600ca5ccd2b600b3dd8) - fix(query-devtools/utils): make 'last updated' sort return 0 for queries with equal 'dataUpdatedAt' to follow the standard comparator contract
- [#10815](https://github.com/TanStack/query/pull/10815) [`ecd89c8`](https://github.com/TanStack/query/commit/ecd89c8faf7acc226f00633ea3a761d3ab842c1d) - fix(query-devtools/utils): scope the 'setupStyleSheet' dedup check to the target so a 'shadowDOMTarget' still receives its own '#\_goober' style tag when 'document.head' already has one
- [#10811](https://github.com/TanStack/query/pull/10811) [`01c7634`](https://github.com/TanStack/query/commit/01c763444e3cf3dfa9744f13911aa1533cac3c29) - fix(query-devtools/Devtools): correct the Theme sub-trigger className from 'position' to 'theme'
- [#10736](https://github.com/TanStack/query/pull/10736) [`49012db`](https://github.com/TanStack/query/commit/49012dbd5192dfe483d3b108b72ffaa7f2849e0f) - `setupStyleSheet` now sets `window.__nonce__` when a `styleNonce` is provided.
The devtools use [goober](https://goober.js.org/) for CSS-in-JS, which reads `window.__nonce__` every time it creates or accesses its style element. Without this, goober overwrote the nonce with `undefined`, causing CSP violations even when `styleNonce` was correctly passed to `<ReactQueryDevtools>`.
## 5.101.1
## 5.101.0
### Patch Changes
- [#10772](https://github.com/TanStack/query/pull/10772) [`3042860`](https://github.com/TanStack/query/commit/3042860e3c8731c94ca4dec0e277e415d0484fce) - Avoid crashing devtools query rows when a cached query state is temporarily unavailable.
- [#10750](https://github.com/TanStack/query/pull/10750) [`e631dc3`](https://github.com/TanStack/query/commit/e631dc3fa17bff71f413246b7a770a730016d346) - Resolve devtools query rows from their stable query hash so mutated object query keys do not break row rendering.
## 5.100.14
## 5.100.13
## 5.100.12
## 5.100.11
## 5.100.10
### Patch Changes
- fix(query-devtools): remove experimentalDts to prevent solid-js type leak ([#10694](https://github.com/TanStack/query/pull/10694))
## 5.100.9
### Patch Changes
- Update the devtools panel `setOnClose` callback type to return `void`. ([#10607](https://github.com/TanStack/query/pull/10607))
## 5.100.8
## 5.100.7
## 5.100.6
## 5.100.5
## 5.100.4
### Patch Changes
- fix(devtools): change onClose callback type from () => unknown to () => void ([#10118](https://github.com/TanStack/query/pull/10118))
## 5.100.3
## 5.100.2
## 5.100.1
## 5.100.0
## 5.99.2
## 5.99.1
## 5.99.0
## 5.98.0
## 5.97.0
## 5.96.2
## 5.96.1
## 5.96.0
## 5.95.2
## 5.95.1
## 5.95.0
## 5.94.5
### Patch Changes
- fix(\*): resolve issue about excluded build directory ([#10312](https://github.com/TanStack/query/pull/10312))
## 5.94.4
### Patch Changes
- chore: fixed version ([#10064](https://github.com/TanStack/query/pull/10064))
## 5.93.0
### Minor Changes
- Set data to undefined on Trigger Error ([#10072](https://github.com/TanStack/query/pull/10072))
## 5.92.0
### Minor Changes
- improve a11y around screenreader + resizing ([#9961](https://github.com/TanStack/query/pull/9961))
### Patch Changes
- fix(devtools): handle PiP open failures by resetting persisted state ([#9983](https://github.com/TanStack/query/pull/9983))
## 5.91.1
### Patch Changes
- improves accessibility of devtools ([#9806](https://github.com/TanStack/query/pull/9806))
## 5.91.0
### Minor Changes
- feat(devtools): allow passing a theme via prop ([#9887](https://github.com/TanStack/query/pull/9887))

View File

@@ -0,0 +1,5 @@
// @ts-check
import rootConfig from './root.eslint.config.js'
export default [...rootConfig]

View File

@@ -0,0 +1,82 @@
{
"name": "@tanstack/query-devtools",
"version": "5.102.8",
"description": "Developer tools to interact with and visualize the TanStack Query cache",
"author": "tannerlinsley",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/TanStack/query.git",
"directory": "packages/query-devtools"
},
"homepage": "https://tanstack.com/query",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"scripts": {
"clean": "premove ./build ./coverage ./dist-ts ./.cache/test-types",
"compile": "tsc --project tsconfig.json",
"test:eslint": "eslint --concurrency=auto ./src",
"test:types": "npm-run-all --serial test:types:*",
"test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts56 --tsBuildInfoFile .cache/test-types/ts56.tsbuildinfo",
"test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts57 --tsBuildInfoFile .cache/test-types/ts57.tsbuildinfo",
"test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts58 --tsBuildInfoFile .cache/test-types/ts58.tsbuildinfo",
"test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts59 --tsBuildInfoFile .cache/test-types/ts59.tsbuildinfo",
"test:types:tscurrent": "tsc --project tsconfig.json --outDir .cache/test-types/tscurrent --tsBuildInfoFile .cache/test-types/tscurrent.tsbuildinfo",
"test:types:ts70": "node ../../node_modules/typescript70/lib/tsc.js --project tsconfig.json --outDir .cache/test-types/ts70 --tsBuildInfoFile .cache/test-types/ts70.tsbuildinfo",
"test:lib": "vitest",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict && attw --pack",
"build": "tsdown --tsconfig tsconfig.prod.json",
"build:dev": "tsdown --watch"
},
"type": "module",
"main": "./build/index.cjs",
"module": "./build/index.js",
"types": "./build/index.d.ts",
"browser": {},
"exports": {
"@tanstack/custom-condition": "./src/index.ts",
"solid": {
"development": "./build/index.js",
"import": "./build/index.js"
},
"development": {
"import": {
"types": "./build/index.d.ts",
"default": "./build/dev.js"
},
"require": "./build/dev.cjs"
},
"import": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
},
"require": "./build/index.cjs"
},
"files": [
"build",
"src",
"!src/__tests__"
],
"dependencies": {
"@kobalte/core": "^0.13.4",
"@solid-primitives/keyed": "^1.2.2",
"@solid-primitives/resize-observer": "^2.0.26",
"@solid-primitives/storage": "^1.3.11",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/query-core": "workspace:*",
"clsx": "^2.1.1",
"goober": "^2.1.16",
"solid-js": "^1.9.7",
"solid-transition-group": "^0.2.3",
"superjson": "^2.2.2"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.10",
"npm-run-all2": "^5.0.0",
"unplugin-solid": "^2.0.0",
"vite-plugin-solid": "^2.11.6"
}
}

View File

@@ -0,0 +1 @@
../../eslint.config.js

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
import { createLocalStorage } from '@solid-primitives/storage'
import { createMemo } from 'solid-js'
import { Devtools } from './Devtools'
import { getPreferredColorScheme } from './utils'
import { THEME_PREFERENCE } from './constants'
import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts'
import type { Theme } from './contexts'
import type { DevtoolsComponentType } from './Devtools'
const DevtoolsComponent: DevtoolsComponentType = (props) => {
const [localStore, setLocalStore] = createLocalStorage({
prefix: 'TanstackQueryDevtools',
})
const colorScheme = getPreferredColorScheme()
const theme = createMemo(() => {
const preference = (props.theme ||
localStore.theme_preference ||
THEME_PREFERENCE) as Theme
if (preference !== 'system') return preference
return colorScheme()
})
return (
<QueryDevtoolsContext.Provider value={props}>
<PiPProvider localStore={localStore} setLocalStore={setLocalStore}>
<ThemeContext.Provider value={theme}>
<Devtools localStore={localStore} setLocalStore={setLocalStore} />
</ThemeContext.Provider>
</PiPProvider>
</QueryDevtoolsContext.Provider>
)
}
export default DevtoolsComponent

View File

@@ -0,0 +1,47 @@
import { createLocalStorage } from '@solid-primitives/storage'
import { createMemo } from 'solid-js'
import { ContentView, ParentPanel } from './Devtools'
import { getPreferredColorScheme } from './utils'
import { THEME_PREFERENCE } from './constants'
import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts'
import type { Theme } from './contexts'
import type { DevtoolsComponentType } from './Devtools'
const DevtoolsPanelComponent: DevtoolsComponentType = (props) => {
const [localStore, setLocalStore] = createLocalStorage({
prefix: 'TanstackQueryDevtools',
})
const colorScheme = getPreferredColorScheme()
const theme = createMemo(() => {
const preference = (props.theme ||
localStore.theme_preference ||
THEME_PREFERENCE) as Theme
if (preference !== 'system') return preference
return colorScheme()
})
return (
<QueryDevtoolsContext.Provider value={props}>
<PiPProvider
disabled
localStore={localStore}
setLocalStore={setLocalStore}
>
<ThemeContext.Provider value={theme}>
<ParentPanel>
<ContentView
localStore={localStore}
setLocalStore={setLocalStore}
onClose={props.onClose}
showPanelViewOnly
/>
</ParentPanel>
</ThemeContext.Provider>
</PiPProvider>
</QueryDevtoolsContext.Provider>
)
}
export default DevtoolsPanelComponent

View File

@@ -0,0 +1,674 @@
import { serialize, stringify } from 'superjson'
import { clsx as cx } from 'clsx'
import {
Index,
Match,
Show,
Switch,
createMemo,
createSignal,
createUniqueId,
} from 'solid-js'
import { Key } from '@solid-primitives/keyed'
import * as goober from 'goober'
import { tokens } from './theme'
import {
deleteNestedDataByPath,
displayValue,
updateNestedDataByPath,
} from './utils'
import {
Check,
CopiedCopier,
Copier,
ErrorCopier,
List,
Pencil,
Trash,
} from './icons'
import { useQueryDevtoolsContext, useTheme } from './contexts'
import type { Query } from '@tanstack/query-core'
/**
* Chunk elements in the array by size
*
* when the array cannot be chunked evenly by size, the last chunk will be
* filled with the remaining elements
*
* @example
* chunkArray(['a','b', 'c', 'd', 'e'], 2) // returns [['a','b'], ['c', 'd'], ['e']]
*/
function chunkArray<T extends { label: string; value: unknown }>(
array: Array<T>,
size: number,
): Array<Array<T>> {
if (size < 1) return []
let i = 0
const result: Array<Array<T>> = []
while (i < array.length) {
result.push(array.slice(i, i + size))
i = i + size
}
return result
}
const Expander = (props: { expanded: boolean }) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
return (
<span
class={cx(
styles().expander,
css`
transform: rotate(${props.expanded ? 90 : 0}deg);
`,
props.expanded &&
css`
& svg {
top: -1px;
}
`,
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 12L10 8L6 4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
)
}
type CopyState = 'NoCopy' | 'SuccessCopy' | 'ErrorCopy'
const CopyButton = (props: { value: unknown }) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const [copyState, setCopyState] = createSignal<CopyState>('NoCopy')
return (
<button
class={styles().actionButton}
title="Copy object to clipboard"
aria-label={`${
copyState() === 'NoCopy'
? 'Copy object to clipboard'
: copyState() === 'SuccessCopy'
? 'Object copied to clipboard'
: 'Error copying object to clipboard'
}`}
onClick={
copyState() === 'NoCopy'
? () => {
navigator.clipboard.writeText(stringify(props.value)).then(
() => {
setCopyState('SuccessCopy')
setTimeout(() => {
setCopyState('NoCopy')
}, 1500)
},
(err) => {
console.error('Failed to copy: ', err)
setCopyState('ErrorCopy')
setTimeout(() => {
setCopyState('NoCopy')
}, 1500)
},
)
}
: undefined
}
>
<Switch>
<Match when={copyState() === 'NoCopy'}>
<Copier />
</Match>
<Match when={copyState() === 'SuccessCopy'}>
<CopiedCopier theme={theme()} />
</Match>
<Match when={copyState() === 'ErrorCopy'}>
<ErrorCopier />
</Match>
</Switch>
</button>
)
}
const ClearArrayButton = (props: {
dataPath: Array<string>
activeQuery: Query
}) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const queryClient = useQueryDevtoolsContext().client
return (
<button
class={styles().actionButton}
title={'Remove all items'}
aria-label={'Remove all items'}
onClick={() => {
const oldData = props.activeQuery.state.data
const newData = updateNestedDataByPath(oldData, props.dataPath, [])
queryClient.setQueryData(props.activeQuery.queryKey, newData)
}}
>
<List />
</button>
)
}
const DeleteItemButton = (props: {
dataPath: Array<string>
activeQuery: Query
}) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const queryClient = useQueryDevtoolsContext().client
return (
<button
class={cx(styles().actionButton)}
title={'Delete item'}
aria-label={'Delete item'}
onClick={() => {
const oldData = props.activeQuery.state.data
const newData = deleteNestedDataByPath(oldData, props.dataPath)
queryClient.setQueryData(props.activeQuery.queryKey, newData)
}}
>
<Trash />
</button>
)
}
const ToggleValueButton = (props: {
dataPath: Array<string>
activeQuery: Query
value: boolean
}) => {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const queryClient = useQueryDevtoolsContext().client
return (
<button
class={cx(
styles().actionButton,
css`
width: ${tokens.size[3.5]};
height: ${tokens.size[3.5]};
`,
)}
title={'Toggle value'}
aria-label={'Toggle value'}
onClick={() => {
const oldData = props.activeQuery.state.data
const newData = updateNestedDataByPath(
oldData,
props.dataPath,
!props.value,
)
queryClient.setQueryData(props.activeQuery.queryKey, newData)
}}
>
<Check theme={theme()} checked={props.value} />
</button>
)
}
type ExplorerProps = {
editable?: boolean
label: string
value: unknown
defaultExpanded?: Array<string>
dataPath?: Array<string>
activeQuery?: Query
itemsDeletable?: boolean
onEdit?: () => void
}
function isIterable(x: any): x is Iterable<unknown> {
return Symbol.iterator in x
}
export default function Explorer(props: ExplorerProps) {
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
const styles = createMemo(() => {
return theme() === 'dark' ? darkStyles(css) : lightStyles(css)
})
const queryClient = useQueryDevtoolsContext().client
const [expanded, setExpanded] = createSignal(
(props.defaultExpanded || []).includes(props.label),
)
const toggleExpanded = () => setExpanded((old) => !old)
const [expandedPages, setExpandedPages] = createSignal<Array<number>>([])
const subEntries = createMemo(() => {
if (Array.isArray(props.value)) {
return props.value.map((d, i) => ({
label: i.toString(),
value: d,
}))
} else if (
props.value !== null &&
typeof props.value === 'object' &&
isIterable(props.value) &&
typeof props.value[Symbol.iterator] === 'function'
) {
if (props.value instanceof Map) {
return Array.from(props.value, ([key, val]) => ({
label: key,
value: val,
}))
}
return Array.from(props.value, (val, i) => ({
label: i.toString(),
value: val,
}))
} else if (typeof props.value === 'object' && props.value !== null) {
return Object.entries(props.value).map(([key, val]) => ({
label: key,
value: val,
}))
}
return []
})
const type = createMemo<string>(() => {
if (Array.isArray(props.value)) {
return 'array'
} else if (
props.value !== null &&
typeof props.value === 'object' &&
isIterable(props.value) &&
typeof props.value[Symbol.iterator] === 'function'
) {
return 'Iterable'
} else if (typeof props.value === 'object' && props.value !== null) {
return 'object'
}
return typeof props.value
})
const subEntryPages = createMemo(() => chunkArray(subEntries(), 100))
const currentDataPath = props.dataPath ?? []
const inputId = createUniqueId()
return (
<div class={styles().entry}>
<Show when={subEntryPages().length}>
<div class={styles().expanderButtonContainer}>
<button
class={styles().expanderButton}
onClick={() => toggleExpanded()}
aria-expanded={expanded() ? 'true' : 'false'}
>
<Expander expanded={expanded()} /> <span>{props.label}</span>{' '}
<span class={styles().info}>
{String(type()).toLowerCase() === 'iterable' ? '(Iterable) ' : ''}
{subEntries().length} {subEntries().length > 1 ? `items` : `item`}
</span>
</button>
<Show when={props.editable}>
<div class={styles().actions}>
<CopyButton value={props.value} />
<Show
when={props.itemsDeletable && props.activeQuery !== undefined}
>
<DeleteItemButton
activeQuery={props.activeQuery!}
dataPath={currentDataPath}
/>
</Show>
<Show
when={type() === 'array' && props.activeQuery !== undefined}
>
<ClearArrayButton
activeQuery={props.activeQuery!}
dataPath={currentDataPath}
/>
</Show>
<Show when={!!props.onEdit && !serialize(props.value).meta}>
<button
class={styles().actionButton}
title={'Bulk Edit Data'}
aria-label={'Bulk Edit Data'}
onClick={() => {
props.onEdit?.()
}}
>
<Pencil />
</button>
</Show>
</div>
</Show>
</div>
<Show when={expanded()}>
<Show when={subEntryPages().length === 1}>
<div class={styles().subEntry}>
<Key each={subEntries()} by={(item) => item.label}>
{(entry) => {
return (
<Explorer
defaultExpanded={props.defaultExpanded}
label={entry().label}
value={entry().value}
editable={props.editable}
dataPath={[...currentDataPath, entry().label]}
activeQuery={props.activeQuery}
itemsDeletable={
type() === 'array' ||
type() === 'Iterable' ||
type() === 'object'
}
/>
)
}}
</Key>
</div>
</Show>
<Show when={subEntryPages().length > 1}>
<div class={styles().subEntry}>
<Index each={subEntryPages()}>
{(entries, index) => (
<div>
<div class={styles().entry}>
<button
onClick={() =>
setExpandedPages((old) =>
old.includes(index)
? old.filter((d) => d !== index)
: [...old, index],
)
}
class={styles().expanderButton}
>
<Expander expanded={expandedPages().includes(index)} />{' '}
[{index * 100}...
{index * 100 + 100 - 1}]
</button>
<Show when={expandedPages().includes(index)}>
<div class={styles().subEntry}>
<Key each={entries()} by={(entry) => entry.label}>
{(entry) => (
<Explorer
defaultExpanded={props.defaultExpanded}
label={entry().label}
value={entry().value}
editable={props.editable}
dataPath={[...currentDataPath, entry().label]}
activeQuery={props.activeQuery}
/>
)}
</Key>
</div>
</Show>
</div>
</div>
)}
</Index>
</div>
</Show>
</Show>
</Show>
<Show when={subEntryPages().length === 0}>
<div class={styles().row}>
<label for={inputId} class={styles().label}>
{props.label}:
</label>
<Show
when={
props.editable &&
props.activeQuery !== undefined &&
(type() === 'string' ||
type() === 'number' ||
type() === 'boolean')
}
fallback={
<span class={styles().value}>{displayValue(props.value)}</span>
}
>
<Show
when={
props.editable &&
props.activeQuery !== undefined &&
(type() === 'string' || type() === 'number')
}
>
<input
id={inputId}
type={type() === 'number' ? 'number' : 'text'}
class={cx(styles().value, styles().editableInput)}
value={props.value as string | number}
onChange={(changeEvent) => {
const oldData = props.activeQuery!.state.data
const newData = updateNestedDataByPath(
oldData,
currentDataPath,
type() === 'number'
? changeEvent.target.valueAsNumber
: changeEvent.target.value,
)
queryClient.setQueryData(props.activeQuery!.queryKey, newData)
}}
/>
</Show>
<Show when={type() === 'boolean'}>
<span
class={cx(
styles().value,
styles().actions,
styles().editableInput,
)}
>
<ToggleValueButton
activeQuery={props.activeQuery!}
dataPath={currentDataPath}
value={props.value as boolean}
/>
{displayValue(props.value)}
</span>
</Show>
</Show>
<Show
when={
props.editable &&
props.itemsDeletable &&
props.activeQuery !== undefined
}
>
<DeleteItemButton
activeQuery={props.activeQuery!}
dataPath={currentDataPath}
/>
</Show>
</div>
</Show>
</div>
)
}
const stylesFactory = (
theme: 'light' | 'dark',
css: (typeof goober)['css'],
) => {
const { colors, font, size, border } = tokens
const t = (light: string, dark: string) => (theme === 'light' ? light : dark)
return {
entry: css`
& * {
font-size: ${font.size.xs};
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
position: relative;
outline: none;
word-break: break-word;
`,
subEntry: css`
margin: 0 0 0 0.5em;
padding-left: 0.75em;
border-left: 2px solid ${t(colors.gray[300], colors.darkGray[400])};
/* outline: 1px solid ${colors.teal[400]}; */
`,
expander: css`
& path {
stroke: ${colors.gray[400]};
}
& svg {
width: ${size[3]};
height: ${size[3]};
}
display: inline-flex;
align-items: center;
transition: all 0.1s ease;
/* outline: 1px solid ${colors.blue[400]}; */
`,
expanderButtonContainer: css`
display: flex;
align-items: center;
line-height: ${size[4]};
min-height: ${size[4]};
gap: ${size[2]};
`,
expanderButton: css`
cursor: pointer;
color: inherit;
font: inherit;
outline: inherit;
height: ${size[5]};
background: transparent;
border: none;
padding: 0;
display: inline-flex;
align-items: center;
gap: ${size[1]};
position: relative;
/* outline: 1px solid ${colors.green[400]}; */
&:focus-visible {
border-radius: ${border.radius.xs};
outline: 2px solid ${colors.blue[800]};
}
& svg {
position: relative;
left: 1px;
}
`,
info: css`
color: ${t(colors.gray[500], colors.gray[500])};
font-size: ${font.size.xs};
margin-left: ${size[1]};
/* outline: 1px solid ${colors.yellow[400]}; */
`,
label: css`
color: ${t(colors.gray[700], colors.gray[300])};
white-space: nowrap;
`,
value: css`
color: ${t(colors.purple[600], colors.purple[400])};
flex-grow: 1;
`,
actions: css`
display: inline-flex;
gap: ${size[2]};
align-items: center;
`,
row: css`
display: inline-flex;
gap: ${size[2]};
width: 100%;
margin: ${size[0.25]} 0px;
line-height: ${size[4.5]};
align-items: center;
`,
editableInput: css`
border: none;
padding: ${size[0.5]} ${size[1]} ${size[0.5]} ${size[1.5]};
flex-grow: 1;
border-radius: ${border.radius.xs};
background-color: ${t(colors.gray[200], colors.darkGray[500])};
&:hover {
background-color: ${t(colors.gray[300], colors.darkGray[600])};
}
`,
actionButton: css`
background-color: transparent;
color: ${t(colors.gray[500], colors.gray[500])};
border: none;
display: inline-flex;
padding: 0px;
align-items: center;
justify-content: center;
cursor: pointer;
width: ${size[3]};
height: ${size[3]};
position: relative;
z-index: 1;
&:hover svg {
color: ${t(colors.gray[600], colors.gray[400])};
}
&:focus-visible {
border-radius: ${border.radius.xs};
outline: 2px solid ${colors.blue[800]};
outline-offset: 2px;
}
`,
}
}
const lightStyles = (css: (typeof goober)['css']) => stylesFactory('light', css)
const darkStyles = (css: (typeof goober)['css']) => stylesFactory('dark', css)

View File

@@ -0,0 +1,160 @@
import { render } from 'solid-js/web'
import { createSignal, lazy } from 'solid-js'
import { setupStyleSheet } from './utils'
import type {
QueryClient,
onlineManager as TOnlineManager,
} from '@tanstack/query-core'
import type { DevtoolsComponentType } from './Devtools'
import type {
DevtoolsButtonPosition,
DevtoolsErrorType,
DevtoolsPosition,
QueryDevtoolsProps,
Theme,
} from './contexts/types'
import type { Signal } from 'solid-js'
export interface TanstackQueryDevtoolsConfig extends QueryDevtoolsProps {
styleNonce?: string
shadowDOMTarget?: ShadowRoot
}
class TanstackQueryDevtools {
#client: Signal<QueryClient>
#onlineManager: typeof TOnlineManager
#queryFlavor: string
#version: string
#isMounted = false
#styleNonce?: string
#shadowDOMTarget?: ShadowRoot
#buttonPosition: Signal<DevtoolsButtonPosition | undefined>
#position: Signal<DevtoolsPosition | undefined>
#initialIsOpen: Signal<boolean | undefined>
#errorTypes: Signal<Array<DevtoolsErrorType> | undefined>
#hideDisabledQueries: Signal<boolean | undefined>
#Component: DevtoolsComponentType | undefined
#theme: Signal<Theme | undefined>
#dispose?: () => void
constructor(config: TanstackQueryDevtoolsConfig) {
const {
client,
queryFlavor,
version,
onlineManager,
buttonPosition,
position,
initialIsOpen,
errorTypes,
styleNonce,
shadowDOMTarget,
hideDisabledQueries,
theme,
} = config
this.#client = createSignal(client)
this.#queryFlavor = queryFlavor
this.#version = version
this.#onlineManager = onlineManager
this.#styleNonce = styleNonce
this.#shadowDOMTarget = shadowDOMTarget
this.#buttonPosition = createSignal(buttonPosition)
this.#position = createSignal(position)
this.#initialIsOpen = createSignal(initialIsOpen)
this.#errorTypes = createSignal(errorTypes)
this.#hideDisabledQueries = createSignal(hideDisabledQueries)
this.#theme = createSignal(theme)
}
setButtonPosition(position: DevtoolsButtonPosition) {
this.#buttonPosition[1](position)
}
setPosition(position: DevtoolsPosition) {
this.#position[1](position)
}
setInitialIsOpen(isOpen: boolean) {
this.#initialIsOpen[1](isOpen)
}
setErrorTypes(errorTypes: Array<DevtoolsErrorType>) {
this.#errorTypes[1](errorTypes)
}
setClient(client: QueryClient) {
this.#client[1](client)
}
setTheme(theme?: Theme) {
this.#theme[1](theme)
}
mount<T extends HTMLElement>(el: T) {
if (this.#isMounted) {
throw new Error('Devtools is already mounted')
}
const dispose = render(() => {
const [btnPosition] = this.#buttonPosition
const [pos] = this.#position
const [isOpen] = this.#initialIsOpen
const [errors] = this.#errorTypes
const [hideDisabledQueries] = this.#hideDisabledQueries
const [queryClient] = this.#client
const [theme] = this.#theme
let Devtools: DevtoolsComponentType
if (this.#Component) {
Devtools = this.#Component
} else {
Devtools = lazy(() => import('./DevtoolsComponent'))
this.#Component = Devtools
}
setupStyleSheet(this.#styleNonce, this.#shadowDOMTarget)
return (
<Devtools
queryFlavor={this.#queryFlavor}
version={this.#version}
onlineManager={this.#onlineManager}
shadowDOMTarget={this.#shadowDOMTarget}
{...{
get client() {
return queryClient()
},
get buttonPosition() {
return btnPosition()
},
get position() {
return pos()
},
get initialIsOpen() {
return isOpen()
},
get errorTypes() {
return errors()
},
get hideDisabledQueries() {
return hideDisabledQueries()
},
get theme() {
return theme()
},
}}
/>
)
}, el)
this.#isMounted = true
this.#dispose = dispose
}
unmount() {
if (!this.#isMounted) {
throw new Error('Devtools is not mounted')
}
this.#dispose?.()
this.#isMounted = false
}
}
export { TanstackQueryDevtools }

View File

@@ -0,0 +1,172 @@
import { render } from 'solid-js/web'
import { createSignal, lazy } from 'solid-js'
import { setupStyleSheet } from './utils'
import type {
QueryClient,
onlineManager as TOnlineManager,
} from '@tanstack/query-core'
import type { DevtoolsComponentType } from './Devtools'
import type {
DevtoolsButtonPosition,
DevtoolsErrorType,
DevtoolsPosition,
QueryDevtoolsProps,
Theme,
} from './contexts/types'
import type { Signal } from 'solid-js'
export interface TanstackQueryDevtoolsPanelConfig extends QueryDevtoolsProps {
styleNonce?: string
shadowDOMTarget?: ShadowRoot
onClose?: () => void
}
class TanstackQueryDevtoolsPanel {
#client: Signal<QueryClient>
#onlineManager: typeof TOnlineManager
#queryFlavor: string
#version: string
#isMounted = false
#styleNonce?: string
#shadowDOMTarget?: ShadowRoot
#buttonPosition: Signal<DevtoolsButtonPosition | undefined>
#position: Signal<DevtoolsPosition | undefined>
#initialIsOpen: Signal<boolean | undefined>
#errorTypes: Signal<Array<DevtoolsErrorType> | undefined>
#hideDisabledQueries: Signal<boolean | undefined>
#onClose: Signal<(() => void) | undefined>
#Component: DevtoolsComponentType | undefined
#theme: Signal<Theme | undefined>
#dispose?: () => void
constructor(config: TanstackQueryDevtoolsPanelConfig) {
const {
client,
queryFlavor,
version,
onlineManager,
buttonPosition,
position,
initialIsOpen,
errorTypes,
styleNonce,
shadowDOMTarget,
onClose,
hideDisabledQueries,
theme,
} = config
this.#client = createSignal(client)
this.#queryFlavor = queryFlavor
this.#version = version
this.#onlineManager = onlineManager
this.#styleNonce = styleNonce
this.#shadowDOMTarget = shadowDOMTarget
this.#buttonPosition = createSignal(buttonPosition)
this.#position = createSignal(position)
this.#initialIsOpen = createSignal(initialIsOpen)
this.#errorTypes = createSignal(errorTypes)
this.#hideDisabledQueries = createSignal(hideDisabledQueries)
this.#onClose = createSignal(onClose)
this.#theme = createSignal(theme)
}
setButtonPosition(position: DevtoolsButtonPosition) {
this.#buttonPosition[1](position)
}
setPosition(position: DevtoolsPosition) {
this.#position[1](position)
}
setInitialIsOpen(isOpen: boolean) {
this.#initialIsOpen[1](isOpen)
}
setErrorTypes(errorTypes: Array<DevtoolsErrorType>) {
this.#errorTypes[1](errorTypes)
}
setClient(client: QueryClient) {
this.#client[1](client)
}
setOnClose(onClose: () => void) {
this.#onClose[1](() => onClose)
}
setTheme(theme?: Theme) {
this.#theme[1](theme)
}
mount<T extends HTMLElement>(el: T) {
if (this.#isMounted) {
throw new Error('Devtools is already mounted')
}
const dispose = render(() => {
const [btnPosition] = this.#buttonPosition
const [pos] = this.#position
const [isOpen] = this.#initialIsOpen
const [errors] = this.#errorTypes
const [hideDisabledQueries] = this.#hideDisabledQueries
const [queryClient] = this.#client
const [onClose] = this.#onClose
const [theme] = this.#theme
let Devtools: DevtoolsComponentType
if (this.#Component) {
Devtools = this.#Component
} else {
Devtools = lazy(() => import('./DevtoolsPanelComponent'))
this.#Component = Devtools
}
setupStyleSheet(this.#styleNonce, this.#shadowDOMTarget)
return (
<Devtools
queryFlavor={this.#queryFlavor}
version={this.#version}
onlineManager={this.#onlineManager}
shadowDOMTarget={this.#shadowDOMTarget}
{...{
get client() {
return queryClient()
},
get buttonPosition() {
return btnPosition()
},
get position() {
return pos()
},
get initialIsOpen() {
return isOpen()
},
get errorTypes() {
return errors()
},
get hideDisabledQueries() {
return hideDisabledQueries()
},
get onClose() {
return onClose()
},
get theme() {
return theme()
},
}}
/>
)
}, el)
this.#isMounted = true
this.#dispose = dispose
}
unmount() {
if (!this.#isMounted) {
throw new Error('Devtools is not mounted')
}
this.#dispose?.()
this.#isMounted = false
}
}
export { TanstackQueryDevtoolsPanel }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QueryClient, onlineManager } from '@tanstack/query-core'
import { render } from '@solidjs/testing-library'
import DevtoolsComponent from '../DevtoolsComponent'
// `solid-transition-group` internally imports from
// `@solid-primitives/transition-group`, whose `exports` field points at
// `src/index.ts` (not published) under a `@solid-primitives/source` condition
// that Vite can't fall through, so we stub it with a transparent pass-through.
vi.mock('solid-transition-group', () => ({
TransitionGroup: (props: { children: unknown }) => props.children,
}))
describe('DevtoolsComponent', () => {
const storage: { [key: string]: string } = {}
let queryClient: QueryClient
beforeEach(() => {
vi.stubGlobal('localStorage', {
getItem: (key: string) =>
Object.prototype.hasOwnProperty.call(storage, key)
? storage[key]
: null,
setItem: (key: string, value: string) => {
storage[key] = value
},
removeItem: (key: string) => {
delete storage[key]
},
clear: () => {
Object.keys(storage).forEach((key) => delete storage[key])
},
})
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
)
vi.stubGlobal(
'ResizeObserver',
class {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
},
)
queryClient = new QueryClient()
})
afterEach(() => {
vi.unstubAllGlobals()
Object.keys(storage).forEach((key) => delete storage[key])
queryClient.clear()
})
it('should render without throwing when only required props are provided', () => {
expect(() =>
render(() => (
<DevtoolsComponent
client={queryClient}
queryFlavor="TanStack Query"
version="5"
onlineManager={onlineManager}
/>
)),
).not.toThrow()
})
it('should render without throwing when all props are provided', () => {
expect(() =>
render(() => (
<DevtoolsComponent
client={queryClient}
queryFlavor="TanStack Query"
version="5"
onlineManager={onlineManager}
buttonPosition="top-left"
position="left"
initialIsOpen={true}
errorTypes={[
{ name: 'NetworkError', initializer: () => new Error('Network') },
]}
hideDisabledQueries={true}
theme="dark"
/>
)),
).not.toThrow()
})
})

View File

@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QueryClient, onlineManager } from '@tanstack/query-core'
import { render } from '@solidjs/testing-library'
import DevtoolsPanelComponent from '../DevtoolsPanelComponent'
// `solid-transition-group` internally imports from
// `@solid-primitives/transition-group`, whose `exports` field points at
// `src/index.ts` (not published) under a `@solid-primitives/source` condition
// that Vite can't fall through, so we stub it with a transparent pass-through.
vi.mock('solid-transition-group', () => ({
TransitionGroup: (props: { children: unknown }) => props.children,
}))
// `goober` compiles every `css\`...\`` template literal at mount time
// (template parsing + class hashing + style serialization), which
// dominates mount cost and produces no value for label/role-based
// assertions, so we replace it with a no-op factory.
vi.mock('goober', () => {
let counter = 0
const css = Object.assign(() => `tsqd-${++counter}`, {
bind: () => css,
})
return { css, glob: () => {}, setup: () => {} }
})
describe('DevtoolsPanelComponent', () => {
const storage: { [key: string]: string } = {}
let queryClient: QueryClient
let previousRootFontSize = ''
beforeEach(() => {
previousRootFontSize = document.documentElement.style.fontSize
vi.stubGlobal('localStorage', {
getItem: (key: string) =>
Object.prototype.hasOwnProperty.call(storage, key)
? storage[key]
: null,
setItem: (key: string, value: string) => {
storage[key] = value
},
removeItem: (key: string) => {
delete storage[key]
},
clear: () => {
Object.keys(storage).forEach((key) => delete storage[key])
},
})
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
)
vi.stubGlobal(
'ResizeObserver',
class {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
},
)
queryClient = new QueryClient()
document.documentElement.style.fontSize = '16px'
})
afterEach(() => {
vi.unstubAllGlobals()
Object.keys(storage).forEach((key) => delete storage[key])
queryClient.clear()
document.documentElement.style.fontSize = previousRootFontSize
})
it('should render the panel without throwing when only required props are provided', () => {
expect(() =>
render(() => (
<DevtoolsPanelComponent
client={queryClient}
queryFlavor="TanStack Query"
version="5"
onlineManager={onlineManager}
/>
)),
).not.toThrow()
})
it('should render the panel without throwing when all props are provided', () => {
expect(() =>
render(() => (
<DevtoolsPanelComponent
client={queryClient}
queryFlavor="TanStack Query"
version="5"
onlineManager={onlineManager}
errorTypes={[
{ name: 'NetworkError', initializer: () => new Error('Network') },
]}
hideDisabledQueries={true}
theme="dark"
onClose={() => {}}
/>
)),
).not.toThrow()
})
it('should not render the open devtools button in panel-only mode', () => {
const rendered = render(() => (
<DevtoolsPanelComponent
client={queryClient}
queryFlavor="TanStack Query"
version="5"
onlineManager={onlineManager}
/>
))
expect(
rendered.queryByLabelText('Open Tanstack query devtools'),
).not.toBeInTheDocument()
})
it('should call "onClose" when the close button is clicked', () => {
const onClose = vi.fn()
const rendered = render(() => (
<DevtoolsPanelComponent
client={queryClient}
queryFlavor="TanStack Query"
version="5"
onlineManager={onlineManager}
onClose={onClose}
/>
))
rendered.getByLabelText('Close Tanstack query devtools').click()
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,633 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, within } from '@solidjs/testing-library'
import { QueryClient, onlineManager } from '@tanstack/query-core'
import Explorer from '../Explorer'
import { QueryDevtoolsContext, ThemeContext } from '../contexts'
import type { Query } from '@tanstack/query-core'
// `goober` compiles every `css\`...\`` template literal at mount time;
// replace it with a no-op factory so label/role-based assertions stay fast.
vi.mock('goober', () => {
let counter = 0
const css = Object.assign(() => `tsqd-${++counter}`, {
bind: () => css,
})
return { css, glob: () => {}, setup: () => {} }
})
describe('Explorer', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
function renderExplorer(
props: Parameters<typeof Explorer>[0],
options: { theme?: 'dark' | 'light' } = {},
) {
const theme = options.theme ?? 'dark'
return render(() => (
<QueryDevtoolsContext.Provider
value={{
client: queryClient,
queryFlavor: 'TanStack Query',
version: '5',
onlineManager,
}}
>
<ThemeContext.Provider value={() => theme}>
<Explorer {...props} />
</ThemeContext.Provider>
</QueryDevtoolsContext.Provider>
))
}
describe('primitive values', () => {
it('should render a "label: value" row for a string value', () => {
const rendered = renderExplorer({ label: 'name', value: 'Anna' })
expect(rendered.getByText('name:')).toBeInTheDocument()
expect(rendered.getByText('"Anna"')).toBeInTheDocument()
})
it('should render a "label: value" row for a number value', () => {
const rendered = renderExplorer({ label: 'count', value: 42 })
expect(rendered.getByText('count:')).toBeInTheDocument()
expect(rendered.getByText('42')).toBeInTheDocument()
})
it('should render a "label: value" row for a boolean value', () => {
const rendered = renderExplorer({ label: 'active', value: true })
expect(rendered.getByText('active:')).toBeInTheDocument()
expect(rendered.getByText('true')).toBeInTheDocument()
})
it('should render a "label: value" row for a "null" value', () => {
const rendered = renderExplorer({ label: 'missing', value: null })
expect(rendered.getByText('missing:')).toBeInTheDocument()
expect(rendered.getByText('null')).toBeInTheDocument()
})
})
describe('arrays and objects', () => {
it('should render an empty object as a primitive row (no expander)', () => {
const rendered = renderExplorer({ label: 'data', value: {} })
expect(rendered.getByText('data:')).toBeInTheDocument()
expect(rendered.queryByRole('button', { expanded: false })).toBeNull()
})
it('should render an array with an expander showing the item count', () => {
const rendered = renderExplorer({
label: 'list',
value: ['a', 'b', 'c'],
})
const expander = rendered.getByRole('button', { expanded: false })
expect(expander).toBeInTheDocument()
expect(expander.textContent).toContain('list')
expect(expander.textContent).toContain('3 items')
})
it('should render children under their index labels when the array expander is clicked', () => {
const rendered = renderExplorer({
label: 'list',
value: ['a', 'b'],
})
fireEvent.click(rendered.getByRole('button', { expanded: false }))
expect(rendered.getByText('0:')).toBeInTheDocument()
expect(rendered.getByText('1:')).toBeInTheDocument()
expect(rendered.getByText('"a"')).toBeInTheDocument()
expect(rendered.getByText('"b"')).toBeInTheDocument()
})
it('should render object entries under their keys when expanded', () => {
const rendered = renderExplorer({
label: 'user',
value: { name: 'Anna', age: 30 },
})
fireEvent.click(rendered.getByRole('button', { expanded: false }))
expect(rendered.getByText('name:')).toBeInTheDocument()
expect(rendered.getByText('"Anna"')).toBeInTheDocument()
expect(rendered.getByText('age:')).toBeInTheDocument()
expect(rendered.getByText('30')).toBeInTheDocument()
})
})
describe('Map and iterable values', () => {
it('should preserve "Map" keys as labels when expanded', () => {
const rendered = renderExplorer({
label: 'm',
value: new Map([
['first', 1],
['second', 2],
]),
})
fireEvent.click(rendered.getByRole('button', { expanded: false }))
expect(rendered.getByText('first:')).toBeInTheDocument()
expect(rendered.getByText('second:')).toBeInTheDocument()
})
it('should mark an iterable value with an "(Iterable)" prefix on the expander', () => {
const rendered = renderExplorer({
label: 's',
value: new Set(['x', 'y']),
})
expect(
rendered.getByRole('button', { expanded: false }).textContent,
).toContain('(Iterable)')
})
it('should render iterable children under their numeric index when expanded', () => {
const rendered = renderExplorer({
label: 's',
value: new Set(['x', 'y']),
})
fireEvent.click(rendered.getByRole('button', { expanded: false }))
expect(rendered.getByText('0:')).toBeInTheDocument()
expect(rendered.getByText('1:')).toBeInTheDocument()
})
})
describe('"defaultExpanded"', () => {
it('should render children eagerly when the label is in "defaultExpanded"', () => {
const rendered = renderExplorer({
label: 'list',
value: ['a'],
defaultExpanded: ['list'],
})
expect(
rendered.getByRole('button', { expanded: true }),
).toBeInTheDocument()
expect(rendered.getByText('0:')).toBeInTheDocument()
})
})
describe('action menu', () => {
it('should copy the serialized value to the clipboard when the copy button is clicked', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
queryClient.setQueryData(['data'], { name: 'Anna' })
const rendered = renderExplorer({
label: 'data',
value: { name: 'Anna' },
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
fireEvent.click(rendered.getByLabelText('Copy object to clipboard'))
expect(writeText).toHaveBeenCalledTimes(1)
const [arg] = writeText.mock.calls[0]!
expect(JSON.parse(arg as string)).toMatchObject({
json: { name: 'Anna' },
})
})
it('should switch the copy button to an error state when clipboard write fails', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
vi.stubGlobal('navigator', { clipboard: { writeText } })
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
queryClient.setQueryData(['data'], { name: 'Anna' })
const rendered = renderExplorer({
label: 'data',
value: { name: 'Anna' },
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
fireEvent.click(rendered.getByLabelText('Copy object to clipboard'))
await vi.advanceTimersByTimeAsync(0)
expect(
rendered.getByLabelText('Error copying object to clipboard'),
).toBeInTheDocument()
expect(consoleError).toHaveBeenCalledWith(
'Failed to copy: ',
new Error('denied'),
)
})
it('should reset the copy button to the idle state 1500ms after a successful copy', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
queryClient.setQueryData(['data'], { name: 'Anna' })
const rendered = renderExplorer({
label: 'data',
value: { name: 'Anna' },
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
fireEvent.click(rendered.getByLabelText('Copy object to clipboard'))
await vi.advanceTimersByTimeAsync(0)
expect(
rendered.getByLabelText('Object copied to clipboard'),
).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1500)
expect(
rendered.getByLabelText('Copy object to clipboard'),
).toBeInTheDocument()
})
it('should reset the copy button to the idle state 1500ms after a failed copy', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
vi.stubGlobal('navigator', { clipboard: { writeText } })
vi.spyOn(console, 'error').mockImplementation(() => {})
queryClient.setQueryData(['data'], { name: 'Anna' })
const rendered = renderExplorer({
label: 'data',
value: { name: 'Anna' },
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
fireEvent.click(rendered.getByLabelText('Copy object to clipboard'))
await vi.advanceTimersByTimeAsync(0)
expect(
rendered.getByLabelText('Error copying object to clipboard'),
).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1500)
expect(
rendered.getByLabelText('Copy object to clipboard'),
).toBeInTheDocument()
})
it('should clear array items via "setQueryData" when the clear-array button is clicked', () => {
queryClient.setQueryData(['data'], ['a', 'b', 'c'])
const rendered = renderExplorer({
label: 'list',
value: ['a', 'b', 'c'],
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
fireEvent.click(rendered.getByLabelText('Remove all items'))
expect(queryClient.getQueryData(['data'])).toEqual([])
})
it('should delete the entry at the current "dataPath" when the delete button is clicked', () => {
queryClient.setQueryData(['data'], ['a', 'b', 'c'])
const rendered = renderExplorer({
label: 'list',
value: ['a', 'b', 'c'],
editable: true,
itemsDeletable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
dataPath: ['1'],
})
fireEvent.click(rendered.getByLabelText('Delete item'))
expect(queryClient.getQueryData(['data'])).toEqual(['a', 'c'])
})
it('should toggle a boolean value via "setQueryData" when the toggle button is clicked', () => {
queryClient.setQueryData(['data'], { flag: true })
const rendered = renderExplorer({
label: 'flag',
value: true,
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
dataPath: ['flag'],
})
fireEvent.click(rendered.getByLabelText('Toggle value'))
expect(queryClient.getQueryData(['data'])).toEqual({ flag: false })
})
it('should not render action buttons when "editable" is false', () => {
queryClient.setQueryData(['data'], ['a'])
const rendered = renderExplorer({
label: 'list',
value: ['a'],
editable: false,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
expect(rendered.queryByLabelText('Copy object to clipboard')).toBeNull()
expect(rendered.queryByLabelText('Remove all items')).toBeNull()
})
it('should not render "ClearArrayButton" when value is not an array', () => {
queryClient.setQueryData(['data'], { name: 'Anna' })
const rendered = renderExplorer({
label: 'user',
value: { name: 'Anna' },
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
expect(rendered.queryByLabelText('Remove all items')).toBeNull()
expect(
rendered.getByLabelText('Copy object to clipboard'),
).toBeInTheDocument()
})
})
describe('pagination', () => {
it('should group entries into 100-item pages when the array has more than 100 entries', () => {
const rendered = renderExplorer({
label: 'big',
value: Array.from({ length: 101 }, (_, i) => i),
})
fireEvent.click(rendered.getByRole('button', { expanded: false }))
expect(rendered.getByText('[0...99]')).toBeInTheDocument()
expect(rendered.getByText('[100...199]')).toBeInTheDocument()
})
it('should keep the items of a page hidden until the page header is clicked', () => {
const rendered = renderExplorer({
label: 'big',
value: Array.from({ length: 101 }, (_, i) => `item-${i}`),
})
fireEvent.click(rendered.getByRole('button', { expanded: false }))
expect(rendered.queryByText('0:')).toBeNull()
})
it('should reveal the items of a page when the page header is clicked', () => {
const rendered = renderExplorer({
label: 'big',
value: Array.from({ length: 101 }, (_, i) => `item-${i}`),
})
fireEvent.click(rendered.getByRole('button', { expanded: false }))
fireEvent.click(rendered.getByText('[0...99]'))
expect(rendered.getByText('0:')).toBeInTheDocument()
expect(rendered.getByText('"item-0"')).toBeInTheDocument()
})
it('should independently toggle two pages when their headers are clicked', () => {
const rendered = renderExplorer({
label: 'big',
value: Array.from({ length: 200 }, (_, i) => `item-${i}`),
})
fireEvent.click(rendered.getByRole('button', { expanded: false }))
fireEvent.click(rendered.getByText('[0...99]'))
fireEvent.click(rendered.getByText('[100...199]'))
expect(rendered.getByText('"item-0"')).toBeInTheDocument()
expect(rendered.getByText('"item-100"')).toBeInTheDocument()
fireEvent.click(rendered.getByText('[0...99]'))
expect(rendered.queryByText('"item-0"')).toBeNull()
expect(rendered.getByText('"item-100"')).toBeInTheDocument()
})
it('should render action buttons for items inside a paginated page', () => {
const value: Array<Array<number>> = Array.from(
{ length: 200 },
(_, i) => [i],
)
queryClient.setQueryData(['data'], value)
const rendered = renderExplorer({
label: 'Data',
value,
defaultExpanded: ['Data'],
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
fireEvent.click(rendered.getByText('[0...99]'))
expect(
rendered.getAllByLabelText('Remove all items').length,
).toBeGreaterThan(1)
})
})
describe('inline edit', () => {
it('should write the new string value via "setQueryData" when a text input is changed', () => {
queryClient.setQueryData(['data'], { name: 'Anna' })
const rendered = renderExplorer({
label: 'name',
value: 'Anna',
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
dataPath: ['name'],
})
const input = rendered.getByLabelText('name:')
expect(input).toHaveAttribute('type', 'text')
fireEvent.change(input, { target: { value: 'Bob' } })
expect(queryClient.getQueryData(['data'])).toEqual({ name: 'Bob' })
})
it('should write the new number value via "setQueryData" when a number input is changed', () => {
queryClient.setQueryData(['data'], { count: 1 })
const rendered = renderExplorer({
label: 'count',
value: 1,
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
dataPath: ['count'],
})
const input = rendered.getByLabelText('count:')
expect(input).toHaveAttribute('type', 'number')
fireEvent.change(input, {
target: { value: '42', valueAsNumber: 42 },
})
expect(queryClient.getQueryData(['data'])).toEqual({ count: 42 })
})
it('should render "ToggleValueButton" inline for a boolean primitive row', () => {
queryClient.setQueryData(['data'], { flag: false })
const rendered = renderExplorer({
label: 'flag',
value: false,
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
dataPath: ['flag'],
})
expect(rendered.getByLabelText('Toggle value')).toBeInTheDocument()
})
it('should render "DeleteItemButton" inline when a primitive row has "itemsDeletable"', () => {
queryClient.setQueryData(['data'], { name: 'Anna' })
const rendered = renderExplorer({
label: 'name',
value: 'Anna',
editable: true,
itemsDeletable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
dataPath: ['name'],
})
expect(rendered.getByLabelText('Delete item')).toBeInTheDocument()
})
it('should delete fields from the active query when their inline delete buttons are clicked', () => {
const value = { name: 'Anna', age: 30 }
queryClient.setQueryData(['data'], value)
const rendered = renderExplorer({
label: 'Data',
value,
defaultExpanded: ['Data'],
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
})
const ageRow = rendered.getByText('age:').parentElement!
fireEvent.click(within(ageRow).getByLabelText('Delete item'))
expect(queryClient.getQueryData(['data'])).toEqual({ name: 'Anna' })
const nameRow = rendered.getByText('name:').parentElement!
fireEvent.click(within(nameRow).getByLabelText('Delete item'))
expect(queryClient.getQueryData(['data'])).toEqual({})
})
})
describe('theme', () => {
it('should render without throwing under the "light" theme', () => {
const value = { items: ['a'], flag: true }
queryClient.setQueryData(['data'], value)
expect(() =>
renderExplorer(
{
label: 'Data',
value,
defaultExpanded: ['Data'],
editable: true,
activeQuery: queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query,
},
{ theme: 'light' },
),
).not.toThrow()
})
})
describe('"shadowDOMTarget"', () => {
it('should render without throwing when a "shadowDOMTarget" is provided', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const shadowRoot = host.attachShadow({ mode: 'open' })
const value = { items: ['a'], flag: true }
queryClient.setQueryData(['data'], value)
try {
expect(() =>
render(() => (
<QueryDevtoolsContext.Provider
value={{
client: queryClient,
queryFlavor: 'TanStack Query',
version: '5',
onlineManager,
shadowDOMTarget: shadowRoot,
}}
>
<ThemeContext.Provider value={() => 'dark'}>
<Explorer
label="Data"
value={value}
defaultExpanded={['Data']}
editable={true}
activeQuery={
queryClient
.getQueryCache()
.find({ queryKey: ['data'] }) as Query
}
/>
</ThemeContext.Provider>
</QueryDevtoolsContext.Provider>
)),
).not.toThrow()
} finally {
host.remove()
}
})
})
})

View File

@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { QueryClient, onlineManager } from '@tanstack/query-core'
import { TanstackQueryDevtools } from '..'
describe('TanstackQueryDevtools', () => {
let devtools: TanstackQueryDevtools
beforeEach(() => {
devtools = new TanstackQueryDevtools({
client: new QueryClient(),
queryFlavor: 'TanStack Query',
version: '5',
onlineManager,
})
})
describe('mount', () => {
it('should mount devtools to the provided element', () => {
const el = document.createElement('div')
expect(() => devtools.mount(el)).not.toThrow()
devtools.unmount()
})
it('should throw if mount is called twice without unmount', () => {
const el = document.createElement('div')
devtools.mount(el)
expect(() => devtools.mount(el)).toThrow('Devtools is already mounted')
devtools.unmount()
})
})
describe('unmount', () => {
it('should unmount devtools and allow remounting', () => {
const el = document.createElement('div')
devtools.mount(el)
expect(() => devtools.unmount()).not.toThrow()
expect(() => devtools.mount(el)).not.toThrow()
devtools.unmount()
})
it('should throw if unmount is called before mount', () => {
expect(() => devtools.unmount()).toThrow('Devtools is not mounted')
})
it('should throw if unmount is called twice', () => {
const el = document.createElement('div')
devtools.mount(el)
devtools.unmount()
expect(() => devtools.unmount()).toThrow('Devtools is not mounted')
})
})
describe('setters', () => {
describe('before mount', () => {
it('should not throw when "setButtonPosition" is called', () => {
expect(() => devtools.setButtonPosition('top-left')).not.toThrow()
})
it('should not throw when "setPosition" is called', () => {
expect(() => devtools.setPosition('left')).not.toThrow()
})
it('should not throw when "setInitialIsOpen" is called', () => {
expect(() => devtools.setInitialIsOpen(true)).not.toThrow()
})
it('should not throw when "setErrorTypes" is called', () => {
expect(() =>
devtools.setErrorTypes([
{ name: 'NetworkError', initializer: () => new Error('Network') },
]),
).not.toThrow()
})
it('should not throw when "setClient" is called', () => {
expect(() => devtools.setClient(new QueryClient())).not.toThrow()
})
it('should not throw when "setTheme" is called', () => {
expect(() => devtools.setTheme('dark')).not.toThrow()
expect(() => devtools.setTheme('light')).not.toThrow()
expect(() => devtools.setTheme('system')).not.toThrow()
expect(() => devtools.setTheme(undefined)).not.toThrow()
})
})
describe('after mount', () => {
it('should not throw when setters are called on a mounted instance', () => {
const el = document.createElement('div')
devtools.mount(el)
try {
expect(() => devtools.setButtonPosition('top-left')).not.toThrow()
expect(() => devtools.setPosition('left')).not.toThrow()
expect(() => devtools.setInitialIsOpen(true)).not.toThrow()
expect(() =>
devtools.setErrorTypes([
{ name: 'NetworkError', initializer: () => new Error('Network') },
]),
).not.toThrow()
expect(() => devtools.setClient(new QueryClient())).not.toThrow()
expect(() => devtools.setTheme('dark')).not.toThrow()
} finally {
devtools.unmount()
}
})
})
})
})

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { QueryClient, onlineManager } from '@tanstack/query-core'
import { TanstackQueryDevtoolsPanel } from '..'
describe('TanstackQueryDevtoolsPanel', () => {
let devtools: TanstackQueryDevtoolsPanel
beforeEach(() => {
devtools = new TanstackQueryDevtoolsPanel({
client: new QueryClient(),
queryFlavor: 'TanStack Query',
version: '5',
onlineManager,
})
})
describe('mount', () => {
it('should mount devtools to the provided element', () => {
const el = document.createElement('div')
expect(() => devtools.mount(el)).not.toThrow()
devtools.unmount()
})
it('should throw if mount is called twice without unmount', () => {
const el = document.createElement('div')
devtools.mount(el)
expect(() => devtools.mount(el)).toThrow('Devtools is already mounted')
devtools.unmount()
})
})
describe('unmount', () => {
it('should unmount devtools and allow remounting', () => {
const el = document.createElement('div')
devtools.mount(el)
expect(() => devtools.unmount()).not.toThrow()
expect(() => devtools.mount(el)).not.toThrow()
devtools.unmount()
})
it('should throw if unmount is called before mount', () => {
expect(() => devtools.unmount()).toThrow('Devtools is not mounted')
})
it('should throw if unmount is called twice', () => {
const el = document.createElement('div')
devtools.mount(el)
devtools.unmount()
expect(() => devtools.unmount()).toThrow('Devtools is not mounted')
})
})
describe('setters', () => {
describe('before mount', () => {
it('should not throw when "setButtonPosition" is called', () => {
expect(() => devtools.setButtonPosition('top-left')).not.toThrow()
})
it('should not throw when "setPosition" is called', () => {
expect(() => devtools.setPosition('left')).not.toThrow()
})
it('should not throw when "setInitialIsOpen" is called', () => {
expect(() => devtools.setInitialIsOpen(true)).not.toThrow()
})
it('should not throw when "setErrorTypes" is called', () => {
expect(() =>
devtools.setErrorTypes([
{ name: 'NetworkError', initializer: () => new Error('Network') },
]),
).not.toThrow()
})
it('should not throw when "setClient" is called', () => {
expect(() => devtools.setClient(new QueryClient())).not.toThrow()
})
it('should not throw when "setOnClose" is called', () => {
expect(() => devtools.setOnClose(() => {})).not.toThrow()
})
it('should not throw when "setTheme" is called', () => {
expect(() => devtools.setTheme('dark')).not.toThrow()
expect(() => devtools.setTheme('light')).not.toThrow()
expect(() => devtools.setTheme('system')).not.toThrow()
expect(() => devtools.setTheme(undefined)).not.toThrow()
})
})
describe('after mount', () => {
it('should not throw when setters are called on a mounted instance', () => {
const el = document.createElement('div')
devtools.mount(el)
try {
expect(() => devtools.setButtonPosition('top-left')).not.toThrow()
expect(() => devtools.setPosition('left')).not.toThrow()
expect(() => devtools.setInitialIsOpen(true)).not.toThrow()
expect(() =>
devtools.setErrorTypes([
{ name: 'NetworkError', initializer: () => new Error('Network') },
]),
).not.toThrow()
expect(() => devtools.setClient(new QueryClient())).not.toThrow()
expect(() => devtools.setOnClose(() => {})).not.toThrow()
expect(() => devtools.setTheme('dark')).not.toThrow()
} finally {
devtools.unmount()
}
})
})
})
})

View File

@@ -0,0 +1,472 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render } from '@solidjs/testing-library'
import { createEffect } from 'solid-js'
import { createLocalStorage } from '@solid-primitives/storage'
import { PiPProvider, usePiPWindow } from '../../contexts'
type FakePipWindowOverrides = {
document?: Document
innerWidth?: number
innerHeight?: number
}
function stubPipWindow(overrides: FakePipWindowOverrides = {}) {
const pipDocument =
overrides.document ?? document.implementation.createHTMLDocument('PiP')
const fakeWindow = {
document: pipDocument,
innerWidth: overrides.innerWidth ?? 800,
innerHeight: overrides.innerHeight ?? 600,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
close: vi.fn(),
}
const open = vi.fn(() => fakeWindow)
vi.stubGlobal('open', open)
return { pipDocument, fakeWindow, open }
}
describe('PiPContext', () => {
afterEach(() => {
vi.unstubAllGlobals()
localStorage.clear()
})
function renderAndAct(
action: (pip: ReturnType<typeof usePiPWindow>) => void,
options: {
disabled?: boolean
initialStorage?: Record<string, string>
} = {},
) {
Object.entries(options.initialStorage ?? {}).forEach(([key, value]) => {
localStorage.setItem(key, value)
})
render(() => {
const [localStore, setLocalStore] = createLocalStorage({
prefix: 'TanstackQueryDevtools',
})
return (
<PiPProvider
localStore={localStore}
setLocalStore={setLocalStore}
disabled={options.disabled}
>
<PiPActor run={action} />
</PiPProvider>
)
})
}
function PiPActor(props: {
run: (pip: ReturnType<typeof usePiPWindow>) => void
}) {
const pip = usePiPWindow()
let hasRun = false
createEffect(() => {
pip()
if (!hasRun) {
hasRun = true
props.run(pip)
}
})
return null
}
describe('usePiPWindow', () => {
it('should throw when used outside a "PiPProvider"', () => {
function PiPProbe() {
usePiPWindow()
return null
}
expect(() => render(() => <PiPProbe />)).toThrow(
'usePiPWindow must be used within a PiPProvider',
)
})
})
describe('"requestPipWindow"', () => {
it('should call "window.open" with the expected target and features', () => {
const { open } = stubPipWindow()
renderAndAct((pip) => pip().requestPipWindow(640, 480))
expect(open).toHaveBeenCalledWith(
'',
'TSQD-Devtools-Panel',
'width=640,height=480,popup',
)
})
it('should set the "pipWindow" signal to the opened window', () => {
const { fakeWindow } = stubPipWindow()
let observed: Window | null = null
renderAndAct((pip) => {
pip().requestPipWindow(640, 480)
observed = pip().pipWindow
})
expect(observed).toBe(fakeWindow)
})
it('should persist "pip_open" as "true" to "localStore" after a successful open', () => {
stubPipWindow()
renderAndAct((pip) => pip().requestPipWindow(640, 480))
expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe(
'true',
)
})
it('should set the PiP document title to "TanStack Query Devtools"', () => {
const { pipDocument } = stubPipWindow()
renderAndAct((pip) => pip().requestPipWindow(640, 480))
expect(pipDocument.title).toBe('TanStack Query Devtools')
})
it('should reset the PiP document body margin to "0"', () => {
const { pipDocument } = stubPipWindow()
renderAndAct((pip) => pip().requestPipWindow(640, 480))
expect(pipDocument.body.style.margin).toMatch(/^0(px)?$/)
})
it('should clear any existing nodes in the PiP document "head"', () => {
const { pipDocument } = stubPipWindow()
pipDocument.head.appendChild(pipDocument.createElement('meta'))
renderAndAct((pip) => pip().requestPipWindow(640, 480))
expect(pipDocument.head.querySelector('meta')).toBeNull()
})
it('should clear any existing nodes in the PiP document "body"', () => {
const { pipDocument } = stubPipWindow()
const leftover = pipDocument.createElement('div')
leftover.id = 'leftover'
pipDocument.body.appendChild(leftover)
renderAndAct((pip) => pip().requestPipWindow(640, 480))
expect(pipDocument.body.querySelector('#leftover')).toBeNull()
})
})
describe('styleSheet propagation', () => {
type FakeCssRule = { readonly cssText: string }
type FakeStyleSheet = {
readonly cssRules?: ArrayLike<FakeCssRule>
readonly href?: string | null
readonly type?: string
readonly media?: { toString: () => string }
readonly ownerNode?: Element | null
}
function makeCssRules(...cssTexts: Array<string>): ArrayLike<FakeCssRule> {
return cssTexts.map((cssText) => ({ cssText }))
}
function stubParentStyleSheet(sheet: FakeStyleSheet) {
return vi
.spyOn(document, 'styleSheets', 'get')
.mockReturnValue([sheet] as unknown as StyleSheetList)
}
it('should copy parent stylesheets as "<style>" with the same id into the PiP document head', () => {
const sourceStyle = document.createElement('style')
sourceStyle.id = 'tsqd-source'
const sheetSpy = stubParentStyleSheet({
cssRules: makeCssRules('.tsqd { color: red; }'),
ownerNode: sourceStyle,
})
const { pipDocument } = stubPipWindow()
try {
renderAndAct((pip) => pip().requestPipWindow(640, 480))
const copied = pipDocument.head.querySelector('style#tsqd-source')
expect(copied).not.toBeNull()
expect(copied?.textContent).toBe('.tsqd { color: red; }')
} finally {
sheetSpy.mockRestore()
}
})
it('should fall back to a "<link>" for cross-origin stylesheets whose "cssRules" throw', () => {
const sheetSpy = stubParentStyleSheet({
get cssRules(): CSSRuleList {
throw new DOMException('blocked', 'SecurityError')
},
href: 'https://example.com/external.css',
type: 'text/css',
media: { toString: () => 'all' },
})
const { pipDocument } = stubPipWindow()
try {
renderAndAct((pip) => pip().requestPipWindow(640, 480))
const link = pipDocument.head.querySelector<HTMLLinkElement>(
'link[href="https://example.com/external.css"]',
)
expect(link).not.toBeNull()
expect(link?.rel).toBe('stylesheet')
} finally {
sheetSpy.mockRestore()
}
})
it('should skip the "<link>" fallback when the cross-origin stylesheet has no "href"', () => {
const sheetSpy = stubParentStyleSheet({
get cssRules(): CSSRuleList {
throw new DOMException('blocked', 'SecurityError')
},
href: null,
type: 'text/css',
media: { toString: () => 'all' },
})
const { pipDocument } = stubPipWindow()
try {
renderAndAct((pip) => pip().requestPipWindow(640, 480))
expect(pipDocument.head.querySelector('link')).toBeNull()
expect(pipDocument.head.querySelector('style')).toBeNull()
} finally {
sheetSpy.mockRestore()
}
})
})
describe('"pagehide" lifecycle', () => {
it('should reset the "pipWindow" signal and "pip_open" when the "pagehide" event fires on the opened window', () => {
const { fakeWindow } = stubPipWindow()
const observed: Array<Window | null> = []
renderAndAct(
(pip) => {
pip().requestPipWindow(640, 480)
observed.push(pip().pipWindow)
const pagehideHandler = fakeWindow.addEventListener.mock.calls.find(
([event]) => event === 'pagehide',
)?.[1]
pagehideHandler?.()
observed.push(pip().pipWindow)
},
{ disabled: true },
)
expect(observed).toEqual([fakeWindow, null])
expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe(
'false',
)
})
})
describe('"closePipWindow"', () => {
it('should be a noop when no window is open', () => {
const { fakeWindow } = stubPipWindow()
renderAndAct((pip) => {
pip().closePipWindow()
})
expect(fakeWindow.close).not.toHaveBeenCalled()
})
it('should call "close" on the opened window and reset the "pipWindow" signal', () => {
const { fakeWindow } = stubPipWindow()
const observed: Array<Window | null> = []
renderAndAct(
(pip) => {
pip().requestPipWindow(640, 480)
observed.push(pip().pipWindow)
pip().closePipWindow()
observed.push(pip().pipWindow)
},
{ disabled: true },
)
expect(fakeWindow.close).toHaveBeenCalledTimes(1)
expect(observed).toEqual([fakeWindow, null])
})
it('should call "close" only once when "closePipWindow" runs twice in a row', () => {
const { fakeWindow } = stubPipWindow()
renderAndAct(
(pip) => {
pip().requestPipWindow(640, 480)
pip().closePipWindow()
pip().closePipWindow()
},
{ disabled: true },
)
expect(fakeWindow.close).toHaveBeenCalledTimes(1)
})
it('should reset "pip_open" in "localStore" so the auto-open createEffect does not reopen the window', () => {
stubPipWindow()
renderAndAct(
(pip) => {
pip().requestPipWindow(640, 480)
pip().closePipWindow()
},
{ disabled: true },
)
expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe(
'false',
)
})
})
describe('"pip_open" auto-open createEffect', () => {
it('should auto-open a PiP window when "pip_open" is "true" on mount', () => {
const { open } = stubPipWindow()
renderAndAct(() => {}, {
initialStorage: { 'TanstackQueryDevtools.pip_open': 'true' },
})
expect(open).toHaveBeenCalled()
})
it('should not auto-open a PiP window when "disabled" is true', () => {
const { open } = stubPipWindow()
renderAndAct(() => {}, {
disabled: true,
initialStorage: { 'TanstackQueryDevtools.pip_open': 'true' },
})
expect(open).not.toHaveBeenCalled()
})
it('should reset "pip_open"/"open" and log when "window.open" returns null on auto-open', () => {
vi.stubGlobal(
'open',
vi.fn(() => null),
)
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
try {
renderAndAct(() => {}, {
initialStorage: { 'TanstackQueryDevtools.pip_open': 'true' },
})
expect(consoleError).toHaveBeenCalledWith(
'Failed to open popup. Please allow popups for this site to view the devtools in picture-in-picture mode.',
)
expect(localStorage.getItem('TanstackQueryDevtools.pip_open')).toBe(
'false',
)
expect(localStorage.getItem('TanstackQueryDevtools.open')).toBe('false')
} finally {
consoleError.mockRestore()
}
})
it('should re-throw non-"PipOpenError" errors from "window.open" on auto-open', () => {
vi.stubGlobal(
'open',
vi.fn(() => {
throw new Error('unexpected')
}),
)
expect(() =>
renderAndAct(() => {}, {
initialStorage: { 'TanstackQueryDevtools.pip_open': 'true' },
}),
).toThrow('unexpected')
})
})
describe('"#_goober" MutationObserver', () => {
function stubMutationObserver() {
let observerCallback: MutationCallback | undefined
const observeSpy = vi.fn()
const disconnectSpy = vi.fn()
class FakeMutationObserver {
observe = observeSpy
disconnect = disconnectSpy
takeRecords = () => []
constructor(cb: MutationCallback) {
observerCallback = cb
}
}
vi.stubGlobal('MutationObserver', FakeMutationObserver)
return {
observeSpy,
disconnectSpy,
fire: () => observerCallback?.([], {} as MutationObserver),
}
}
it('should observe the parent "#_goober" style for childList and subtree mutations', () => {
const gooberStyle = document.createElement('style')
gooberStyle.id = '_goober'
gooberStyle.textContent = '.initial { color: red; }'
document.head.appendChild(gooberStyle)
const { observeSpy } = stubMutationObserver()
stubPipWindow()
try {
renderAndAct((pip) => pip().requestPipWindow(640, 480), {
disabled: true,
})
expect(observeSpy).toHaveBeenCalledWith(gooberStyle, {
childList: true,
subtree: true,
characterDataOldValue: true,
})
} finally {
gooberStyle.remove()
}
})
it('should copy parent "#_goober" "textContent" into the PiP mirror when the observer fires', () => {
const gooberStyle = document.createElement('style')
gooberStyle.id = '_goober'
gooberStyle.textContent = '.initial { color: red; }'
document.head.appendChild(gooberStyle)
const { fire } = stubMutationObserver()
const { pipDocument } = stubPipWindow()
try {
const pipGooberStyle = pipDocument.createElement('style')
pipGooberStyle.id = '_goober'
renderAndAct(
(pip) => {
pip().requestPipWindow(640, 480)
// `requestPipWindow` clears the PiP document head, so install
// the goober mirror style after that point.
pipDocument.head.appendChild(pipGooberStyle)
},
{ disabled: true },
)
gooberStyle.textContent = '.next { color: blue; }'
fire()
expect(pipGooberStyle.textContent).toBe('.next { color: blue; }')
} finally {
gooberStyle.remove()
}
})
})
})

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { render } from '@solidjs/testing-library'
import { createEffect, createSignal } from 'solid-js'
import { QueryClient, onlineManager } from '@tanstack/query-core'
import { QueryDevtoolsContext, useQueryDevtoolsContext } from '../../contexts'
describe('QueryDevtoolsContext', () => {
it('should reflect updates when the "Provider" value exposes a reactive getter', () => {
const initialClient = new QueryClient()
const nextClient = new QueryClient()
const [client, setClient] = createSignal(initialClient)
const observed: Array<QueryClient> = []
function ContextProbe() {
const ctx = useQueryDevtoolsContext()
createEffect(() => {
observed.push(ctx.client)
})
return null
}
render(() => (
<QueryDevtoolsContext.Provider
value={{
queryFlavor: 'TanStack Query',
version: '5',
onlineManager,
get client() {
return client()
},
}}
>
<ContextProbe />
</QueryDevtoolsContext.Provider>
))
expect(observed).toEqual([initialClient])
setClient(nextClient)
expect(observed).toEqual([initialClient, nextClient])
initialClient.clear()
nextClient.clear()
})
})

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { render } from '@solidjs/testing-library'
import { createEffect, createSignal } from 'solid-js'
import { ThemeContext, useTheme } from '../../contexts'
type Theme = ReturnType<ReturnType<typeof useTheme>>
describe('ThemeContext', () => {
describe('default value', () => {
it('should resolve to "dark" when no "ThemeContext.Provider" wraps the consumer', () => {
let resolvedTheme: Theme | undefined
function ThemeProbe() {
const theme = useTheme()
resolvedTheme = theme()
return null
}
render(() => <ThemeProbe />)
expect(resolvedTheme).toBe('dark')
})
})
describe('with a "ThemeContext.Provider"', () => {
it('should reflect updates when the "Provider" value is a reactive "Accessor"', () => {
const [theme, setTheme] = createSignal<Theme>('dark')
const observed: Array<Theme> = []
function ThemeProbe() {
const resolved = useTheme()
createEffect(() => {
observed.push(resolved())
})
return null
}
render(() => (
<ThemeContext.Provider value={theme}>
<ThemeProbe />
</ThemeContext.Provider>
))
expect(observed).toEqual(['dark'])
setTheme('light')
expect(observed).toEqual(['dark', 'light'])
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
import { mutationSortFns, sortFns } from './utils'
import type { DevtoolsButtonPosition, DevtoolsPosition } from './contexts'
export const firstBreakpoint = 1024
export const secondBreakpoint = 796
export const thirdBreakpoint = 700
export const BUTTON_POSITION: DevtoolsButtonPosition = 'bottom-right'
export const POSITION: DevtoolsPosition = 'bottom'
export const THEME_PREFERENCE = 'system'
export const INITIAL_IS_OPEN = false
export const DEFAULT_HEIGHT = 500
export const PIP_DEFAULT_HEIGHT = 500
export const DEFAULT_WIDTH = 500
export const DEFAULT_SORT_FN_NAME = Object.keys(sortFns)[0]
export const DEFAULT_SORT_ORDER = 1
export const DEFAULT_MUTATION_SORT_FN_NAME = Object.keys(mutationSortFns)[0]

View File

@@ -0,0 +1,204 @@
import {
createContext,
createEffect,
createMemo,
createSignal,
onCleanup,
useContext,
} from 'solid-js'
import { clearDelegatedEvents, delegateEvents } from 'solid-js/web'
import { PIP_DEFAULT_HEIGHT } from '../constants'
import { useQueryDevtoolsContext } from './QueryDevtoolsContext'
import type { Accessor, JSX } from 'solid-js'
import type { StorageObject, StorageSetter } from '@solid-primitives/storage'
interface PiPProviderProps {
children: JSX.Element
localStore: StorageObject<string>
setLocalStore: StorageSetter<string, unknown>
disabled?: boolean
}
type PiPContextType = {
pipWindow: Window | null
requestPipWindow: (width: number, height: number) => void
closePipWindow: () => void
disabled: boolean
}
class PipOpenError extends Error {}
const PiPContext = createContext<Accessor<PiPContextType> | undefined>(
undefined,
)
export const PiPProvider = (props: PiPProviderProps) => {
// Expose pipWindow that is currently active
const [pipWindow, setPipWindow] = createSignal<Window | null>(null)
// Close pipWindow programmatically
const closePipWindow = () => {
const w = pipWindow()
if (w != null) {
w.close()
props.setLocalStore('pip_open', 'false')
setPipWindow(null)
}
}
// Open new pipWindow
const requestPipWindow = (width: number, height: number) => {
// We don't want to allow multiple requests.
if (pipWindow() != null) {
return
}
const pip = window.open(
'',
'TSQD-Devtools-Panel',
`width=${width},height=${height},popup`,
)
if (!pip) {
throw new PipOpenError(
'Failed to open popup. Please allow popups for this site to view the devtools in picture-in-picture mode.',
)
}
// Remove existing styles
pip.document.head.innerHTML = ''
// Remove existing body
pip.document.body.innerHTML = ''
// Clear Delegated Events
clearDelegatedEvents(pip.document)
pip.document.title = 'TanStack Query Devtools'
pip.document.body.style.margin = '0'
// Detect when window is closed by user
pip.addEventListener('pagehide', () => {
props.setLocalStore('pip_open', 'false')
setPipWindow(null)
})
// It is important to copy all parent window styles. Otherwise, there would be no CSS available at all
// https://developer.chrome.com/docs/web-platform/document-picture-in-picture/#copy-style-sheets-to-the-picture-in-picture-window
;[
...(useQueryDevtoolsContext().shadowDOMTarget || document).styleSheets,
].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules]
.map((rule) => rule.cssText)
.join('')
const style = document.createElement('style')
const style_node = styleSheet.ownerNode
let style_id = ''
if (style_node && 'id' in style_node) {
style_id = style_node.id
}
if (style_id) {
style.setAttribute('id', style_id)
}
style.textContent = cssRules
pip.document.head.appendChild(style)
} catch (e) {
const link = document.createElement('link')
if (styleSheet.href == null) {
return
}
link.rel = 'stylesheet'
link.type = styleSheet.type
link.media = styleSheet.media.toString()
link.href = styleSheet.href
pip.document.head.appendChild(link)
}
})
delegateEvents(
[
'focusin',
'focusout',
'pointermove',
'keydown',
'pointerdown',
'pointerup',
'click',
'mousedown',
'input',
],
pip.document,
)
props.setLocalStore('pip_open', 'true')
setPipWindow(pip)
}
createEffect(() => {
const pip_open = (props.localStore.pip_open ?? 'false') as 'true' | 'false'
if (pip_open === 'true' && !props.disabled) {
try {
requestPipWindow(
Number(window.innerWidth),
Number(props.localStore.height || PIP_DEFAULT_HEIGHT),
)
} catch (error) {
if (error instanceof PipOpenError) {
console.error(error.message)
props.setLocalStore('pip_open', 'false')
props.setLocalStore('open', 'false')
return
}
throw error
}
}
})
createEffect(() => {
// Setup mutation observer for goober styles with id `_goober
const gooberStyles = (
useQueryDevtoolsContext().shadowDOMTarget || document
).querySelector('#_goober')
const w = pipWindow()
if (gooberStyles && w) {
const observer = new MutationObserver(() => {
const pip_style = (
useQueryDevtoolsContext().shadowDOMTarget || w.document
).querySelector('#_goober')
if (pip_style) {
pip_style.textContent = gooberStyles.textContent
}
})
observer.observe(gooberStyles, {
childList: true, // observe direct children
subtree: true, // and lower descendants too
characterDataOldValue: true, // pass old data to callback
})
onCleanup(() => {
observer.disconnect()
})
}
})
const value = createMemo(() => ({
pipWindow: pipWindow(),
requestPipWindow,
closePipWindow,
disabled: props.disabled ?? false,
}))
return (
<PiPContext.Provider value={value}>{props.children}</PiPContext.Provider>
)
}
export const usePiPWindow = () => {
const context = createMemo(() => {
const ctx = useContext(PiPContext)
if (!ctx) {
throw new Error('usePiPWindow must be used within a PiPProvider')
}
return ctx()
})
return context
}

View File

@@ -0,0 +1,23 @@
import { createContext, useContext } from 'solid-js'
import type { QueryClient, onlineManager } from '@tanstack/query-core'
import type { QueryDevtoolsProps } from './types'
export type {
DevtoolsButtonPosition,
DevtoolsErrorType,
DevtoolsPosition,
QueryDevtoolsProps,
Theme,
} from './types'
export const QueryDevtoolsContext = createContext<QueryDevtoolsProps>({
client: undefined as unknown as QueryClient,
onlineManager: undefined as unknown as typeof onlineManager,
queryFlavor: '',
version: '',
shadowDOMTarget: undefined,
})
export function useQueryDevtoolsContext() {
return useContext(QueryDevtoolsContext)
}

View File

@@ -0,0 +1,10 @@
import { createContext, useContext } from 'solid-js'
import type { Accessor } from 'solid-js'
export const ThemeContext = createContext<Accessor<'light' | 'dark'>>(
() => 'dark' as const,
)
export function useTheme() {
return useContext(ThemeContext)
}

View File

@@ -0,0 +1,3 @@
export * from './PiPContext'
export * from './QueryDevtoolsContext'
export * from './ThemeContext'

View File

@@ -0,0 +1,35 @@
import type { Query, QueryClient, onlineManager } from '@tanstack/query-core'
type XPosition = 'left' | 'right'
type YPosition = 'top' | 'bottom'
export type DevtoolsPosition = XPosition | YPosition
export type DevtoolsButtonPosition = `${YPosition}-${XPosition}` | 'relative'
export type Theme = 'dark' | 'light' | 'system'
export interface DevtoolsErrorType {
/**
* The name of the error.
*/
name: string
/**
* How the error is initialized.
*/
initializer: (query: Query) => Error
}
export interface QueryDevtoolsProps {
readonly client: QueryClient
queryFlavor: string
version: string
onlineManager: typeof onlineManager
buttonPosition?: DevtoolsButtonPosition
position?: DevtoolsPosition
initialIsOpen?: boolean
errorTypes?: Array<DevtoolsErrorType>
shadowDOMTarget?: ShadowRoot
onClose?: () => void
hideDisabledQueries?: boolean
theme?: Theme
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
export type {
DevtoolsButtonPosition,
DevtoolsErrorType,
DevtoolsPosition,
Theme,
} from './contexts/types'
export {
TanstackQueryDevtools,
type TanstackQueryDevtoolsConfig,
} from './TanstackQueryDevtools'
export {
TanstackQueryDevtoolsPanel,
type TanstackQueryDevtoolsPanelConfig,
} from './TanstackQueryDevtoolsPanel'

View File

@@ -0,0 +1,299 @@
export const tokens = {
colors: {
inherit: 'inherit',
current: 'currentColor',
transparent: 'transparent',
black: '#000000',
white: '#ffffff',
neutral: {
50: '#f9fafb',
100: '#f2f4f7',
200: '#eaecf0',
300: '#d0d5dd',
400: '#98a2b3',
500: '#667085',
600: '#475467',
700: '#344054',
800: '#1d2939',
900: '#101828',
},
darkGray: {
50: '#525c7a',
100: '#49536e',
200: '#414962',
300: '#394056',
400: '#313749',
500: '#292e3d',
600: '#212530',
700: '#191c24',
800: '#111318',
900: '#0b0d10',
},
gray: {
50: '#f9fafb',
100: '#f2f4f7',
200: '#eaecf0',
300: '#d0d5dd',
400: '#98a2b3',
500: '#667085',
600: '#475467',
700: '#344054',
800: '#1d2939',
900: '#101828',
},
blue: {
25: '#F5FAFF',
50: '#EFF8FF',
100: '#D1E9FF',
200: '#B2DDFF',
300: '#84CAFF',
400: '#53B1FD',
500: '#2E90FA',
600: '#1570EF',
700: '#175CD3',
800: '#1849A9',
900: '#194185',
},
green: {
25: '#F6FEF9',
50: '#ECFDF3',
100: '#D1FADF',
200: '#A6F4C5',
300: '#6CE9A6',
400: '#32D583',
500: '#12B76A',
600: '#039855',
700: '#027A48',
800: '#05603A',
900: '#054F31',
},
red: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
yellow: {
25: '#FFFCF5',
50: '#FFFAEB',
100: '#FEF0C7',
200: '#FEDF89',
300: '#FEC84B',
400: '#FDB022',
500: '#F79009',
600: '#DC6803',
700: '#B54708',
800: '#93370D',
900: '#7A2E0E',
},
purple: {
25: '#FAFAFF',
50: '#F4F3FF',
100: '#EBE9FE',
200: '#D9D6FE',
300: '#BDB4FE',
400: '#9B8AFB',
500: '#7A5AF8',
600: '#6938EF',
700: '#5925DC',
800: '#4A1FB8',
900: '#3E1C96',
},
teal: {
25: '#F6FEFC',
50: '#F0FDF9',
100: '#CCFBEF',
200: '#99F6E0',
300: '#5FE9D0',
400: '#2ED3B7',
500: '#15B79E',
600: '#0E9384',
700: '#107569',
800: '#125D56',
900: '#134E48',
},
pink: {
25: '#fdf2f8',
50: '#fce7f3',
100: '#fbcfe8',
200: '#f9a8d4',
300: '#f472b6',
400: '#ec4899',
500: '#db2777',
600: '#be185d',
700: '#9d174d',
800: '#831843',
900: '#500724',
},
cyan: {
25: '#ecfeff',
50: '#cffafe',
100: '#a5f3fc',
200: '#67e8f9',
300: '#22d3ee',
400: '#06b6d4',
500: '#0891b2',
600: '#0e7490',
700: '#155e75',
800: '#164e63',
900: '#083344',
},
},
alpha: {
100: 'ff',
90: 'e5',
80: 'cc',
70: 'b3',
60: '99',
50: '80',
40: '66',
30: '4d',
20: '33',
10: '1a',
0: '00',
},
font: {
size: {
'2xs': 'calc(var(--tsqd-font-size) * 0.625)',
xs: 'calc(var(--tsqd-font-size) * 0.75)',
sm: 'calc(var(--tsqd-font-size) * 0.875)',
md: 'var(--tsqd-font-size)',
lg: 'calc(var(--tsqd-font-size) * 1.125)',
xl: 'calc(var(--tsqd-font-size) * 1.25)',
'2xl': 'calc(var(--tsqd-font-size) * 1.5)',
'3xl': 'calc(var(--tsqd-font-size) * 1.875)',
'4xl': 'calc(var(--tsqd-font-size) * 2.25)',
'5xl': 'calc(var(--tsqd-font-size) * 3)',
'6xl': 'calc(var(--tsqd-font-size) * 3.75)',
'7xl': 'calc(var(--tsqd-font-size) * 4.5)',
'8xl': 'calc(var(--tsqd-font-size) * 6)',
'9xl': 'calc(var(--tsqd-font-size) * 8)',
},
lineHeight: {
xs: 'calc(var(--tsqd-font-size) * 1)',
sm: 'calc(var(--tsqd-font-size) * 1.25)',
md: 'calc(var(--tsqd-font-size) * 1.5)',
lg: 'calc(var(--tsqd-font-size) * 1.75)',
xl: 'calc(var(--tsqd-font-size) * 2)',
'2xl': 'calc(var(--tsqd-font-size) * 2.25)',
'3xl': 'calc(var(--tsqd-font-size) * 2.5)',
'4xl': 'calc(var(--tsqd-font-size) * 2.75)',
'5xl': 'calc(var(--tsqd-font-size) * 3)',
'6xl': 'calc(var(--tsqd-font-size) * 3.25)',
'7xl': 'calc(var(--tsqd-font-size) * 3.5)',
'8xl': 'calc(var(--tsqd-font-size) * 3.75)',
'9xl': 'calc(var(--tsqd-font-size) * 4)',
},
weight: {
thin: '100',
extralight: '200',
light: '300',
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900',
},
},
breakpoints: {
xs: '320px',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
border: {
radius: {
none: '0px',
xs: 'calc(var(--tsqd-font-size) * 0.125)',
sm: 'calc(var(--tsqd-font-size) * 0.25)',
md: 'calc(var(--tsqd-font-size) * 0.375)',
lg: 'calc(var(--tsqd-font-size) * 0.5)',
xl: 'calc(var(--tsqd-font-size) * 0.75)',
'2xl': 'calc(var(--tsqd-font-size) * 1)',
'3xl': 'calc(var(--tsqd-font-size) * 1.5)',
full: '9999px',
},
},
size: {
0: '0px',
0.25: 'calc(var(--tsqd-font-size) * 0.0625)',
0.5: 'calc(var(--tsqd-font-size) * 0.125)',
1: 'calc(var(--tsqd-font-size) * 0.25)',
1.5: 'calc(var(--tsqd-font-size) * 0.375)',
2: 'calc(var(--tsqd-font-size) * 0.5)',
2.5: 'calc(var(--tsqd-font-size) * 0.625)',
3: 'calc(var(--tsqd-font-size) * 0.75)',
3.5: 'calc(var(--tsqd-font-size) * 0.875)',
4: 'calc(var(--tsqd-font-size) * 1)',
4.5: 'calc(var(--tsqd-font-size) * 1.125)',
5: 'calc(var(--tsqd-font-size) * 1.25)',
5.5: 'calc(var(--tsqd-font-size) * 1.375)',
6: 'calc(var(--tsqd-font-size) * 1.5)',
6.5: 'calc(var(--tsqd-font-size) * 1.625)',
7: 'calc(var(--tsqd-font-size) * 1.75)',
8: 'calc(var(--tsqd-font-size) * 2)',
9: 'calc(var(--tsqd-font-size) * 2.25)',
10: 'calc(var(--tsqd-font-size) * 2.5)',
11: 'calc(var(--tsqd-font-size) * 2.75)',
12: 'calc(var(--tsqd-font-size) * 3)',
14: 'calc(var(--tsqd-font-size) * 3.5)',
16: 'calc(var(--tsqd-font-size) * 4)',
20: 'calc(var(--tsqd-font-size) * 5)',
24: 'calc(var(--tsqd-font-size) * 6)',
28: 'calc(var(--tsqd-font-size) * 7)',
32: 'calc(var(--tsqd-font-size) * 8)',
36: 'calc(var(--tsqd-font-size) * 9)',
40: 'calc(var(--tsqd-font-size) * 10)',
44: 'calc(var(--tsqd-font-size) * 11)',
48: 'calc(var(--tsqd-font-size) * 12)',
52: 'calc(var(--tsqd-font-size) * 13)',
56: 'calc(var(--tsqd-font-size) * 14)',
60: 'calc(var(--tsqd-font-size) * 15)',
64: 'calc(var(--tsqd-font-size) * 16)',
72: 'calc(var(--tsqd-font-size) * 18)',
80: 'calc(var(--tsqd-font-size) * 20)',
96: 'calc(var(--tsqd-font-size) * 24)',
},
shadow: {
xs: (_: string = 'rgb(0 0 0 / 0.1)') =>
`0 1px 2px 0 rgb(0 0 0 / 0.05)` as const,
sm: (color: string = 'rgb(0 0 0 / 0.1)') =>
`0 1px 3px 0 ${color}, 0 1px 2px -1px ${color}` as const,
md: (color: string = 'rgb(0 0 0 / 0.1)') =>
`0 4px 6px -1px ${color}, 0 2px 4px -2px ${color}` as const,
lg: (color: string = 'rgb(0 0 0 / 0.1)') =>
`0 10px 15px -3px ${color}, 0 4px 6px -4px ${color}` as const,
xl: (color: string = 'rgb(0 0 0 / 0.1)') =>
`0 20px 25px -5px ${color}, 0 8px 10px -6px ${color}` as const,
'2xl': (color: string = 'rgb(0 0 0 / 0.25)') =>
`0 25px 50px -12px ${color}` as const,
inner: (color: string = 'rgb(0 0 0 / 0.05)') =>
`inset 0 2px 4px 0 ${color}` as const,
none: () => `none` as const,
},
zIndices: {
hide: -1,
auto: 'auto',
base: 0,
docked: 10,
dropdown: 1000,
sticky: 1100,
banner: 1200,
overlay: 1300,
modal: 1400,
popover: 1500,
skipLink: 1600,
toast: 1700,
tooltip: 1800,
},
} as const

View File

@@ -0,0 +1,324 @@
import { serialize } from 'superjson'
import { createSignal, onCleanup, onMount } from 'solid-js'
import type { Mutation, Query } from '@tanstack/query-core'
import type { DevtoolsPosition } from './contexts'
export function getQueryStatusLabel(query: Query) {
return query.state.fetchStatus === 'fetching'
? 'fetching'
: !query.getObserversCount()
? 'inactive'
: query.state.fetchStatus === 'paused'
? 'paused'
: query.isStale()
? 'stale'
: 'fresh'
}
type QueryStatusLabel = 'fresh' | 'stale' | 'paused' | 'inactive' | 'fetching'
export function getSidedProp<T extends string>(
prop: T,
side: DevtoolsPosition,
) {
return `${prop}${
side.charAt(0).toUpperCase() + side.slice(1)
}` as `${T}${Capitalize<DevtoolsPosition>}`
}
export function getQueryStatusColor({
queryState,
observerCount,
isStale,
}: {
queryState: Query['state'] | undefined
observerCount: number
isStale: boolean
}) {
return queryState?.fetchStatus === 'fetching'
? 'blue'
: !observerCount
? 'gray'
: queryState?.fetchStatus === 'paused'
? 'purple'
: isStale
? 'yellow'
: 'green'
}
export function getMutationStatusColor({
status,
isPaused,
}: {
status: Mutation['state']['status']
isPaused: boolean
}) {
return isPaused
? 'purple'
: status === 'error'
? 'red'
: status === 'pending'
? 'yellow'
: status === 'success'
? 'green'
: 'gray'
}
export function getQueryStatusColorByLabel(label: QueryStatusLabel) {
return label === 'fresh'
? 'green'
: label === 'stale'
? 'yellow'
: label === 'paused'
? 'purple'
: label === 'inactive'
? 'gray'
: 'blue'
}
/**
* Displays a string regardless the type of the data
* @param {unknown} value Value to be stringified
* @param {boolean} beautify Formats json to multiline
*/
export const displayValue = (value: unknown, beautify: boolean = false) => {
const { json } = serialize(value)
return JSON.stringify(json, null, beautify ? 2 : undefined)
}
// Sorting functions
type SortFn = (a: Query, b: Query) => number
const getStatusRank = (q: Query) =>
q.state.fetchStatus !== 'idle'
? 0
: !q.getObserversCount()
? 3
: q.isStale()
? 2
: 1
const queryHashSort: SortFn = (a, b) => a.queryHash.localeCompare(b.queryHash)
const dateSort: SortFn = (a, b) => {
const diff = b.state.dataUpdatedAt - a.state.dataUpdatedAt
return diff < 0 ? -1 : diff > 0 ? 1 : 0
}
const statusAndDateSort: SortFn = (a, b) => {
if (getStatusRank(a) === getStatusRank(b)) {
return dateSort(a, b)
}
return getStatusRank(a) > getStatusRank(b) ? 1 : -1
}
export const sortFns: Record<string, SortFn> = {
status: statusAndDateSort,
'query hash': queryHashSort,
'last updated': dateSort,
}
type MutationSortFn = (a: Mutation, b: Mutation) => number
const getMutationStatusRank = (m: Mutation) =>
m.state.isPaused
? 0
: m.state.status === 'error'
? 2
: m.state.status === 'pending'
? 1
: 3
const mutationDateSort: MutationSortFn = (a, b) =>
a.state.submittedAt < b.state.submittedAt ? 1 : -1
const mutationStatusSort: MutationSortFn = (a, b) => {
if (getMutationStatusRank(a) === getMutationStatusRank(b)) {
return mutationDateSort(a, b)
}
return getMutationStatusRank(a) > getMutationStatusRank(b) ? 1 : -1
}
export const mutationSortFns: Record<string, MutationSortFn> = {
status: mutationStatusSort,
'last updated': mutationDateSort,
}
export const convertRemToPixels = (rem: number) => {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
}
export const getPreferredColorScheme = () => {
const [colorScheme, setColorScheme] = createSignal<'light' | 'dark'>('dark')
onMount(() => {
const query = window.matchMedia('(prefers-color-scheme: dark)')
setColorScheme(query.matches ? 'dark' : 'light')
const listener = (e: MediaQueryListEvent) => {
setColorScheme(e.matches ? 'dark' : 'light')
}
query.addEventListener('change', listener)
onCleanup(() => query.removeEventListener('change', listener))
})
return colorScheme
}
/**
* updates nested data by path
*
* @param {unknown} oldData Data to be updated
* @param {Array<string>} updatePath Path to the data to be updated
* @param {unknown} value New value
*/
export const updateNestedDataByPath = (
oldData: unknown,
updatePath: Array<string>,
value: unknown,
): any => {
if (updatePath.length === 0) {
return value
}
if (oldData instanceof Map) {
const newData = new Map(oldData)
if (updatePath.length === 1) {
newData.set(updatePath[0], value)
return newData
}
const [head, ...tail] = updatePath
newData.set(head, updateNestedDataByPath(newData.get(head), tail, value))
return newData
}
if (oldData instanceof Set) {
const setAsArray = updateNestedDataByPath(
Array.from(oldData),
updatePath,
value,
)
return new Set(setAsArray)
}
if (Array.isArray(oldData)) {
const newData = [...oldData]
if (updatePath.length === 1) {
// @ts-expect-error
newData[updatePath[0]] = value
return newData
}
const [head, ...tail] = updatePath
// @ts-expect-error
newData[head] = updateNestedDataByPath(newData[head], tail, value)
return newData
}
if (oldData instanceof Object) {
const newData = { ...oldData }
if (updatePath.length === 1) {
// @ts-expect-error
newData[updatePath[0]] = value
return newData
}
const [head, ...tail] = updatePath
// @ts-expect-error
newData[head] = updateNestedDataByPath(newData[head], tail, value)
return newData
}
return oldData
}
/**
* Deletes nested data by path
*
* @param {unknown} oldData Data to be updated
* @param {Array<string>} deletePath Path to the data to be deleted
* @returns newData without the deleted items by path
*/
export const deleteNestedDataByPath = (
oldData: unknown,
deletePath: Array<string>,
): any => {
if (oldData instanceof Map) {
const newData = new Map(oldData)
if (deletePath.length === 1) {
newData.delete(deletePath[0])
return newData
}
const [head, ...tail] = deletePath
newData.set(head, deleteNestedDataByPath(newData.get(head), tail))
return newData
}
if (oldData instanceof Set) {
const setAsArray = deleteNestedDataByPath(Array.from(oldData), deletePath)
return new Set(setAsArray)
}
if (Array.isArray(oldData)) {
const newData = [...oldData]
if (deletePath.length === 1) {
return newData.filter((_, idx) => idx.toString() !== deletePath[0])
}
const [head, ...tail] = deletePath
// @ts-expect-error
newData[head] = deleteNestedDataByPath(newData[head], tail)
return newData
}
if (oldData instanceof Object) {
const newData = { ...oldData }
if (deletePath.length === 1) {
// @ts-expect-error
delete newData[deletePath[0]]
return newData
}
const [head, ...tail] = deletePath
// @ts-expect-error
newData[head] = deleteNestedDataByPath(newData[head], tail)
return newData
}
return oldData
}
// Sets up the goober stylesheet
// Adds a nonce to the style tag if needed
export const setupStyleSheet = (nonce?: string, target?: ShadowRoot) => {
if (!nonce)
return // Goober reads window.__nonce__ every time it creates or accesses its style
// element (el.nonce = window.__nonce__). Without this, goober overwrites the
// nonce we set on the pre-created element with undefined, clearing it.
;(window as any).__nonce__ = nonce
const root = target ?? document.head
if (root.querySelector('#_goober')) return
const styleTag = document.createElement('style')
const textNode = document.createTextNode('')
styleTag.appendChild(textNode)
styleTag.id = '_goober'
styleTag.setAttribute('nonce', nonce)
root.appendChild(styleTag)
}

View File

@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@solidjs/testing-library'
import { afterEach } from 'vitest'
afterEach(() => cleanup())

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist-ts",
"rootDir": ".",
"jsx": "preserve",
"jsxImportSource": "solid-js"
},
"include": [
"src",
"test-setup.ts",
"*.config.ts",
"*.config.js",
"package.json"
]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false,
"composite": false,
"rootDir": "../../"
},
"include": ["src"],
"exclude": ["src/__tests__"]
}

View File

@@ -0,0 +1,84 @@
import { createRequire } from 'node:module'
import { defineConfig } from 'tsdown'
import solid from 'unplugin-solid/rolldown'
const require = createRequire(import.meta.url)
const solidBrowserIds: Record<string, string> = {
'solid-js/web': require.resolve('solid-js/web/dist/web.js'),
'solid-js/store': require.resolve('solid-js/store/dist/store.js'),
'solid-js/html': require.resolve('solid-js/html/dist/html.js'),
'solid-js/h': require.resolve('solid-js/h/dist/h.js'),
'solid-js': require.resolve('solid-js/dist/solid.js'),
}
const solidBrowserImports = () => ({
name: 'solid-browser-imports',
resolveId(id: string, importer?: string) {
// Resolve the browser runtime explicitly. This keeps Solid bundled in
// query-devtools, as it was with tsup-preset-solid, while keeping d.ts
// imports unchanged.
if (importer?.includes('.d.')) return undefined
const browserId = solidBrowserIds[id]
if (browserId) {
return { id: browserId, external: false }
}
return undefined
},
})
const baseConfig = {
target: 'esnext',
platform: 'neutral' as const,
alias: {
'solid-transition-group': 'solid-transition-group/dist/index.js',
},
format: ['esm', 'cjs'] as ['esm', 'cjs'],
outDir: 'build',
fixedExtension: false,
// Published source needs these dependencies declared, but the compiled
// devtools must still bundle them with the Solid browser runtime.
deps: {
alwaysBundle: [/./],
dts: { alwaysBundle: [] },
},
inputOptions: {
// Keep package imports in declaration output. Runtime Solid imports are
// bundled from the browser files by solidBrowserImports above.
external: (id: string, importer?: string) =>
(id.startsWith('solid-js') && importer?.includes('.d.')) ||
(id === '@tanstack/query-core' && importer?.includes('.d.')),
},
}
const environment = (development: boolean) => ({
NODE_ENV: development ? 'development' : 'production',
PROD: !development,
DEV: development,
SSR: false,
})
export default defineConfig([
{
...baseConfig,
entry: { index: 'src/index.ts' },
clean: true,
dts: true,
env: environment(false),
minify: {
compress: {
dropConsole: true,
dropDebugger: true,
},
},
plugins: [solid(), solidBrowserImports()],
},
{
...baseConfig,
entry: { dev: 'src/index.ts' },
clean: false,
dts: false,
env: environment(true),
plugins: [solid(), solidBrowserImports()],
},
])

View File

@@ -0,0 +1,34 @@
import solid from 'vite-plugin-solid'
import { defineConfig } from 'vitest/config'
import packageJson from './package.json'
export default defineConfig({
plugins: [solid()],
// fix from https://github.com/vitest-dev/vitest/issues/6992#issuecomment-2509408660
resolve: {
conditions: ['@tanstack/custom-condition'],
},
environments: {
ssr: {
resolve: {
conditions: ['@tanstack/custom-condition'],
},
},
},
test: {
name: packageJson.name,
dir: './src',
watch: false,
environment: 'jsdom',
coverage: {
enabled: !!process.env.CI,
provider: 'istanbul',
include: ['src/**/*'],
exclude: ['src/__tests__/**'],
},
typecheck: { enabled: true },
restoreMocks: true,
setupFiles: ['test-setup.ts'],
},
})