Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: determine staleness locally instead of globally #933

Merged
Merged
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
1 change: 1 addition & 0 deletions docs/src/pages/docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Feature/Capability Key:
| Supported Query Keys | JSON | JSON | GraphQL Query |
| Query Key Change Detection | Deep Compare (Serialization) | Referential Equality (===) | Deep Compare (Serialization) |
| Query Data Memoization Level | Query + Structural Sharing | Query | Query + Entity + Structural Sharing |
| Stale While Revalidate | Server-Side + Client-Side | Server-Side | None |
| Bundle Size | [![][bp-react-query]][bpl-react-query] | [![][bp-swr]][bpl-swr] | [![][bp-apollo]][bpl-apollo] |
| Queries | ✅ | ✅ | ✅ |
| Caching | ✅ | ✅ | ✅ |
Expand Down
147 changes: 31 additions & 116 deletions src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export interface QueryState<TResult, TError> {
isFetchingMore: IsFetchingMoreValue
isIdle: boolean
isLoading: boolean
isStale: boolean
isSuccess: boolean
status: QueryStatus
throwInErrorBoundary?: boolean
Expand All @@ -66,7 +65,6 @@ export interface RefetchOptions {

export enum ActionType {
Failed = 'Failed',
MarkStale = 'MarkStale',
Fetch = 'Fetch',
Success = 'Success',
Error = 'Error',
Expand All @@ -76,10 +74,6 @@ interface FailedAction {
type: ActionType.Failed
}

interface MarkStaleAction {
type: ActionType.MarkStale
}

interface FetchAction {
type: ActionType.Fetch
isFetchingMore?: IsFetchingMoreValue
Expand All @@ -89,7 +83,6 @@ interface SuccessAction<TResult> {
type: ActionType.Success
data: TResult | undefined
canFetchMore?: boolean
isStale: boolean
}

interface ErrorAction<TError> {
Expand All @@ -101,7 +94,6 @@ export type Action<TResult, TError> =
| ErrorAction<TError>
| FailedAction
| FetchAction
| MarkStaleAction
| SuccessAction<TResult>

// CLASS
Expand All @@ -115,14 +107,11 @@ export class Query<TResult, TError> {

private queryCache: QueryCache
private promise?: Promise<TResult | undefined>
private cacheTimeout?: number
private staleTimeout?: number
private gcTimeout?: number
private cancelFetch?: () => void
private continueFetch?: () => void
private isTransportCancelable?: boolean
private notifyGlobalListeners: (query: Query<TResult, TError>) => void
private enableStaleTimeout: boolean
private enableGarbageCollectionTimeout: boolean

constructor(init: QueryInitConfig<TResult, TError>) {
this.config = init.config
Expand All @@ -132,23 +121,7 @@ export class Query<TResult, TError> {
this.notifyGlobalListeners = init.notifyGlobalListeners
this.observers = []
this.state = getDefaultState(init.config)
this.enableStaleTimeout = false
this.enableGarbageCollectionTimeout = false
}

activateStaleTimeout(): void {
this.enableStaleTimeout = true
this.rescheduleStaleTimeout()
}

activateGarbageCollectionTimeout(): void {
this.enableGarbageCollectionTimeout = true
this.rescheduleGarbageCollection()
}

activateTimeouts(): void {
this.activateStaleTimeout()
this.activateGarbageCollectionTimeout()
this.scheduleGc()
}

updateConfig(config: QueryConfig<TResult, TError>): void {
Expand All @@ -161,61 +134,18 @@ export class Query<TResult, TError> {
this.notifyGlobalListeners(this)
}

private rescheduleStaleTimeout(): void {
private scheduleGc(): void {
if (isServer) {
return
}

this.clearStaleTimeout()

if (
!this.enableStaleTimeout ||
this.state.isStale ||
this.state.status !== QueryStatus.Success ||
this.config.staleTime === Infinity
) {
return
}

const staleTime = this.config.staleTime || 0
let timeout = staleTime
if (this.state.updatedAt) {
const timeElapsed = Date.now() - this.state.updatedAt
const timeUntilStale = staleTime - timeElapsed
timeout = Math.max(timeUntilStale, 0)
}

this.staleTimeout = setTimeout(() => {
this.invalidate()
}, timeout)
}
this.clearGcTimeout()

invalidate(): void {
this.clearStaleTimeout()

if (this.state.isStale) {
if (this.config.cacheTime === Infinity || this.observers.length > 0) {
return
}

this.dispatch({ type: ActionType.MarkStale })
}

private rescheduleGarbageCollection(): void {
if (isServer) {
return
}

this.clearCacheTimeout()

if (
!this.enableGarbageCollectionTimeout ||
this.config.cacheTime === Infinity ||
this.observers.length > 0
) {
return
}

this.cacheTimeout = setTimeout(() => {
this.gcTimeout = setTimeout(() => {
this.clear()
}, this.config.cacheTime)
}
Expand All @@ -241,21 +171,14 @@ export class Query<TResult, TError> {

private clearTimersObservers(): void {
this.observers.forEach(observer => {
observer.clearRefetchInterval()
observer.clearTimers()
})
}

private clearStaleTimeout() {
if (this.staleTimeout) {
clearTimeout(this.staleTimeout)
this.staleTimeout = undefined
}
}

private clearCacheTimeout() {
if (this.cacheTimeout) {
clearTimeout(this.cacheTimeout)
this.cacheTimeout = undefined
private clearGcTimeout() {
if (this.gcTimeout) {
clearTimeout(this.gcTimeout)
this.gcTimeout = undefined
}
}

Expand All @@ -275,25 +198,19 @@ export class Query<TResult, TError> {
data = prevData
}

const isStale = this.config.staleTime === 0

// Try to determine if more data can be fetched
const canFetchMore = hasMorePages(this.config, data)

// Set data and mark it as cached
this.dispatch({
type: ActionType.Success,
data,
isStale,
canFetchMore,
})

this.rescheduleStaleTimeout()
}

clear(): void {
this.clearStaleTimeout()
this.clearCacheTimeout()
this.clearGcTimeout()
this.clearTimersObservers()
this.cancel()
delete this.queryCache.queries[this.queryHash]
Expand All @@ -304,12 +221,23 @@ export class Query<TResult, TError> {
return this.observers.some(observer => observer.config.enabled)
}

isStale(): boolean {
return this.observers.some(observer => observer.isStale())
}

isStaleByTime(staleTime = 0): boolean {
return (
!this.state.isSuccess || this.state.updatedAt + staleTime <= Date.now()
)
}

onWindowFocus(): void {
if (
this.state.isStale &&
this.observers.some(
observer =>
observer.config.enabled && observer.config.refetchOnWindowFocus
observer.isStale() &&
observer.config.enabled &&
observer.config.refetchOnWindowFocus
)
) {
this.fetch()
Expand All @@ -319,10 +247,11 @@ export class Query<TResult, TError> {

onOnline(): void {
if (
this.state.isStale &&
this.observers.some(
observer =>
observer.config.enabled && observer.config.refetchOnReconnect
observer.isStale() &&
observer.config.enabled &&
observer.config.refetchOnReconnect
)
) {
this.fetch()
Expand All @@ -348,7 +277,7 @@ export class Query<TResult, TError> {
this.observers.push(observer)

// Stop the query from being garbage collected
this.clearCacheTimeout()
this.clearGcTimeout()
}

unsubscribeObserver(observer: QueryObserver<TResult, TError>): void {
Expand All @@ -362,7 +291,7 @@ export class Query<TResult, TError> {
}
}

this.rescheduleGarbageCollection()
this.scheduleGc()
}

private async tryFetchData<T>(
Expand Down Expand Up @@ -639,12 +568,6 @@ function getDefaultState<TResult, TError>(

const hasInitialData = typeof initialData !== 'undefined'

const isStale =
!config.enabled ||
(typeof config.initialStale === 'function'
? config.initialStale()
: config.initialStale ?? !hasInitialData)

const initialStatus = hasInitialData
? QueryStatus.Success
: config.enabled
Expand All @@ -658,9 +581,8 @@ function getDefaultState<TResult, TError>(
isFetching: initialStatus === QueryStatus.Loading,
isFetchingMore: false,
failureCount: 0,
isStale,
data: initialData,
updatedAt: hasInitialData ? Date.now() : 0,
updatedAt: Date.now(),
canFetchMore: hasMorePages(config, initialData),
}
}
Expand All @@ -675,11 +597,6 @@ export function queryReducer<TResult, TError>(
...state,
failureCount: state.failureCount + 1,
}
case ActionType.MarkStale:
return {
...state,
isStale: true,
}
case ActionType.Fetch:
const status =
typeof state.data !== 'undefined'
Expand All @@ -698,7 +615,6 @@ export function queryReducer<TResult, TError>(
...getStatusProps(QueryStatus.Success),
data: action.data,
error: null,
isStale: action.isStale,
isFetched: true,
isFetching: false,
isFetchingMore: false,
Expand All @@ -714,7 +630,6 @@ export function queryReducer<TResult, TError>(
isFetched: true,
isFetching: false,
isFetchingMore: false,
isStale: true,
failureCount: state.failureCount + 1,
throwInErrorBoundary: true,
}
Expand Down
16 changes: 3 additions & 13 deletions src/core/queryCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export class QueryCache {
}
}

return query.invalidate()
return undefined
})
)
} catch (err) {
Expand Down Expand Up @@ -305,7 +305,7 @@ export class QueryCache {
let query
try {
query = this.buildQuery<TResult, TError>(queryKey, configWithoutRetry)
if (options?.force || query.state.isStale) {
if (options?.force || query.isStaleByTime(config.staleTime)) {
await query.fetch()
}
return query.state.data
Expand All @@ -314,14 +314,6 @@ export class QueryCache {
throw error
}
return
} finally {
if (query) {
// When prefetching, no observer is tied to the query,
// so to avoid immediate garbage collection of the still
// empty query, we wait with activating timeouts until
// the prefetch is done
query.activateTimeouts()
}
}
}

Expand All @@ -337,13 +329,11 @@ export class QueryCache {
return
}

const newQuery = this.buildQuery<TResult, TError>(queryKey, {
this.buildQuery<TResult, TError>(queryKey, {
initialStale: typeof config?.staleTime === 'undefined',
initialData: functionalUpdate(updater, undefined),
...config,
})

newQuery.activateTimeouts()
}
}

Expand Down
Loading