diff --git a/.changeset/tanstack-query-plugin-reload-sync.md b/.changeset/tanstack-query-plugin-reload-sync.md new file mode 100644 index 00000000..72272c0b --- /dev/null +++ b/.changeset/tanstack-query-plugin-reload-sync.md @@ -0,0 +1,8 @@ +--- +'@rozenite/tanstack-query-plugin': patch +--- + +Fix the TanStack Query panel staying empty after an app reload. The device now +announces itself once it is listening, so the panel pulls the cache again +instead of waiting forever on a request that was sent before the app finished +mounting. diff --git a/packages/tanstack-query-plugin/src/react-native/useHandleInitialData.ts b/packages/tanstack-query-plugin/src/react-native/useHandleInitialData.ts index 4b34ceec..925bff77 100644 --- a/packages/tanstack-query-plugin/src/react-native/useHandleInitialData.ts +++ b/packages/tanstack-query-plugin/src/react-native/useHandleInitialData.ts @@ -17,6 +17,13 @@ export const useHandleInitialData = ( client.send('sync-data', { data: dehydratedState }); }); + // This hook runs last in `useTanStackQueryDevTools`, so by the time the + // panel reacts to this every handler is listening. The panel is recreated on + // every app reload and asks for the cache as soon as it boots, which usually + // beats the app's React tree to the punch; without this its only request is + // dropped and the panel stays empty until the app is restarted. + client.send('device-ready', {}); + return () => { subscription.remove(); }; diff --git a/packages/tanstack-query-plugin/src/shared/messaging.ts b/packages/tanstack-query-plugin/src/shared/messaging.ts index a00b4ffa..dd5efb01 100644 --- a/packages/tanstack-query-plugin/src/shared/messaging.ts +++ b/packages/tanstack-query-plugin/src/shared/messaging.ts @@ -19,6 +19,13 @@ export type TanStackQueryPluginEventMap = { data?: unknown; }; }; + /** + * Announced by the device once it is listening for panel messages. The panel + * is recreated on every app reload and asks for the cache immediately, which + * can land before the app's React tree has mounted the plugin; this lets the + * panel ask again instead of staying empty until the app is restarted. + */ + 'device-ready': unknown; 'request-initial-data': unknown; 'sync-data': { data: SerializableQueryClient; diff --git a/packages/tanstack-query-plugin/src/ui/useSyncInitialData.test.tsx b/packages/tanstack-query-plugin/src/ui/useSyncInitialData.test.tsx new file mode 100644 index 00000000..01aeca9d --- /dev/null +++ b/packages/tanstack-query-plugin/src/ui/useSyncInitialData.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TanStackQueryPluginClient } from '../shared/messaging'; +import { useSyncInitialData } from './useSyncInitialData'; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +type Listener = (payload: unknown) => void; + +const createClient = () => { + const listeners = new Map>(); + + return { + send: vi.fn(), + onMessage: vi.fn((type: string, listener: Listener) => { + const typeListeners = listeners.get(type) ?? new Set(); + typeListeners.add(listener); + listeners.set(type, typeListeners); + return { remove: () => typeListeners.delete(listener) }; + }), + close: vi.fn(), + emit: (type: string, payload: unknown) => + listeners.get(type)?.forEach((listener) => listener(payload)), + }; +}; + +const requests = (client: ReturnType) => + client.send.mock.calls.filter(([type]) => type === 'request-initial-data'); + +describe('useSyncInitialData', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + }); + afterEach(() => { + document.body.innerHTML = ''; + delete globalThis.IS_REACT_ACT_ENVIRONMENT; + }); + + // The panel is recreated on every app reload and asks for the cache before the + // app's React tree has mounted the plugin, so its first request is dropped. + it('asks for the cache again when the device announces itself', async () => { + const client = createClient(); + const queryClient = new QueryClient(); + queryClient.setQueryData(['stale-from-previous-context'], 'value'); + + const Harness = ({ children }: { children?: ReactNode }) => { + useSyncInitialData(client as unknown as TanStackQueryPluginClient); + return <>{children}; + }; + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + await act(async () => + root.render( + + + , + ), + ); + + expect(requests(client)).toHaveLength(1); + + await act(async () => client.emit('device-ready', {})); + + expect(requests(client)).toHaveLength(2); + expect(queryClient.getQueryData(['stale-from-previous-context'])).toBeUndefined(); + + await act(async () => root.unmount()); + }); +}); diff --git a/packages/tanstack-query-plugin/src/ui/useSyncInitialData.ts b/packages/tanstack-query-plugin/src/ui/useSyncInitialData.ts index ea8f5f51..dc4f0972 100644 --- a/packages/tanstack-query-plugin/src/ui/useSyncInitialData.ts +++ b/packages/tanstack-query-plugin/src/ui/useSyncInitialData.ts @@ -1,12 +1,27 @@ import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { TanStackQueryPluginClient } from '../shared/messaging'; export const useSyncInitialData = (client: TanStackQueryPluginClient | null) => { + const queryClient = useQueryClient(); + useEffect(() => { if (!client) { return; } + // The device announces itself once it is listening. It reconnects on every + // app reload, so the cache has to be pulled again — what the panel holds + // belongs to the previous JS context. + const subscription = client.onMessage('device-ready', () => { + queryClient.clear(); + client.send('request-initial-data', {}); + }); + client.send('request-initial-data', {}); - }, [client]); + + return () => { + subscription.remove(); + }; + }, [client, queryClient]); };