Skip to content

Commit 5902599

Browse files
committed
feat(query-db-collection): expose query state from QueryObserver
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 <noreply@anthropic.com>
1 parent 36d2439 commit 5902599

File tree

2 files changed

+277
-0
lines changed

2 files changed

+277
-0
lines changed

packages/query-db-collection/src/query.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,33 @@ export interface QueryCollectionUtils<
175175
* @throws Error if the refetch fails
176176
*/
177177
clearError: () => Promise<void>
178+
/**
179+
* Check if the query is currently fetching data (including background refetches).
180+
* Returns true during both initial fetches and background refetches.
181+
*/
182+
isFetching: () => boolean
183+
/**
184+
* Check if the query is currently refetching data in the background.
185+
* Returns true only during background refetches (not initial fetch).
186+
*/
187+
isRefetching: () => boolean
188+
/**
189+
* Check if the query is loading for the first time (no data yet).
190+
* Returns true only during the initial fetch before any data is available.
191+
*/
192+
isLoading: () => boolean
193+
/**
194+
* Get the timestamp (in milliseconds since epoch) when the data was last successfully updated.
195+
* Returns 0 if the query has never successfully fetched data.
196+
*/
197+
dataUpdatedAt: () => number
198+
/**
199+
* Get the current fetch status of the query.
200+
* - 'fetching': Query is currently fetching
201+
* - 'paused': Query is paused (e.g., network offline)
202+
* - 'idle': Query is not fetching
203+
*/
204+
fetchStatus: () => `fetching` | `paused` | `idle`
178205
}
179206

180207
/**
@@ -421,6 +448,15 @@ export function queryCollectionOptions(
421448
/** The timestamp for when the query most recently returned the status as "error" */
422449
let lastErrorUpdatedAt = 0
423450

451+
/** Query state tracking from QueryObserver */
452+
const queryState = {
453+
isFetching: false,
454+
isRefetching: false,
455+
isLoading: false,
456+
dataUpdatedAt: 0,
457+
fetchStatus: `idle` as `fetching` | `paused` | `idle`,
458+
}
459+
424460
const internalSync: SyncConfig<any>[`sync`] = (params) => {
425461
const { begin, write, commit, markReady, collection } = params
426462

@@ -456,6 +492,13 @@ export function queryCollectionOptions(
456492

457493
type UpdateHandler = Parameters<typeof localObserver.subscribe>[0]
458494
const handleQueryResult: UpdateHandler = (result) => {
495+
// Update query state from QueryObserver result
496+
queryState.isFetching = result.isFetching
497+
queryState.isRefetching = result.isRefetching
498+
queryState.isLoading = result.isLoading
499+
queryState.dataUpdatedAt = result.dataUpdatedAt
500+
queryState.fetchStatus = result.fetchStatus
501+
459502
if (result.isSuccess) {
460503
// Clear error state
461504
lastError = undefined
@@ -704,6 +747,11 @@ export function queryCollectionOptions(
704747
lastErrorUpdatedAt = 0
705748
return refetch({ throwOnError: true })
706749
},
750+
isFetching: () => queryState.isFetching,
751+
isRefetching: () => queryState.isRefetching,
752+
isLoading: () => queryState.isLoading,
753+
dataUpdatedAt: () => queryState.dataUpdatedAt,
754+
fetchStatus: () => queryState.fetchStatus,
707755
},
708756
}
709757
}

packages/query-db-collection/tests/query.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2340,4 +2340,233 @@ describe(`QueryCollection`, () => {
23402340
expect(collection.size).toBe(items.length)
23412341
})
23422342
})
2343+
2344+
describe(`Query State Utils`, () => {
2345+
it(`should expose isFetching, isRefetching, isLoading state`, async () => {
2346+
const queryKey = [`queryStateTest`]
2347+
const items = [{ id: `1`, name: `Item 1` }]
2348+
const queryFn = vi.fn().mockResolvedValue(items)
2349+
2350+
const config: QueryCollectionConfig<TestItem> = {
2351+
id: `queryStateTest`,
2352+
queryClient,
2353+
queryKey,
2354+
queryFn,
2355+
getKey,
2356+
startSync: true,
2357+
}
2358+
2359+
const options = queryCollectionOptions(config)
2360+
const collection = createCollection(options)
2361+
2362+
// Initially should be loading (first fetch)
2363+
expect(collection.utils.isLoading()).toBe(true)
2364+
expect(collection.utils.isFetching()).toBe(true)
2365+
expect(collection.utils.isRefetching()).toBe(false)
2366+
2367+
// Wait for initial fetch to complete
2368+
await vi.waitFor(() => {
2369+
expect(collection.status).toBe(`ready`)
2370+
})
2371+
2372+
// After initial fetch, should not be loading/fetching
2373+
expect(collection.utils.isLoading()).toBe(false)
2374+
expect(collection.utils.isFetching()).toBe(false)
2375+
expect(collection.utils.isRefetching()).toBe(false)
2376+
2377+
// Trigger a refetch
2378+
const refetchPromise = collection.utils.refetch()
2379+
2380+
// During refetch, should be fetching and refetching, but not loading
2381+
await vi.waitFor(() => {
2382+
expect(collection.utils.isFetching()).toBe(true)
2383+
})
2384+
expect(collection.utils.isRefetching()).toBe(true)
2385+
expect(collection.utils.isLoading()).toBe(false)
2386+
2387+
await refetchPromise
2388+
2389+
// After refetch completes, should not be fetching
2390+
expect(collection.utils.isFetching()).toBe(false)
2391+
expect(collection.utils.isRefetching()).toBe(false)
2392+
expect(collection.utils.isLoading()).toBe(false)
2393+
})
2394+
2395+
it(`should expose dataUpdatedAt timestamp`, async () => {
2396+
const queryKey = [`dataUpdatedAtTest`]
2397+
const items = [{ id: `1`, name: `Item 1` }]
2398+
const queryFn = vi.fn().mockResolvedValue(items)
2399+
2400+
const config: QueryCollectionConfig<TestItem> = {
2401+
id: `dataUpdatedAtTest`,
2402+
queryClient,
2403+
queryKey,
2404+
queryFn,
2405+
getKey,
2406+
startSync: true,
2407+
}
2408+
2409+
const options = queryCollectionOptions(config)
2410+
const collection = createCollection(options)
2411+
2412+
// Initially should be 0 (no data yet)
2413+
expect(collection.utils.dataUpdatedAt()).toBe(0)
2414+
2415+
// Wait for initial fetch to complete
2416+
await vi.waitFor(() => {
2417+
expect(collection.status).toBe(`ready`)
2418+
})
2419+
2420+
// After successful fetch, should have a timestamp
2421+
const firstTimestamp = collection.utils.dataUpdatedAt()
2422+
expect(firstTimestamp).toBeGreaterThan(0)
2423+
2424+
// Wait a bit to ensure timestamp difference
2425+
await new Promise((resolve) => setTimeout(resolve, 10))
2426+
2427+
// Trigger a refetch
2428+
await collection.utils.refetch()
2429+
2430+
// Timestamp should be updated
2431+
const secondTimestamp = collection.utils.dataUpdatedAt()
2432+
expect(secondTimestamp).toBeGreaterThan(firstTimestamp)
2433+
})
2434+
2435+
it(`should expose fetchStatus`, async () => {
2436+
const queryKey = [`fetchStatusTest`]
2437+
const items = [{ id: `1`, name: `Item 1` }]
2438+
const queryFn = vi.fn().mockResolvedValue(items)
2439+
2440+
const config: QueryCollectionConfig<TestItem> = {
2441+
id: `fetchStatusTest`,
2442+
queryClient,
2443+
queryKey,
2444+
queryFn,
2445+
getKey,
2446+
startSync: true,
2447+
}
2448+
2449+
const options = queryCollectionOptions(config)
2450+
const collection = createCollection(options)
2451+
2452+
// Initially should be 'fetching'
2453+
expect(collection.utils.fetchStatus()).toBe(`fetching`)
2454+
2455+
// Wait for initial fetch to complete
2456+
await vi.waitFor(() => {
2457+
expect(collection.status).toBe(`ready`)
2458+
})
2459+
2460+
// After fetch completes, should be 'idle'
2461+
expect(collection.utils.fetchStatus()).toBe(`idle`)
2462+
2463+
// Trigger a refetch
2464+
const refetchPromise = collection.utils.refetch()
2465+
2466+
// During refetch, should be 'fetching'
2467+
await vi.waitFor(() => {
2468+
expect(collection.utils.fetchStatus()).toBe(`fetching`)
2469+
})
2470+
2471+
await refetchPromise
2472+
2473+
// After refetch completes, should be 'idle'
2474+
expect(collection.utils.fetchStatus()).toBe(`idle`)
2475+
})
2476+
2477+
it(`should maintain query state across multiple refetches`, async () => {
2478+
const queryKey = [`multipleRefetchStateTest`]
2479+
let callCount = 0
2480+
const queryFn = vi.fn().mockImplementation(() => {
2481+
callCount++
2482+
return Promise.resolve([{ id: `1`, name: `Item ${callCount}` }])
2483+
})
2484+
2485+
const config: QueryCollectionConfig<TestItem> = {
2486+
id: `multipleRefetchStateTest`,
2487+
queryClient,
2488+
queryKey,
2489+
queryFn,
2490+
getKey,
2491+
startSync: true,
2492+
}
2493+
2494+
const options = queryCollectionOptions(config)
2495+
const collection = createCollection(options)
2496+
2497+
// Wait for initial load
2498+
await vi.waitFor(() => {
2499+
expect(collection.status).toBe(`ready`)
2500+
})
2501+
2502+
const initialTimestamp = collection.utils.dataUpdatedAt()
2503+
2504+
// Perform multiple refetches
2505+
for (let i = 0; i < 3; i++) {
2506+
await new Promise((resolve) => setTimeout(resolve, 10))
2507+
await collection.utils.refetch()
2508+
2509+
// After each refetch, should have an updated timestamp
2510+
expect(collection.utils.dataUpdatedAt()).toBeGreaterThan(
2511+
initialTimestamp
2512+
)
2513+
expect(collection.utils.isFetching()).toBe(false)
2514+
expect(collection.utils.isRefetching()).toBe(false)
2515+
expect(collection.utils.isLoading()).toBe(false)
2516+
expect(collection.utils.fetchStatus()).toBe(`idle`)
2517+
}
2518+
2519+
expect(queryFn).toHaveBeenCalledTimes(4) // 1 initial + 3 refetches
2520+
})
2521+
2522+
it(`should expose query state even when collection has errors`, async () => {
2523+
const queryKey = [`errorStateTest`]
2524+
const testError = new Error(`Test error`)
2525+
const successData = [{ id: `1`, name: `Item 1` }]
2526+
2527+
const queryFn = vi
2528+
.fn()
2529+
.mockResolvedValueOnce(successData) // Initial success
2530+
.mockRejectedValueOnce(testError) // Error on refetch
2531+
2532+
const config: QueryCollectionConfig<TestItem> = {
2533+
id: `errorStateTest`,
2534+
queryClient,
2535+
queryKey,
2536+
queryFn,
2537+
getKey,
2538+
startSync: true,
2539+
retry: false,
2540+
}
2541+
2542+
const options = queryCollectionOptions(config)
2543+
const collection = createCollection(options)
2544+
2545+
// Wait for initial success
2546+
await vi.waitFor(() => {
2547+
expect(collection.status).toBe(`ready`)
2548+
expect(collection.utils.isError()).toBe(false)
2549+
})
2550+
2551+
const successTimestamp = collection.utils.dataUpdatedAt()
2552+
expect(successTimestamp).toBeGreaterThan(0)
2553+
2554+
// Trigger a refetch that will error
2555+
await collection.utils.refetch()
2556+
2557+
// Wait for error
2558+
await vi.waitFor(() => {
2559+
expect(collection.utils.isError()).toBe(true)
2560+
})
2561+
2562+
// Query state should still be accessible
2563+
expect(collection.utils.isFetching()).toBe(false)
2564+
expect(collection.utils.isRefetching()).toBe(false)
2565+
expect(collection.utils.isLoading()).toBe(false)
2566+
expect(collection.utils.fetchStatus()).toBe(`idle`)
2567+
2568+
// dataUpdatedAt should remain at the last successful fetch timestamp
2569+
expect(collection.utils.dataUpdatedAt()).toBe(successTimestamp)
2570+
})
2571+
})
23432572
})

0 commit comments

Comments
 (0)