diff --git a/docs/config.json b/docs/config.json index 89eb47fd2a..3e77bad407 100644 --- a/docs/config.json +++ b/docs/config.json @@ -929,6 +929,10 @@ { "label": "Shadow DOM", "to": "framework/react/examples/shadow-dom" + }, + { + "label": "Devtools Embedded Panel", + "to": "framework/react/examples/devtools-panel" } ] }, diff --git a/docs/framework/react/devtools.md b/docs/framework/react/devtools.md index e9331622a3..2725130b96 100644 --- a/docs/framework/react/devtools.md +++ b/docs/framework/react/devtools.md @@ -83,7 +83,50 @@ function App() { - The position of the React Query devtools panel - `client?: QueryClient`, - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. -- `errorTypes?: { name: string; initializer: (query: Query) => TError}` +- `errorTypes?: { name: string; initializer: (query: Query) => TError}[]` + - Use this to predefine some errors that can be triggered on your queries. Initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. +- `styleNonce?: string` + - Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. +- `shadowDOMTarget?: ShadowRoot` + - Default behavior will apply the devtool's styles to the head tag within the DOM. + - Use this to pass a shadow DOM target to the devtools so that the styles will be applied within the shadow DOM instead of within the head tag in the light DOM. + +## Embedded Mode + +Embedded mode will show the development tools as a fixed element in your application, so you can use our panel in your own development tools. + +Place the following code as high in your React app as you can. The closer it is to the root of the page, the better it will work! + +```tsx +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' + +function App() { + const [isOpen, setIsOpen] = React.useState(false) + + return ( + + {/* The rest of your application */} + + {isOpen && setIsOpen(false)} />} + + ) +} +``` + +### Options + +- `style?: React.CSSProperties` + - Custom styles for the devtools panel + - Default: `{ height: '500px' }` + - Example: `{ height: '100%' }` + - Example: `{ height: '100%', width: '100%' }` +- `onClose?: () => unknown` + - Callback function that is called when the devtools panel is closed +- `client?: QueryClient`, + - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. +- `errorTypes?: { name: string; initializer: (query: Query) => TError}[]` - Use this to predefine some errors that can be triggered on your queries. Initializer will be called (with the specific query) when that error is toggled on from the UI. It must return an Error. - `styleNonce?: string` - Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. diff --git a/examples/react/devtools-panel/.eslintrc b/examples/react/devtools-panel/.eslintrc new file mode 100644 index 0000000000..4e03b9e10b --- /dev/null +++ b/examples/react/devtools-panel/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["plugin:react/jsx-runtime", "plugin:react-hooks/recommended"] +} diff --git a/examples/react/devtools-panel/.gitignore b/examples/react/devtools-panel/.gitignore new file mode 100644 index 0000000000..4673b022e5 --- /dev/null +++ b/examples/react/devtools-panel/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +pnpm-lock.yaml +yarn.lock +package-lock.json + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/react/devtools-panel/README.md b/examples/react/devtools-panel/README.md new file mode 100644 index 0000000000..1cf8892652 --- /dev/null +++ b/examples/react/devtools-panel/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/react/devtools-panel/index.html b/examples/react/devtools-panel/index.html new file mode 100644 index 0000000000..204cab6a43 --- /dev/null +++ b/examples/react/devtools-panel/index.html @@ -0,0 +1,16 @@ + + + + + + + + + TanStack Query React Devtools Panel Example App + + + +
+ + + diff --git a/examples/react/devtools-panel/package.json b/examples/react/devtools-panel/package.json new file mode 100644 index 0000000000..f6614ffe28 --- /dev/null +++ b/examples/react/devtools-panel/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/query-example-react-devtools-panel", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.53.2", + "@tanstack/react-query-devtools": "^5.53.2", + "react": "19.0.0-rc-4c2e457c7c-20240522", + "react-dom": "19.0.0-rc-4c2e457c7c-20240522" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "typescript": "5.3.3", + "vite": "^5.3.5" + } +} diff --git a/examples/react/devtools-panel/public/emblem-light.svg b/examples/react/devtools-panel/public/emblem-light.svg new file mode 100644 index 0000000000..a58e69ad5e --- /dev/null +++ b/examples/react/devtools-panel/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/examples/react/devtools-panel/src/index.tsx b/examples/react/devtools-panel/src/index.tsx new file mode 100644 index 0000000000..7278a000e3 --- /dev/null +++ b/examples/react/devtools-panel/src/index.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query' +import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' + +const queryClient = new QueryClient() + +export default function App() { + const [isOpen, setIsOpen] = React.useState(false) + + return ( + + + + {isOpen && setIsOpen(false)} />} + + ) +} + +function Example() { + const { isPending, error, data, isFetching } = useQuery({ + queryKey: ['repoData'], + queryFn: async () => { + const response = await fetch( + 'https://api.github.com/repos/TanStack/query', + ) + return await response.json() + }, + }) + + if (isPending) return 'Loading...' + + if (error) return 'An error has occurred: ' + error.message + + return ( +
+

{data.full_name}

+

{data.description}

+ 👀 {data.subscribers_count}{' '} + ✨ {data.stargazers_count}{' '} + 🍴 {data.forks_count} +
{isFetching ? 'Updating...' : ''}
+
+ ) +} + +const rootElement = document.getElementById('root') as HTMLElement +ReactDOM.createRoot(rootElement).render() diff --git a/examples/react/devtools-panel/tsconfig.json b/examples/react/devtools-panel/tsconfig.json new file mode 100644 index 0000000000..23a8707ef4 --- /dev/null +++ b/examples/react/devtools-panel/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "eslint.config.js"] +} diff --git a/examples/react/devtools-panel/vite.config.ts b/examples/react/devtools-panel/vite.config.ts new file mode 100644 index 0000000000..9ffcc67574 --- /dev/null +++ b/examples/react/devtools-panel/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index b04e4f3d25..73802c0adb 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -2,30 +2,26 @@ import { For, Show, batch, - createContext, createEffect, createMemo, createSignal, on, onCleanup, onMount, - useContext, } from 'solid-js' import { rankItem } from '@tanstack/match-sorter-utils' import * as goober from 'goober' import { clsx as cx } from 'clsx' import { TransitionGroup } from 'solid-transition-group' import { Key } from '@solid-primitives/keyed' -import { createLocalStorage } from '@solid-primitives/storage' import { createResizeObserver } from '@solid-primitives/resize-observer' import { DropdownMenu, RadioGroup } from '@kobalte/core' -import { Portal, clearDelegatedEvents, delegateEvents } from 'solid-js/web' +import { Portal } from 'solid-js/web' import { tokens } from './theme' import { convertRemToPixels, displayValue, getMutationStatusColor, - getPreferredColorScheme, getQueryStatusColor, getQueryStatusColorByLabel, getQueryStatusLabel, @@ -55,18 +51,25 @@ import { XCircle, } from './icons' import Explorer from './Explorer' +import { usePiPWindow, useQueryDevtoolsContext, useTheme } from './contexts' import { - QueryDevtoolsContext, - ThemeContext, - useQueryDevtoolsContext, - useTheme, -} from './Context' + BUTTON_POSITION, + DEFAULT_HEIGHT, + DEFAULT_MUTATION_SORT_FN_NAME, + DEFAULT_SORT_FN_NAME, + DEFAULT_SORT_ORDER, + DEFAULT_WIDTH, + INITIAL_IS_OPEN, + POSITION, + firstBreakpoint, + secondBreakpoint, + thirdBreakpoint, +} from './constants' import type { - DevtoolsButtonPosition, DevtoolsErrorType, DevtoolsPosition, QueryDevtoolsProps, -} from './Context' +} from './contexts' import type { Mutation, MutationCache, @@ -83,26 +86,19 @@ interface DevtoolsPanelProps { setLocalStore: StorageSetter } +interface ContentViewProps { + localStore: StorageObject + setLocalStore: StorageSetter + showPanelViewOnly?: boolean + onClose?: () => unknown +} + interface QueryStatusProps { label: string color: 'green' | 'yellow' | 'gray' | 'blue' | 'purple' | 'red' count: number } -const firstBreakpoint = 1024 -const secondBreakpoint = 796 -const thirdBreakpoint = 700 - -const BUTTON_POSITION: DevtoolsButtonPosition = 'bottom-right' -const POSITION: DevtoolsPosition = 'bottom' -const THEME_PREFERENCE = 'system' -const INITIAL_IS_OPEN = false -const DEFAULT_HEIGHT = 500 -const DEFAULT_WIDTH = 500 -const DEFAULT_SORT_FN_NAME = Object.keys(sortFns)[0] -const DEFAULT_SORT_ORDER = 1 -const DEFAULT_MUTATION_SORT_FN_NAME = Object.keys(mutationSortFns)[0] - const [selectedQueryHash, setSelectedQueryHash] = createSignal( null, ) @@ -116,211 +112,7 @@ export type DevtoolsComponentType = Component & { shadowDOMTarget?: ShadowRoot } -interface PiPProviderProps { - children: JSX.Element - localStore: StorageObject - setLocalStore: StorageSetter -} - -type PiPContextType = { - pipWindow: Window | null - requestPipWindow: (width: number, height: number) => Promise - closePipWindow: () => void -} - -const PiPContext = createContext | undefined>( - undefined, -) - -const PiPProvider = (props: PiPProviderProps) => { - // Expose pipWindow that is currently active - const [pipWindow, setPipWindow] = createSignal(null) - - // Close pipWindow programmatically - const closePipWindow = () => { - const w = pipWindow() - if (w != null) { - w.close() - setPipWindow(null) - } - } - - // Open new pipWindow - const requestPipWindow = async (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 Error( - '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') { - requestPipWindow( - Number(window.innerWidth), - Number(props.localStore.height || DEFAULT_HEIGHT), - ) - } - }) - - 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, - })) - - return ( - {props.children} - ) -} - -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 -} - -const DevtoolsComponent: DevtoolsComponentType = (props) => { - const [localStore, setLocalStore] = createLocalStorage({ - prefix: 'TanstackQueryDevtools', - }) - - const colorScheme = getPreferredColorScheme() - - const theme = createMemo(() => { - const preference = (localStore.theme_preference || THEME_PREFERENCE) as - | 'system' - | 'dark' - | 'light' - if (preference !== 'system') return preference - return colorScheme() - }) - - return ( - - - - - - - - ) -} - -export default DevtoolsComponent - -const Devtools: Component = (props) => { +export const Devtools: Component = (props) => { const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -393,10 +185,7 @@ const Devtools: Component = (props) => { - + @@ -444,7 +233,7 @@ const Devtools: Component = (props) => { > - @@ -547,7 +336,66 @@ const PiPPanel: Component<{ ) } -const DevtoolsPanel: Component = (props) => { +export const ParentPanel: Component<{ + children: JSX.Element +}> = (props) => { + 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) + }) + + let panelRef!: HTMLDivElement + + onMount(() => { + createResizeObserver(panelRef, ({ width }, el) => { + if (el === panelRef) { + setPanelWidth(width) + } + }) + }) + + const getPanelDynamicStyles = () => { + const { colors } = tokens + const t = (light: string, dark: string) => + theme() === 'dark' ? dark : light + if (panelWidth() < secondBreakpoint) { + return css` + flex-direction: column; + background-color: ${t(colors.gray[300], colors.gray[600])}; + ` + } + return css` + flex-direction: row; + background-color: ${t(colors.gray[200], colors.darkGray[900])}; + ` + } + + return ( +
+ {props.children} +
+ ) +} + +const DraggablePanel: Component = (props) => { const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -740,18 +588,14 @@ const DevtoolsPanel: Component = (props) => { > - + ) } -const ContentView: Component = (props) => { +export const ContentView: Component = (props) => { setupQueryCacheSubscription() setupMutationCacheSubscription() - let containerRef!: HTMLDivElement const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget @@ -900,8 +744,12 @@ const ContentView: Component = (props) => { - +