diff --git a/.changeset/tanstack-query-expose-query-state.md b/.changeset/tanstack-query-expose-query-state.md new file mode 100644 index 000000000..75e80805e --- /dev/null +++ b/.changeset/tanstack-query-expose-query-state.md @@ -0,0 +1,48 @@ +--- +"@tanstack/query-db-collection": minor +--- + +**BREAKING**: Refactor query state utils from functions to getters + +This change refactors the query state utility properties from function calls to getters, aligning with TanStack Query's API patterns and providing a more intuitive developer experience. + +**Breaking Changes:** + +- `collection.utils.lastError()` → `collection.utils.lastError` +- `collection.utils.isError()` → `collection.utils.isError` +- `collection.utils.errorCount()` → `collection.utils.errorCount` +- `collection.utils.isFetching()` → `collection.utils.isFetching` +- `collection.utils.isRefetching()` → `collection.utils.isRefetching` +- `collection.utils.isLoading()` → `collection.utils.isLoading` +- `collection.utils.dataUpdatedAt()` → `collection.utils.dataUpdatedAt` +- `collection.utils.fetchStatus()` → `collection.utils.fetchStatus` + +**New Features:** +Exposes TanStack Query's QueryObserver state through new utility getters: + +- `isFetching` - Whether the query is currently fetching (initial or background) +- `isRefetching` - Whether the query is refetching in the background +- `isLoading` - Whether the query is loading for the first time +- `dataUpdatedAt` - Timestamp of last successful data update +- `fetchStatus` - 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 + +**Migration Guide:** +Remove parentheses from all utility property access. Properties are now accessed directly instead of being called as functions: + +```typescript +// Before +if (collection.utils.isFetching()) { + console.log("Syncing...", collection.utils.dataUpdatedAt()) +} + +// After +if (collection.utils.isFetching) { + console.log("Syncing...", collection.utils.dataUpdatedAt) +} +``` diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index bbfd6db56..80a1ed2d6 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -151,7 +151,7 @@ export interface QueryCollectionUtils< TKey extends string | number = string | number, TInsertInput extends object = TItem, TError = unknown, -> extends UtilsRecord { +> { /** Manually trigger a refetch of the query */ refetch: RefetchFn /** Insert one or more items directly into the synced data store without triggering a query refetch or optimistic update */ @@ -164,21 +164,48 @@ export interface QueryCollectionUtils< writeUpsert: (data: Partial | Array>) => void /** Execute multiple write operations as a single atomic batch to the synced data store */ writeBatch: (callback: () => void) => void - /** Get the last error encountered by the query (if any); reset on success */ - lastError: () => TError | undefined - /** Check if the collection is in an error state */ - isError: () => boolean + /** The last error encountered by the query (if any); reset on success */ + readonly lastError: TError | undefined + /** Whether the collection is in an error state */ + readonly isError: boolean /** - * Get the number of consecutive sync failures. + * The number of consecutive sync failures. * Incremented only when query fails completely (not per retry attempt); reset on success. */ - errorCount: () => number + readonly errorCount: number /** * Clear the error state and trigger a refetch of the query * @returns Promise that resolves when the refetch completes successfully * @throws Error if the refetch fails */ clearError: () => Promise + /** + * Whether the query is currently fetching data (including background refetches). + * True during both initial fetches and background refetches. + */ + readonly isFetching: boolean + /** + * Whether the query is currently refetching data in the background. + * True only during background refetches (not initial fetch). + */ + readonly isRefetching: boolean + /** + * Whether the query is loading for the first time (no data yet). + * True only during the initial fetch before any data is available. + */ + readonly isLoading: boolean + /** + * The timestamp (in milliseconds since epoch) when the data was last successfully updated. + * Returns 0 if the query has never successfully fetched data. + */ + readonly dataUpdatedAt: number + /** + * 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 + */ + readonly fetchStatus: `fetching` | `paused` | `idle` } /** @@ -286,7 +313,7 @@ export function queryCollectionOptions< schema: T select: (data: TQueryData) => Array> } -): CollectionConfig, TKey, T> & { +): CollectionConfig, TKey, T, UtilsRecord> & { schema: T utils: QueryCollectionUtils< InferSchemaOutput, @@ -319,7 +346,7 @@ export function queryCollectionOptions< schema?: never // prohibit schema select: (data: TQueryData) => Array } -): CollectionConfig & { +): CollectionConfig & { schema?: never // no schema in the result utils: QueryCollectionUtils } @@ -343,7 +370,7 @@ export function queryCollectionOptions< > & { schema: T } -): CollectionConfig, TKey, T> & { +): CollectionConfig, TKey, T, UtilsRecord> & { schema: T utils: QueryCollectionUtils< InferSchemaOutput, @@ -369,14 +396,19 @@ export function queryCollectionOptions< > & { schema?: never // prohibit schema } -): CollectionConfig & { +): CollectionConfig & { schema?: never // no schema in the result utils: QueryCollectionUtils } export function queryCollectionOptions( config: QueryCollectionConfig> -): CollectionConfig & { +): CollectionConfig< + Record, + string | number, + never, + UtilsRecord +> & { utils: QueryCollectionUtils } { const { @@ -427,6 +459,15 @@ export function queryCollectionOptions( /** Reference to the QueryObserver for imperative refetch */ let queryObserver: QueryObserver, any, Array, Array, any> + /** 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 @@ -466,6 +507,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 @@ -717,18 +765,67 @@ export function queryCollectionOptions( onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, - utils: { - refetch, - ...writeUtils, - lastError: () => lastError, - isError: () => !!lastError, - errorCount: () => errorCount, - clearError: async () => { - lastError = undefined - errorCount = 0 - lastErrorUpdatedAt = 0 - await refetch({ throwOnError: true }) + utils: Object.defineProperties( + { + refetch, + ...writeUtils, + clearError: async () => { + lastError = undefined + errorCount = 0 + lastErrorUpdatedAt = 0 + await refetch({ throwOnError: true }) + }, }, - }, + { + lastError: { + get() { + return lastError + }, + enumerable: true, + }, + isError: { + get() { + return !!lastError + }, + enumerable: true, + }, + errorCount: { + get() { + return errorCount + }, + enumerable: true, + }, + isFetching: { + get() { + return queryState.isFetching + }, + enumerable: true, + }, + isRefetching: { + get() { + return queryState.isRefetching + }, + enumerable: true, + }, + isLoading: { + get() { + return queryState.isLoading + }, + enumerable: true, + }, + dataUpdatedAt: { + get() { + return queryState.dataUpdatedAt + }, + enumerable: true, + }, + fetchStatus: { + get() { + return queryState.fetchStatus + }, + enumerable: true, + }, + } + ) as any, } } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index b3a6dd712..94dd84089 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -2097,7 +2097,7 @@ describe(`QueryCollection`, () => { const collection2 = createCollection(options2) await vi.waitFor(() => { - expect(collection1.utils.isError()).toBe(true) + expect(collection1.utils.isError).toBe(true) expect(collection2.status).toBe(`ready`) }) @@ -2134,7 +2134,7 @@ describe(`QueryCollection`, () => { ) await vi.waitFor(() => { - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.isError).toBe(true) }) await expect( @@ -2264,33 +2264,33 @@ describe(`QueryCollection`, () => { // Wait for initial success - no errors await vi.waitFor(() => { expect(collection.status).toBe(`ready`) - expect(collection.utils.lastError()).toBeUndefined() - expect(collection.utils.isError()).toBe(false) - expect(collection.utils.errorCount()).toBe(0) + expect(collection.utils.lastError).toBeUndefined() + expect(collection.utils.isError).toBe(false) + expect(collection.utils.errorCount).toBe(0) }) // First error - count increments await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.lastError()).toBe(errors[0]) - expect(collection.utils.errorCount()).toBe(1) - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.lastError).toBe(errors[0]) + expect(collection.utils.errorCount).toBe(1) + expect(collection.utils.isError).toBe(true) }) // Second error - count increments again await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.lastError()).toBe(errors[1]) - expect(collection.utils.errorCount()).toBe(2) - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.lastError).toBe(errors[1]) + expect(collection.utils.errorCount).toBe(2) + expect(collection.utils.isError).toBe(true) }) // Successful refetch resets error state await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.lastError()).toBeUndefined() - expect(collection.utils.isError()).toBe(false) - expect(collection.utils.errorCount()).toBe(0) + expect(collection.utils.lastError).toBeUndefined() + expect(collection.utils.isError).toBe(false) + expect(collection.utils.errorCount).toBe(0) expect(collection.get(`1`)).toEqual(updatedData[0]) }) }) @@ -2312,16 +2312,16 @@ describe(`QueryCollection`, () => { // Wait for initial error await vi.waitFor(() => { - expect(collection.utils.isError()).toBe(true) - expect(collection.utils.errorCount()).toBe(1) + expect(collection.utils.isError).toBe(true) + expect(collection.utils.errorCount).toBe(1) }) // Manual error clearing triggers refetch await collection.utils.clearError() - expect(collection.utils.lastError()).toBeUndefined() - expect(collection.utils.isError()).toBe(false) - expect(collection.utils.errorCount()).toBe(0) + expect(collection.utils.lastError).toBeUndefined() + expect(collection.utils.isError).toBe(false) + expect(collection.utils.errorCount).toBe(0) await vi.waitFor(() => { expect(collection.get(`1`)).toEqual(recoveryData[0]) @@ -2329,9 +2329,9 @@ describe(`QueryCollection`, () => { // Refetch on rejection should throw an error await expect(collection.utils.clearError()).rejects.toThrow(testError) - expect(collection.utils.lastError()).toBe(testError) - expect(collection.utils.isError()).toBe(true) - expect(collection.utils.errorCount()).toBe(1) + expect(collection.utils.lastError).toBe(testError) + expect(collection.utils.isError).toBe(true) + expect(collection.utils.errorCount).toBe(1) }) it(`should maintain collection functionality despite errors and persist error state`, async () => { @@ -2359,8 +2359,8 @@ describe(`QueryCollection`, () => { // Cause error await collection.utils.refetch() await vi.waitFor(() => { - expect(collection.utils.errorCount()).toBe(1) - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.errorCount).toBe(1) + expect(collection.utils.isError).toBe(true) }) // Collection operations still work with cached data @@ -2377,25 +2377,25 @@ describe(`QueryCollection`, () => { await flushPromises() // Manual writes clear error state - expect(collection.utils.lastError()).toBeUndefined() - expect(collection.utils.isError()).toBe(false) - expect(collection.utils.errorCount()).toBe(0) + expect(collection.utils.lastError).toBeUndefined() + expect(collection.utils.isError).toBe(false) + expect(collection.utils.errorCount).toBe(0) // Create error state again for persistence test await collection.utils.refetch() - await vi.waitFor(() => expect(collection.utils.isError()).toBe(true)) + await vi.waitFor(() => expect(collection.utils.isError).toBe(true)) - const originalError = collection.utils.lastError() - const originalErrorCount = collection.utils.errorCount() + const originalError = collection.utils.lastError + const originalErrorCount = collection.utils.errorCount // Read-only operations don't affect error state expect(collection.has(`1`)).toBe(true) const changeHandler = vi.fn() const subscription = collection.subscribeChanges(changeHandler) - expect(collection.utils.lastError()).toBe(originalError) - expect(collection.utils.isError()).toBe(true) - expect(collection.utils.errorCount()).toBe(originalErrorCount) + expect(collection.utils.lastError).toBe(originalError) + expect(collection.utils.isError).toBe(true) + expect(collection.utils.errorCount).toBe(originalErrorCount) subscription.unsubscribe() }) @@ -2435,16 +2435,16 @@ describe(`QueryCollection`, () => { // Wait for collection to be ready (even with error) await vi.waitFor(() => { expect(collection.status).toBe(`ready`) - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.isError).toBe(true) }) // Verify custom error is accessible with all its properties - const lastError = collection.utils.lastError() + const lastError = collection.utils.lastError expect(lastError).toBe(customError) expect(lastError?.code).toBe(`NETWORK_ERROR`) expect(lastError?.message).toBe(`Failed to fetch data`) expect(lastError?.details?.retryAfter).toBe(5000) - expect(collection.utils.errorCount()).toBe(1) + expect(collection.utils.errorCount).toBe(1) }) it(`should persist error state after collection cleanup`, async () => { @@ -2461,21 +2461,21 @@ describe(`QueryCollection`, () => { // Wait for collection to be ready (even with error) await vi.waitFor(() => { expect(collection.status).toBe(`ready`) - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.isError).toBe(true) }) // Verify error state before cleanup - expect(collection.utils.lastError()).toBe(testError) - expect(collection.utils.errorCount()).toBe(1) + expect(collection.utils.lastError).toBe(testError) + expect(collection.utils.errorCount).toBe(1) // Cleanup collection await collection.cleanup() expect(collection.status).toBe(`cleaned-up`) // Error state should persist after cleanup - expect(collection.utils.isError()).toBe(true) - expect(collection.utils.lastError()).toBe(testError) - expect(collection.utils.errorCount()).toBe(1) + expect(collection.utils.isError).toBe(true) + expect(collection.utils.lastError).toBe(testError) + expect(collection.utils.errorCount).toBe(1) }) it(`should increment errorCount only after final failure when using Query retries`, async () => { @@ -2506,16 +2506,16 @@ describe(`QueryCollection`, () => { () => { expect(collection.status).toBe(`ready`) // Should be ready even with error expect(queryFn).toHaveBeenCalledTimes(totalAttempts) - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.isError).toBe(true) }, { timeout: 2000 } ) // Error count should only increment once after all retries are exhausted // This ensures we track "consecutive post-retry failures," not per-attempt failures - expect(collection.utils.errorCount()).toBe(1) - expect(collection.utils.lastError()).toBe(testError) - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.errorCount).toBe(1) + expect(collection.utils.lastError).toBe(testError) + expect(collection.utils.isError).toBe(true) // Reset attempt counter for second test queryFn.mockClear() @@ -2532,9 +2532,9 @@ describe(`QueryCollection`, () => { ) // Error count should now be 2 (two post-retry failures) - expect(collection.utils.errorCount()).toBe(2) - expect(collection.utils.lastError()).toBe(testError) - expect(collection.utils.isError()).toBe(true) + expect(collection.utils.errorCount).toBe(2) + expect(collection.utils.lastError).toBe(testError) + expect(collection.utils.isError).toBe(true) }) }) @@ -2788,4 +2788,231 @@ describe(`QueryCollection`, () => { customQueryClient.clear() }) }) + + 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) + }) + }) })