From bd32d34ec0d0e21657dc034c98546c15712a55cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Oct 2025 13:45:07 +0000 Subject: [PATCH 1/2] feat(query-db-collection)!: refactor query state utils to getters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Query state utilities are now accessed as properties instead of function calls, aligning with TanStack Query's API patterns for a more intuitive developer experience. Changed utilities: - lastError() → lastError - isError() → isError - errorCount() → errorCount - isFetching() → isFetching - isRefetching() → isRefetching - isLoading() → isLoading - dataUpdatedAt() → dataUpdatedAt - fetchStatus() → fetchStatus New features: - Exposes TanStack Query's QueryObserver state through utility getters - Enables "Last updated" UI patterns and background fetch indicators - Provides insight into sync behavior beyond error states Migration: Remove parentheses when accessing these properties Example: collection.utils.isFetching() → collection.utils.isFetching 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 --- .../tanstack-query-expose-query-state.md | 45 +++ packages/query-db-collection/src/query.ts | 176 ++++++++-- .../query-db-collection/tests/query.test.ts | 321 +++++++++++++++--- 3 files changed, 470 insertions(+), 72 deletions(-) create mode 100644 .changeset/tanstack-query-expose-query-state.md diff --git a/.changeset/tanstack-query-expose-query-state.md b/.changeset/tanstack-query-expose-query-state.md new file mode 100644 index 000000000..af212fbe2 --- /dev/null +++ b/.changeset/tanstack-query-expose-query-state.md @@ -0,0 +1,45 @@ +--- +"@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 2d53105cc..5bcdce480 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -20,7 +20,6 @@ import type { InsertMutationFnParams, SyncConfig, UpdateMutationFnParams, - UtilsRecord, } from "@tanstack/db" import type { StandardSchemaV1 } from "@standard-schema/spec" @@ -147,7 +146,9 @@ export interface QueryCollectionUtils< TKey extends string | number = string | number, TInsertInput extends object = TItem, TError = unknown, -> extends UtilsRecord { +> { + /** Allow additional utility functions to be added */ + [key: string]: any /** 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 */ @@ -160,21 +161,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` } /** @@ -282,7 +310,12 @@ export function queryCollectionOptions< schema: T select: (data: TQueryData) => Array> } -): CollectionConfig, TKey, T> & { +): CollectionConfig< + InferSchemaOutput, + TKey, + T, + QueryCollectionUtils, TKey, InferSchemaInput, TError> +> & { schema: T utils: QueryCollectionUtils< InferSchemaOutput, @@ -315,7 +348,12 @@ export function queryCollectionOptions< schema?: never // prohibit schema select: (data: TQueryData) => Array } -): CollectionConfig & { +): CollectionConfig< + T, + TKey, + never, + QueryCollectionUtils +> & { schema?: never // no schema in the result utils: QueryCollectionUtils } @@ -339,7 +377,12 @@ export function queryCollectionOptions< > & { schema: T } -): CollectionConfig, TKey, T> & { +): CollectionConfig< + InferSchemaOutput, + TKey, + T, + QueryCollectionUtils, TKey, InferSchemaInput, TError> +> & { schema: T utils: QueryCollectionUtils< InferSchemaOutput, @@ -365,14 +408,24 @@ export function queryCollectionOptions< > & { schema?: never // prohibit schema } -): CollectionConfig & { +): CollectionConfig< + T, + TKey, + never, + QueryCollectionUtils +> & { schema?: never // no schema in the result utils: QueryCollectionUtils } export function queryCollectionOptions( config: QueryCollectionConfig> -): CollectionConfig & { +): CollectionConfig< + Record, + string | number, + never, + QueryCollectionUtils +> & { utils: QueryCollectionUtils } { const { @@ -421,6 +474,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 @@ -451,11 +513,26 @@ export function queryCollectionOptions( any >(queryClient, observerOptions) + // Initialize query state with current observer state + const initialResult = localObserver.getCurrentResult() + queryState.isFetching = initialResult.isFetching + queryState.isRefetching = initialResult.isRefetching + queryState.isLoading = initialResult.isLoading + queryState.dataUpdatedAt = initialResult.dataUpdatedAt + queryState.fetchStatus = initialResult.fetchStatus + let isSubscribed = false let actualUnsubscribeFn: (() => void) | null = null 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 @@ -692,18 +769,67 @@ export function queryCollectionOptions( onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, - utils: { - refetch, - ...writeUtils, - lastError: () => lastError, - isError: () => !!lastError, - errorCount: () => errorCount, - clearError: () => { - lastError = undefined - errorCount = 0 - lastErrorUpdatedAt = 0 - return refetch({ throwOnError: true }) + utils: Object.defineProperties( + { + refetch, + ...writeUtils, + clearError: () => { + lastError = undefined + errorCount = 0 + lastErrorUpdatedAt = 0 + return 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, + }, + } + ), } } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index b87caf67c..5807c66d6 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1997,33 +1997,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]) }) }) @@ -2045,16 +2045,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]) @@ -2062,9 +2062,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 () => { @@ -2092,8 +2092,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 @@ -2110,25 +2110,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() }) @@ -2168,16 +2168,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 () => { @@ -2194,21 +2194,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 () => { @@ -2239,16 +2239,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() @@ -2265,9 +2265,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) }) }) @@ -2340,4 +2340,231 @@ 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) + }) + }) }) From e92c3f2ac755cdf9826c80038ae65e84a0985f99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Oct 2025 16:28:38 +0000 Subject: [PATCH 2/2] fix: resolve type errors and refine query state utils - Fixed type compatibility issues by using UtilsRecord as TUtils parameter in return types - Removed duplicate queryState initialization - Converted all test function calls to property access for getters - Ran prettier to format code - Reduced test failures from 15 to 13 Type errors: 0 remaining Test status: 13 failures (down from 15), 60 passing --- .../tanstack-query-expose-query-state.md | 7 +++- packages/query-db-collection/src/query.ts | 41 +++---------------- .../query-db-collection/tests/query.test.ts | 2 +- 3 files changed, 12 insertions(+), 38 deletions(-) diff --git a/.changeset/tanstack-query-expose-query-state.md b/.changeset/tanstack-query-expose-query-state.md index af212fbe2..75e80805e 100644 --- a/.changeset/tanstack-query-expose-query-state.md +++ b/.changeset/tanstack-query-expose-query-state.md @@ -7,6 +7,7 @@ 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` @@ -18,6 +19,7 @@ This change refactors the query state utility properties from function calls to **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 @@ -25,6 +27,7 @@ Exposes TanStack Query's QueryObserver state through new utility getters: - `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 @@ -35,11 +38,11 @@ Remove parentheses from all utility property access. Properties are now accessed ```typescript // Before if (collection.utils.isFetching()) { - console.log('Syncing...', collection.utils.dataUpdatedAt()) + console.log("Syncing...", collection.utils.dataUpdatedAt()) } // After if (collection.utils.isFetching) { - console.log('Syncing...', collection.utils.dataUpdatedAt) + 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 6c0da61b5..80a1ed2d6 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -21,6 +21,7 @@ import type { InsertMutationFnParams, SyncConfig, UpdateMutationFnParams, + UtilsRecord, } from "@tanstack/db" import type { StandardSchemaV1 } from "@standard-schema/spec" @@ -151,8 +152,6 @@ export interface QueryCollectionUtils< TInsertInput extends object = TItem, TError = unknown, > { - /** Allow additional utility functions to be added */ - [key: string]: any /** 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 */ @@ -314,12 +313,7 @@ export function queryCollectionOptions< schema: T select: (data: TQueryData) => Array> } -): CollectionConfig< - InferSchemaOutput, - TKey, - T, - QueryCollectionUtils, TKey, InferSchemaInput, TError> -> & { +): CollectionConfig, TKey, T, UtilsRecord> & { schema: T utils: QueryCollectionUtils< InferSchemaOutput, @@ -352,12 +346,7 @@ export function queryCollectionOptions< schema?: never // prohibit schema select: (data: TQueryData) => Array } -): CollectionConfig< - T, - TKey, - never, - QueryCollectionUtils -> & { +): CollectionConfig & { schema?: never // no schema in the result utils: QueryCollectionUtils } @@ -381,12 +370,7 @@ export function queryCollectionOptions< > & { schema: T } -): CollectionConfig< - InferSchemaOutput, - TKey, - T, - QueryCollectionUtils, TKey, InferSchemaInput, TError> -> & { +): CollectionConfig, TKey, T, UtilsRecord> & { schema: T utils: QueryCollectionUtils< InferSchemaOutput, @@ -412,12 +396,7 @@ export function queryCollectionOptions< > & { schema?: never // prohibit schema } -): CollectionConfig< - T, - TKey, - never, - QueryCollectionUtils -> & { +): CollectionConfig & { schema?: never // no schema in the result utils: QueryCollectionUtils } @@ -428,7 +407,7 @@ export function queryCollectionOptions( Record, string | number, never, - QueryCollectionUtils + UtilsRecord > & { utils: QueryCollectionUtils } { @@ -523,14 +502,6 @@ export function queryCollectionOptions( // Store reference for imperative refetch queryObserver = localObserver - // Initialize query state with current observer state - const initialResult = localObserver.getCurrentResult() - queryState.isFetching = initialResult.isFetching - queryState.isRefetching = initialResult.isRefetching - queryState.isLoading = initialResult.isLoading - queryState.dataUpdatedAt = initialResult.dataUpdatedAt - queryState.fetchStatus = initialResult.fetchStatus - let isSubscribed = false let actualUnsubscribeFn: (() => void) | null = null diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index c9e39038c..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`) })