Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,33 @@ export interface QueryCollectionUtils<
* @throws Error if the refetch fails
*/
clearError: () => Promise<void>
/**
* 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`
}

/**
Expand Down Expand Up @@ -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<any>[`sync`] = (params) => {
const { begin, write, commit, markReady, collection } = params

Expand Down Expand Up @@ -456,6 +492,13 @@ export function queryCollectionOptions(

type UpdateHandler = Parameters<typeof localObserver.subscribe>[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
Expand Down Expand Up @@ -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,
},
}
}
229 changes: 229 additions & 0 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestItem> = {
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<TestItem> = {
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<TestItem> = {
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<TestItem> = {
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<TestItem> = {
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)
})
})
})
Loading