diff --git a/examples/react/basic/src/index.tsx b/examples/react/basic/src/index.tsx index 14679c24..a51cdbfe 100644 --- a/examples/react/basic/src/index.tsx +++ b/examples/react/basic/src/index.tsx @@ -1,24 +1,155 @@ import { createRoot } from 'react-dom/client' +import { + QueryClient, + QueryClientProvider, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { useState } from 'react' import Devtools from './setup' -import { queryPlugin } from './plugin' -setTimeout(() => { - queryPlugin.emit('test', { - title: 'Test Event', - description: - 'This is a test event from the TanStack Query Devtools plugin.', +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24, // 24 hours + }, + }, +}) + +type Post = { + id: number + title: string + body: string +} + +function Posts({ + setPostId, +}: { + setPostId: React.Dispatch> +}) { + const queryClient = useQueryClient() + const { status, data, error, isFetching } = usePosts() + + return ( +
+

Posts

+
+ {status === 'pending' ? ( + 'Loading...' + ) : status === 'error' ? ( + Error: {error.message} + ) : ( + <> +
+ {data.map((post) => ( +

+ setPostId(post.id)} + href="#" + style={ + // We can access the query data here to show bold links for + // ones that are cached + queryClient.getQueryData(['post', post.id]) + ? { + fontWeight: 'bold', + color: 'green', + } + : {} + } + > + {post.title} + +

+ ))} +
+
{isFetching ? 'Background Updating...' : ' '}
+ + )} +
+
+ ) +} + +const getPostById = async (id: number): Promise => { + const response = await fetch( + `https://jsonplaceholder.typicode.com/posts/${id}`, + ) + return await response.json() +} + +function usePost(postId: number) { + return useQuery({ + queryKey: ['post', postId], + queryFn: () => getPostById(postId), + enabled: !!postId, }) -}, 1000) +} -queryPlugin.on('test', (event) => { - console.log('Received test event:', event) -}) +function Post({ + postId, + setPostId, +}: { + postId: number + setPostId: React.Dispatch> +}) { + const { status, data, error, isFetching } = usePost(postId) + return ( +
+
+ setPostId(-1)} href="#"> + Back + +
+ {!postId || status === 'pending' ? ( + 'Loading...' + ) : status === 'error' ? ( + Error: {error.message} + ) : ( + <> +

{data.title}

+
+

{data.body}

+
+
{isFetching ? 'Background Updating...' : ' '}
+ + )} +
+ ) +} +function usePosts() { + return useQuery({ + queryKey: ['posts'], + queryFn: async (): Promise> => { + const response = await fetch('https://jsonplaceholder.typicode.com/posts') + return await response.json() + }, + }) +} function App() { + const [postId, setPostId] = useState(-1) + return (
+ +

+ As you visit the posts below, you will notice them in a loading state + the first time you load them. However, after you return to this list + and click on any posts you have already visited again, you will see + them load instantly and background refresh right before your eyes!{' '} + + (You may need to throttle your network speed to simulate longer + loading sequences) + +

+ {postId > -1 ? ( + + ) : ( + + )} + +

TanStack Devtools React Basic Example

-
) } diff --git a/examples/react/basic/src/setup.tsx b/examples/react/basic/src/setup.tsx index 91909a22..db41604a 100644 --- a/examples/react/basic/src/setup.tsx +++ b/examples/react/basic/src/setup.tsx @@ -1,4 +1,3 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { @@ -57,26 +56,22 @@ const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) const router = createRouter({ routeTree }) -const queryClient = new QueryClient() - export default function DevtoolsExample() { return ( <> - - , - }, - { - name: 'TanStack Router', - render: , - }, - ]} - /> - - + , + }, + { + name: 'Tanstack Router', + render: , + }, + ]} + /> + ) } diff --git a/packages/devtools/src/components/content-panel.tsx b/packages/devtools/src/components/content-panel.tsx index 24a007b7..e4014418 100644 --- a/packages/devtools/src/components/content-panel.tsx +++ b/packages/devtools/src/components/content-panel.tsx @@ -1,4 +1,8 @@ -import { useDevtoolsSettings } from '../context/use-devtools-context' +import { Show } from 'solid-js' +import { + useDetachedWindowControls, + useDevtoolsSettings, +} from '../context/use-devtools-context' import { useStyles } from '../styles/use-styles' import type { JSX } from 'solid-js/jsx-runtime' @@ -9,14 +13,15 @@ export const ContentPanel = (props: { }) => { const styles = useStyles() const { settings } = useDevtoolsSettings() + const { isDetached } = useDetachedWindowControls() return (
- {props.handleDragStart ? ( +
- ) : null} +
{props.children}
) diff --git a/packages/devtools/src/components/main-panel.tsx b/packages/devtools/src/components/main-panel.tsx index 38c1bcbc..bd705722 100644 --- a/packages/devtools/src/components/main-panel.tsx +++ b/packages/devtools/src/components/main-panel.tsx @@ -1,5 +1,9 @@ import clsx from 'clsx' -import { useDevtoolsSettings, useHeight } from '../context/use-devtools-context' +import { + useDetachedWindowControls, + useDevtoolsSettings, + useHeight, +} from '../context/use-devtools-context' import { useStyles } from '../styles/use-styles' import { TANSTACK_DEVTOOLS } from '../utils/storage' import type { Accessor, JSX } from 'solid-js' @@ -12,14 +16,15 @@ export const MainPanel = (props: { const styles = useStyles() const { height } = useHeight() const { settings } = useDevtoolsSettings() + const { isDetached } = useDetachedWindowControls() return (
void @@ -11,7 +20,22 @@ interface TabsProps { export const Tabs = (props: TabsProps) => { const styles = useStyles() const { state, setState } = useDevtoolsState() + const { setDetachedWindowOwner, detachedWindowOwner, detachedWindow } = + useDetachedWindowControls() + const handleDetachment = () => { + const detachedWindow = window.open( + window.location.href, + '', + `popup,width=${window.innerWidth},height=${state().height},top=${window.screen.height},left=${window.screenLeft}}`, + ) + if (detachedWindow) { + setDetachedWindowOwner(true) + setStorageItem(TANSTACK_DEVTOOLS_IS_DETACHED, 'true') + setSessionItem(TANSTACK_DEVTOOLS_DETACHED_OWNER, 'true') + detachedWindow.TDT_MOUNTED = true + } + } return (
@@ -25,26 +49,60 @@ export const Tabs = (props: TabsProps) => { )} - + {!detachedWindowOwner() && ( + + )} + +
+ )}
) } diff --git a/packages/devtools/src/context/devtools-context.tsx b/packages/devtools/src/context/devtools-context.tsx index cb808457..28748fd8 100644 --- a/packages/devtools/src/context/devtools-context.tsx +++ b/packages/devtools/src/context/devtools-context.tsx @@ -2,11 +2,20 @@ import { createContext } from 'solid-js' import { createStore } from 'solid-js/store' import { tryParseJson } from '../utils/sanitize' import { + TANSTACK_DEVTOOLS_CHECK_DETACHED, + TANSTACK_DEVTOOLS_DETACHED, TANSTACK_DEVTOOLS_SETTINGS, TANSTACK_DEVTOOLS_STATE, getStorageItem, + setSessionItem, setStorageItem, } from '../utils/storage' +import { + checkIsDetached, + checkIsDetachedOwner, + checkIsDetachedWindow, +} from '../utils/detached' +import { useRemoveBody } from '../hooks/detached/use-remove-body' import { initialState } from './devtools-store' import type { DevtoolsStore } from './devtools-store' import type { JSX, Setter } from 'solid-js' @@ -91,13 +100,40 @@ const generatePluginId = (plugin: TanStackDevtoolsPlugin, index: number) => { return index.toString() } -const getExistingStateFromStorage = ( +const setIsDetachedIfRequired = () => { + const isDetachedWindow = checkIsDetachedWindow() + if (!isDetachedWindow && window.TDT_MOUNTED) { + setSessionItem(TANSTACK_DEVTOOLS_DETACHED, 'true') + } +} + +const resetIsDetachedCheck = () => { + setStorageItem(TANSTACK_DEVTOOLS_CHECK_DETACHED, 'false') +} + +const detachedModeSetup = () => { + resetIsDetachedCheck() + setIsDetachedIfRequired() + const isDetachedWindow = checkIsDetachedWindow() + const isDetached = checkIsDetached() + const isDetachedOwner = checkIsDetachedOwner() + + if (isDetachedWindow && !isDetached) { + window.close() + } + + return { + detachedWindow: window.TDT_MOUNTED ?? isDetachedWindow, + detachedWindowOwner: isDetachedOwner, + } +} +export const getExistingStateFromStorage = ( config?: TanStackDevtoolsConfig, plugins?: Array, ) => { const existingState = getStorageItem(TANSTACK_DEVTOOLS_STATE) const settings = getSettings() - + const { detachedWindow, detachedWindowOwner } = detachedModeSetup() const state: DevtoolsStore = { ...initialState, plugins: @@ -112,6 +148,8 @@ const getExistingStateFromStorage = ( ...initialState.state, ...(existingState ? JSON.parse(existingState) : {}), }, + detachedWindow, + detachedWindowOwner, settings: { ...initialState.settings, ...config, @@ -128,6 +166,8 @@ export const DevtoolsProvider = (props: ContextProps) => { getExistingStateFromStorage(props.config, props.plugins), ) + useRemoveBody(store) + const value = { store, setStore: ( diff --git a/packages/devtools/src/context/devtools-store.ts b/packages/devtools/src/context/devtools-store.ts index 26e05e15..ee2a5bcd 100644 --- a/packages/devtools/src/context/devtools-store.ts +++ b/packages/devtools/src/context/devtools-store.ts @@ -57,6 +57,8 @@ export type DevtoolsStore = { activePlugin?: string | undefined persistOpen: boolean } + detachedWindowOwner?: boolean + detachedWindow?: boolean plugins?: Array } diff --git a/packages/devtools/src/context/use-devtools-context.ts b/packages/devtools/src/context/use-devtools-context.ts index cdf256f6..dfd5cd77 100644 --- a/packages/devtools/src/context/use-devtools-context.ts +++ b/packages/devtools/src/context/use-devtools-context.ts @@ -7,7 +7,7 @@ import type { DevtoolsStore } from './devtools-store.js' * Returns an object containing the current state and setState function of the ShellContext. * Throws an error if used outside of a ShellContextProvider. */ -const useDevtoolsContext = () => { +export const useDevtoolsContext = () => { const context = useContext(DevtoolsContext) if (context === undefined) { throw new Error( @@ -92,3 +92,29 @@ export const useHeight = () => { return { height, setHeight } } + +declare global { + interface Window { + TDT_MOUNTED: boolean | undefined + } +} + +export const useDetachedWindowControls = () => { + const { store, setStore } = useDevtoolsContext() + const detachedWindowOwner = createMemo(() => store.detachedWindowOwner) + const detachedWindow = createMemo(() => store.detachedWindow) + const mounted = createMemo(() => Boolean(window.TDT_MOUNTED)) + const setDetachedWindowOwner = (isDetachedWindowOwner: boolean) => { + setStore((prev) => ({ + ...prev, + detachedWindowOwner: isDetachedWindowOwner, + })) + } + + return { + detachedWindow: detachedWindow() || mounted(), + detachedWindowOwner, + setDetachedWindowOwner, + isDetached: Boolean(detachedWindow() || detachedWindowOwner()), + } +} diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 458cb161..06cc85ae 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -1,26 +1,40 @@ -import { Show, createEffect, createSignal } from 'solid-js' +import { Show, createEffect, createSignal, onCleanup } from 'solid-js' import { createShortcut } from '@solid-primitives/keyboard' import { + useDetachedWindowControls, useDevtoolsSettings, useHeight, usePersistOpen, } from './context/use-devtools-context' import { useDisableTabbing } from './hooks/use-disable-tabbing' -import { TANSTACK_DEVTOOLS } from './utils/storage' +import { + TANSTACK_DEVTOOLS, + TANSTACK_DEVTOOLS_DETACHED_OWNER, + TANSTACK_DEVTOOLS_IS_DETACHED, + setSessionItem, + setStorageItem, +} from './utils/storage' import { Trigger } from './components/trigger' import { MainPanel } from './components/main-panel' import { ContentPanel } from './components/content-panel' import { Tabs } from './components/tabs' import { TabContent } from './components/tab-content' +import { useResetDetachmentCheck } from './hooks/detached/use-reset-detachment-check' +import { useSyncStateWhenDetached } from './hooks/detached/use-sync-state-when-detached' +import { useWindowListener } from './hooks/use-event-listener' +import { useCheckIfStillDetached } from './hooks/detached/use-check-if-still-detached' export default function DevTools() { + const { detachedWindowOwner, isDetached, setDetachedWindowOwner } = + useDetachedWindowControls() const { settings } = useDevtoolsSettings() const { setHeight } = useHeight() const { persistOpen, setPersistOpen } = usePersistOpen() const [rootEl, setRootEl] = createSignal() const [isOpen, setIsOpen] = createSignal( - settings().defaultOpen || persistOpen(), + isDetached || settings().defaultOpen || persistOpen(), ) + let panelRef: HTMLDivElement | undefined = undefined const [isResizing, setIsResizing] = createSignal(false) const toggleOpen = () => { @@ -28,7 +42,10 @@ export default function DevTools() { setIsOpen(!open) setPersistOpen(!open) } - createEffect(() => {}) + + useSyncStateWhenDetached() + useResetDetachmentCheck() + useCheckIfStillDetached() // Used to resize the panel const handleDragStart = ( panelElement: HTMLDivElement | undefined, @@ -116,10 +133,14 @@ export default function DevTools() { return }) createEffect(() => { - window.addEventListener('keydown', (e) => { + const event = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen()) { toggleOpen() } + } + window.addEventListener('keydown', event) + onCleanup(() => { + window.removeEventListener('keydown', event) }) }) useDisableTabbing(isOpen) @@ -136,14 +157,32 @@ export default function DevTools() { }) }) - createEffect(() => {}) + createEffect(() => { + if (isDetached) { + useWindowListener('resize', () => { + setHeight(window.innerHeight) + }) + } + }) + return ( -
+
+ + false} + setIsOpen={() => { + setDetachedWindowOwner(false) + setStorageItem(TANSTACK_DEVTOOLS_IS_DETACHED, 'false') + setSessionItem(TANSTACK_DEVTOOLS_DETACHED_OWNER, 'false') + }} + /> + diff --git a/packages/devtools/src/hooks/detached/use-check-if-still-detached.ts b/packages/devtools/src/hooks/detached/use-check-if-still-detached.ts new file mode 100644 index 00000000..e17201ad --- /dev/null +++ b/packages/devtools/src/hooks/detached/use-check-if-still-detached.ts @@ -0,0 +1,70 @@ +import { createEffect, onCleanup } from 'solid-js' +import { + TANSTACK_DEVTOOLS_CHECK_DETACHED, + TANSTACK_DEVTOOLS_DETACHED, + TANSTACK_DEVTOOLS_DETACHED_OWNER, + TANSTACK_DEVTOOLS_IS_DETACHED, + getBooleanFromSession, + getBooleanFromStorage, + setStorageItem, +} from '../../utils/storage' +import { getExistingStateFromStorage } from '../../context/devtools-context.jsx' +import { useDevtoolsContext } from '../../context/use-devtools-context' + +export const useCheckIfStillDetached = () => { + const context = useDevtoolsContext() + + const checkDetachment = (e: StorageEvent) => { + const isWindowOwner = getBooleanFromSession( + TANSTACK_DEVTOOLS_DETACHED_OWNER, + ) + // close the window if the main panel closed it via trigger + if ( + e.key === TANSTACK_DEVTOOLS_IS_DETACHED && + e.newValue === 'false' && + !isWindowOwner + ) { + window.close() + } + // We only care about the should_check key + if (e.key !== TANSTACK_DEVTOOLS_CHECK_DETACHED) { + return + } + const isDetached = getBooleanFromStorage(TANSTACK_DEVTOOLS_IS_DETACHED) + + if (!isDetached) { + return + } + const shouldCheckDetached = getBooleanFromStorage( + TANSTACK_DEVTOOLS_CHECK_DETACHED, + ) + + // If the detached window is unloaded we want to check if it is still there + if (shouldCheckDetached) { + setTimeout(() => { + // On reload the detached window will set the flag back to false so we can check if it is still detached + const isNotDetachedAnymore = getBooleanFromStorage( + TANSTACK_DEVTOOLS_CHECK_DETACHED, + ) + + // The window hasn't set it back to true so it is not detached anymore and we clean all the detached state + if (isNotDetachedAnymore) { + setStorageItem(TANSTACK_DEVTOOLS_IS_DETACHED, 'false') + setStorageItem(TANSTACK_DEVTOOLS_CHECK_DETACHED, 'false') + sessionStorage.removeItem(TANSTACK_DEVTOOLS_DETACHED_OWNER) + sessionStorage.removeItem(TANSTACK_DEVTOOLS_DETACHED) + const state = getExistingStateFromStorage() + context.setStore((prev) => ({ + ...prev, + ...state, + plugins: prev.plugins, + })) + } + }, 200) + } + } + createEffect(() => { + window.addEventListener('storage', checkDetachment) + onCleanup(() => window.removeEventListener('storage', checkDetachment)) + }) +} diff --git a/packages/devtools/src/hooks/detached/use-remove-body.ts b/packages/devtools/src/hooks/detached/use-remove-body.ts new file mode 100644 index 00000000..f75acadd --- /dev/null +++ b/packages/devtools/src/hooks/detached/use-remove-body.ts @@ -0,0 +1,16 @@ +import { createEffect } from 'solid-js' +import { useStyles } from '../../styles/use-styles' +import type { DevtoolsStore } from '../../context/devtools-store' + +export const useRemoveBody = (state: DevtoolsStore) => { + const styles = useStyles() + createEffect(() => { + if (!state.detachedWindow) { + return + } + + const coverEl = document.createElement('div') + coverEl.classList.add(styles().cover) + document.body.appendChild(coverEl) + }) +} diff --git a/packages/devtools/src/hooks/detached/use-reset-detachment-check.ts b/packages/devtools/src/hooks/detached/use-reset-detachment-check.ts new file mode 100644 index 00000000..87169b04 --- /dev/null +++ b/packages/devtools/src/hooks/detached/use-reset-detachment-check.ts @@ -0,0 +1,16 @@ +import { useDetachedWindowControls } from '../../context/use-devtools-context' +import { + TANSTACK_DEVTOOLS_CHECK_DETACHED, + setStorageItem, +} from '../../utils/storage' +import { useWindowListener } from '../use-event-listener' +// called on windows unmount +export const useResetDetachmentCheck = () => { + const { isDetached } = useDetachedWindowControls() + + useWindowListener( + 'unload', + () => setStorageItem(TANSTACK_DEVTOOLS_CHECK_DETACHED, 'true'), + isDetached, + ) +} diff --git a/packages/devtools/src/hooks/detached/use-sync-state-when-detached.ts b/packages/devtools/src/hooks/detached/use-sync-state-when-detached.ts new file mode 100644 index 00000000..26596175 --- /dev/null +++ b/packages/devtools/src/hooks/detached/use-sync-state-when-detached.ts @@ -0,0 +1,51 @@ +import { getExistingStateFromStorage } from '../../context/devtools-context' +import { + useDevtoolsContext, + useDevtoolsSettings, + useDevtoolsState, +} from '../../context/use-devtools-context' +import { + TANSTACK_DEVTOOLS_SETTINGS, + TANSTACK_DEVTOOLS_STATE, +} from '../../utils/storage' +import { useWindowListener } from '../use-event-listener' + +const refreshRequiredKeys = [ + TANSTACK_DEVTOOLS_SETTINGS, + TANSTACK_DEVTOOLS_STATE, +] + +// Sync state with local storage when in detached mode +export const useSyncStateWhenDetached = () => { + const { store } = useDevtoolsContext() + const { state, setState } = useDevtoolsState() + const { setSettings, settings } = useDevtoolsSettings() + useWindowListener('storage', (e) => { + // Not in detached mode + if (!store.detachedWindow && !store.detachedWindowOwner) { + return + } + // Not caused by the dev tools + if (e.key && !refreshRequiredKeys.includes(e.key)) { + return + } + // Check if the settings have not changed and early return + if (e.key === TANSTACK_DEVTOOLS_SETTINGS) { + const oldSettings = JSON.stringify(settings()) + if (oldSettings === e.newValue) { + return + } + } + // Check if the state has not changed and early return + if (e.key === TANSTACK_DEVTOOLS_STATE) { + const oldState = JSON.stringify(state()) + if (oldState === e.newValue) { + return + } + } + // store new state + const newState = getExistingStateFromStorage() + setState(newState.state) + setSettings(newState.settings) + }) +} diff --git a/packages/devtools/src/hooks/use-event-listener.ts b/packages/devtools/src/hooks/use-event-listener.ts new file mode 100644 index 00000000..05bfe785 --- /dev/null +++ b/packages/devtools/src/hooks/use-event-listener.ts @@ -0,0 +1,50 @@ +import { createEffect, onCleanup } from 'solid-js' + +type Events = HTMLElementEventMap & + WindowEventMap & + DocumentEventMap & + MediaQueryListEventMap + +type ListenerElements = Document | HTMLElement | MediaQueryList | Window + +export const useWindowListener = ( + type: TEvent, + handler: (event: WindowEventMap[TEvent]) => void, + options?: boolean | AddEventListenerOptions, +) => + useEventListener( + typeof window !== 'undefined' ? window : undefined, + type, + handler, + options, + ) + +const useEventListener = < + TEvent extends Events[keyof Events], + TType extends keyof Pick< + Events, + { [K in keyof Events]: Events[K] extends TEvent ? K : never }[keyof Events] + >, +>( + element: ListenerElements | undefined, + type: TType, + handler: (event: Events[TType]) => void, + options?: AddEventListenerOptions | boolean, +) => { + let savedHandler = handler + + createEffect(() => { + savedHandler = handler + }) + + createEffect(() => { + if (!element) return + const listener: EventListenerOrEventListenerObject = (event) => + savedHandler(event as never) + + element.addEventListener(type, listener, options) + onCleanup(() => { + element.removeEventListener(type, listener, options) + }) + }) +} diff --git a/packages/devtools/src/styles/use-styles.ts b/packages/devtools/src/styles/use-styles.ts index d67cf4f0..86bf4f27 100644 --- a/packages/devtools/src/styles/use-styles.ts +++ b/packages/devtools/src/styles/use-styles.ts @@ -12,6 +12,7 @@ const stylesFactory = () => { return { devtoolsPanelContainer: ( panelLocation: TanStackDevtoolsConfig['panelLocation'], + isDetached: boolean, ) => css` direction: ltr; position: fixed; @@ -21,8 +22,7 @@ const stylesFactory = () => { right: 0; z-index: 99999; width: 100%; - - max-height: 90%; + ${isDetached ? '' : 'max-height: 90%;'} border-top: 1px solid ${colors.gray[700]}; transform-origin: top; `, @@ -181,7 +181,7 @@ const stylesFactory = () => { border: none; transition: all 0.2s ease-in-out; border-left: 2px solid transparent; - &:hover:not(.close):not(.active) { + &:hover:not(.close):not(.active):not(.detach) { background-color: ${colors.gray[700]}; color: ${colors.gray[100]}; border-left: 2px solid ${colors.purple[500]}; @@ -191,8 +191,15 @@ const stylesFactory = () => { color: ${colors.gray[100]}; border-left: 2px solid ${colors.purple[500]}; } + &.detach { + &:hover { + background-color: ${colors.gray[700]}; + } + &:hover { + color: ${colors.green[500]}; + } + } &.close { - margin-top: auto; &:hover { background-color: ${colors.gray[700]}; } @@ -313,6 +320,15 @@ const stylesFactory = () => { grid-template-columns: 1fr; } `, + cover: css` + position: fixed; + width: 100vw; + height: 100vh; + z-index: 9997; + background-color: ${colors.darkGray[700]}; + top: 0; + left: 0; + `, } } diff --git a/packages/devtools/src/utils/detached.ts b/packages/devtools/src/utils/detached.ts new file mode 100644 index 00000000..ed17878a --- /dev/null +++ b/packages/devtools/src/utils/detached.ts @@ -0,0 +1,14 @@ +import { + TANSTACK_DEVTOOLS_DETACHED, + TANSTACK_DEVTOOLS_DETACHED_OWNER, + TANSTACK_DEVTOOLS_IS_DETACHED, + getBooleanFromSession, + getBooleanFromStorage, +} from './storage.js' + +export const checkIsDetachedWindow = () => + getBooleanFromSession(TANSTACK_DEVTOOLS_DETACHED) +export const checkIsDetached = () => + getBooleanFromStorage(TANSTACK_DEVTOOLS_IS_DETACHED) +export const checkIsDetachedOwner = () => + getBooleanFromSession(TANSTACK_DEVTOOLS_DETACHED_OWNER) diff --git a/packages/devtools/src/utils/storage.ts b/packages/devtools/src/utils/storage.ts index aa46abfa..04898cfd 100644 --- a/packages/devtools/src/utils/storage.ts +++ b/packages/devtools/src/utils/storage.ts @@ -7,6 +7,26 @@ export const setStorageItem = (key: string, value: string) => { } } +const getSessionItem = (key: string) => sessionStorage.getItem(key) +export const getBooleanFromStorage = (key: string) => + getStorageItem(key) === 'true' +export const getBooleanFromSession = (key: string) => + getSessionItem(key) === 'true' +export const setSessionItem = (key: string, value: string) => { + try { + sessionStorage.setItem(key, value) + } catch (e) { + return + } +} + export const TANSTACK_DEVTOOLS = 'tanstack_devtools' export const TANSTACK_DEVTOOLS_STATE = 'tanstack_devtools_state' export const TANSTACK_DEVTOOLS_SETTINGS = 'tanstack_devtools_settings' + +export const TANSTACK_DEVTOOLS_DETACHED = 'tanstack_devtools_detached' +export const TANSTACK_DEVTOOLS_DETACHED_OWNER = + 'tanstack_devtools_detached_owner' +export const TANSTACK_DEVTOOLS_IS_DETACHED = 'tanstack_devtools_is_detached' +export const TANSTACK_DEVTOOLS_CHECK_DETACHED = + 'tanstack_devtools_check_detached' diff --git a/packages/event-bus-client/tests/index.test.ts b/packages/event-bus-client/tests/index.test.ts index 048ffa4a..0701b9b4 100644 --- a/packages/event-bus-client/tests/index.test.ts +++ b/packages/event-bus-client/tests/index.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it, vi } from 'vitest' import { ClientEventBus } from '@tanstack/devtools-event-bus/client' import { EventClient } from '../src' +vi.stubGlobal( + 'BroadcastChannel', + class { + postMessage = vi.fn() + addEventListener = vi.fn() + removeEventListener = vi.fn() + close = vi.fn() + }, +) // start the client bus for testing const bus = new ClientEventBus() bus.start() diff --git a/packages/event-bus/src/client/client.ts b/packages/event-bus/src/client/client.ts index 393522ce..4d5123b0 100644 --- a/packages/event-bus/src/client/client.ts +++ b/packages/event-bus/src/client/client.ts @@ -30,7 +30,7 @@ export class ClientEventBus { #eventTarget: EventTarget #debug: boolean #connectToServerBus: boolean - + #broadcastChannel: BroadcastChannel | null #dispatcher = (e: Event) => { const event = (e as CustomEvent).detail this.emitToServer(event) @@ -48,16 +48,22 @@ export class ClientEventBus { connectToServerBus = false, }: ClientEventBusConfig = {}) { this.#debug = debug + this.#broadcastChannel = new BroadcastChannel('tanstack-devtools') this.#eventSource = null this.#port = port this.#socket = null this.#connectToServerBus = connectToServerBus this.#eventTarget = this.getGlobalTarget() - + this.#broadcastChannel.onmessage = (e) => { + this.emitToClients(e.data, true) + } this.debugLog('Initializing client event bus') } - private emitToClients(event: TanStackDevtoolsEvent) { + private emitToClients( + event: TanStackDevtoolsEvent, + fromBroadcastChannel = false, + ) { this.debugLog('Emitting event from client bus', event) const specificEvent = new CustomEvent(event.type, { detail: event }) this.debugLog('Emitting event to specific client listeners', event) @@ -65,6 +71,11 @@ export class ClientEventBus { const globalEvent = new CustomEvent('tanstack-devtools-global', { detail: event, }) + // We only emit the events if they didn't come from the broadcast channel + // otherwise it would infinitely send events between + if (!fromBroadcastChannel) { + this.#broadcastChannel?.postMessage(event) + } this.debugLog('Emitting event to global client listeners', event) this.#eventTarget.dispatchEvent(globalEvent) } diff --git a/packages/event-bus/tests/index.test.ts b/packages/event-bus/tests/index.test.ts index 305c4ead..6d214c55 100644 --- a/packages/event-bus/tests/index.test.ts +++ b/packages/event-bus/tests/index.test.ts @@ -1,6 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ClientEventBus } from '../src/client' +vi.stubGlobal( + 'BroadcastChannel', + class { + postMessage = vi.fn() + addEventListener = vi.fn() + removeEventListener = vi.fn() + close = vi.fn() + }, +) describe('ClientEventBus', () => { describe('debug', () => { afterEach(() => {