From 590259980b459b61322ad41429aa1bf357c30fb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Oct 2025 13:45:07 +0000 Subject: [PATCH] feat(query-db-collection): expose query state from QueryObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds new utility methods to QueryCollectionUtils to expose TanStack Query's QueryObserver state, addressing user request for visibility into sync status: New utils exposed: - isFetching() - Check if query is currently fetching (initial or background) - isRefetching() - Check if query is refetching in background - isLoading() - Check if query is loading for first time - dataUpdatedAt() - Get timestamp of last successful data update - fetchStatus() - Get current fetch status ('fetching' | 'paused' | 'idle') This allows users to: - Show loading indicators during background refetches - Implement "Last updated X minutes ago" UI patterns - Understand sync behavior beyond just error states Resolves user request from Discord where status is always 'ready' after initial load, making it impossible to know if background refetches are happening. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/query-db-collection/src/query.ts | 48 ++++ .../query-db-collection/tests/query.test.ts | 229 ++++++++++++++++++ 2 files changed, 277 insertions(+) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 2d53105cc..9db7c3960 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -175,6 +175,33 @@ export interface QueryCollectionUtils< * @throws Error if the refetch fails */ clearError: () => Promise + /** + * Check if the query is currently fetching data (including background refetches). + * Returns true during both initial fetches and background refetches. + */ + isFetching: () => boolean + /** + * Check if the query is currently refetching data in the background. + * Returns true only during background refetches (not initial fetch). + */ + isRefetching: () => boolean + /** + * Check if the query is loading for the first time (no data yet). + * Returns true only during the initial fetch before any data is available. + */ + isLoading: () => boolean + /** + * Get the timestamp (in milliseconds since epoch) when the data was last successfully updated. + * Returns 0 if the query has never successfully fetched data. + */ + dataUpdatedAt: () => number + /** + * Get the current fetch status of the query. + * - 'fetching': Query is currently fetching + * - 'paused': Query is paused (e.g., network offline) + * - 'idle': Query is not fetching + */ + fetchStatus: () => `fetching` | `paused` | `idle` } /** @@ -421,6 +448,15 @@ export function queryCollectionOptions( /** The timestamp for when the query most recently returned the status as "error" */ let lastErrorUpdatedAt = 0 + /** Query state tracking from QueryObserver */ + const queryState = { + isFetching: false, + isRefetching: false, + isLoading: false, + dataUpdatedAt: 0, + fetchStatus: `idle` as `fetching` | `paused` | `idle`, + } + const internalSync: SyncConfig[`sync`] = (params) => { const { begin, write, commit, markReady, collection } = params @@ -456,6 +492,13 @@ export function queryCollectionOptions( type UpdateHandler = Parameters[0] const handleQueryResult: UpdateHandler = (result) => { + // Update query state from QueryObserver result + queryState.isFetching = result.isFetching + queryState.isRefetching = result.isRefetching + queryState.isLoading = result.isLoading + queryState.dataUpdatedAt = result.dataUpdatedAt + queryState.fetchStatus = result.fetchStatus + if (result.isSuccess) { // Clear error state lastError = undefined @@ -704,6 +747,11 @@ export function queryCollectionOptions( lastErrorUpdatedAt = 0 return refetch({ throwOnError: true }) }, + isFetching: () => queryState.isFetching, + isRefetching: () => queryState.isRefetching, + isLoading: () => queryState.isLoading, + dataUpdatedAt: () => queryState.dataUpdatedAt, + fetchStatus: () => queryState.fetchStatus, }, } } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index b87caf67c..e684fd4f0 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -2340,4 +2340,233 @@ describe(`QueryCollection`, () => { expect(collection.size).toBe(items.length) }) }) + + describe(`Query State Utils`, () => { + it(`should expose isFetching, isRefetching, isLoading state`, async () => { + const queryKey = [`queryStateTest`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `queryStateTest`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Initially should be loading (first fetch) + expect(collection.utils.isLoading()).toBe(true) + expect(collection.utils.isFetching()).toBe(true) + expect(collection.utils.isRefetching()).toBe(false) + + // Wait for initial fetch to complete + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + }) + + // After initial fetch, should not be loading/fetching + expect(collection.utils.isLoading()).toBe(false) + expect(collection.utils.isFetching()).toBe(false) + expect(collection.utils.isRefetching()).toBe(false) + + // Trigger a refetch + const refetchPromise = collection.utils.refetch() + + // During refetch, should be fetching and refetching, but not loading + await vi.waitFor(() => { + expect(collection.utils.isFetching()).toBe(true) + }) + expect(collection.utils.isRefetching()).toBe(true) + expect(collection.utils.isLoading()).toBe(false) + + await refetchPromise + + // After refetch completes, should not be fetching + expect(collection.utils.isFetching()).toBe(false) + expect(collection.utils.isRefetching()).toBe(false) + expect(collection.utils.isLoading()).toBe(false) + }) + + it(`should expose dataUpdatedAt timestamp`, async () => { + const queryKey = [`dataUpdatedAtTest`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `dataUpdatedAtTest`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Initially should be 0 (no data yet) + expect(collection.utils.dataUpdatedAt()).toBe(0) + + // Wait for initial fetch to complete + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + }) + + // After successful fetch, should have a timestamp + const firstTimestamp = collection.utils.dataUpdatedAt() + expect(firstTimestamp).toBeGreaterThan(0) + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Trigger a refetch + await collection.utils.refetch() + + // Timestamp should be updated + const secondTimestamp = collection.utils.dataUpdatedAt() + expect(secondTimestamp).toBeGreaterThan(firstTimestamp) + }) + + it(`should expose fetchStatus`, async () => { + const queryKey = [`fetchStatusTest`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `fetchStatusTest`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Initially should be 'fetching' + expect(collection.utils.fetchStatus()).toBe(`fetching`) + + // Wait for initial fetch to complete + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + }) + + // After fetch completes, should be 'idle' + expect(collection.utils.fetchStatus()).toBe(`idle`) + + // Trigger a refetch + const refetchPromise = collection.utils.refetch() + + // During refetch, should be 'fetching' + await vi.waitFor(() => { + expect(collection.utils.fetchStatus()).toBe(`fetching`) + }) + + await refetchPromise + + // After refetch completes, should be 'idle' + expect(collection.utils.fetchStatus()).toBe(`idle`) + }) + + it(`should maintain query state across multiple refetches`, async () => { + const queryKey = [`multipleRefetchStateTest`] + let callCount = 0 + const queryFn = vi.fn().mockImplementation(() => { + callCount++ + return Promise.resolve([{ id: `1`, name: `Item ${callCount}` }]) + }) + + const config: QueryCollectionConfig = { + id: `multipleRefetchStateTest`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial load + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + }) + + const initialTimestamp = collection.utils.dataUpdatedAt() + + // Perform multiple refetches + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + await collection.utils.refetch() + + // After each refetch, should have an updated timestamp + expect(collection.utils.dataUpdatedAt()).toBeGreaterThan( + initialTimestamp + ) + expect(collection.utils.isFetching()).toBe(false) + expect(collection.utils.isRefetching()).toBe(false) + expect(collection.utils.isLoading()).toBe(false) + expect(collection.utils.fetchStatus()).toBe(`idle`) + } + + expect(queryFn).toHaveBeenCalledTimes(4) // 1 initial + 3 refetches + }) + + it(`should expose query state even when collection has errors`, async () => { + const queryKey = [`errorStateTest`] + const testError = new Error(`Test error`) + const successData = [{ id: `1`, name: `Item 1` }] + + const queryFn = vi + .fn() + .mockResolvedValueOnce(successData) // Initial success + .mockRejectedValueOnce(testError) // Error on refetch + + const config: QueryCollectionConfig = { + id: `errorStateTest`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + retry: false, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Wait for initial success + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + expect(collection.utils.isError()).toBe(false) + }) + + const successTimestamp = collection.utils.dataUpdatedAt() + expect(successTimestamp).toBeGreaterThan(0) + + // Trigger a refetch that will error + await collection.utils.refetch() + + // Wait for error + await vi.waitFor(() => { + expect(collection.utils.isError()).toBe(true) + }) + + // Query state should still be accessible + expect(collection.utils.isFetching()).toBe(false) + expect(collection.utils.isRefetching()).toBe(false) + expect(collection.utils.isLoading()).toBe(false) + expect(collection.utils.fetchStatus()).toBe(`idle`) + + // dataUpdatedAt should remain at the last successful fetch timestamp + expect(collection.utils.dataUpdatedAt()).toBe(successTimestamp) + }) + }) })