@tanstack/query-core
Version:
The framework agnostic core that powers TanStack Query
418 lines (368 loc) • 13 kB
text/typescript
import { notifyManager } from './notifyManager'
import { QueryObserver } from './queryObserver'
import { Subscribable } from './subscribable'
import { replaceEqualDeep, shallowEqualObjects } from './utils'
import type {
DefaultedQueryObserverOptions,
QueryObserverOptions,
QueryObserverResult,
} from './types'
import type { QueryClient } from './queryClient'
/**
* Returns the items of `array1` that are not in `array2`.
* @param array1 - The items to filter.
* @param array2 - The items to exclude.
* @returns A new array with the items of `array1` that are not in `array2`.
*/
function difference<T>(array1: Array<T>, array2: Array<T>): Array<T> {
const excludeSet = new Set(array2)
return array1.filter((x) => !excludeSet.has(x))
}
type QueriesObserverListener = (result: Array<QueryObserverResult>) => void
type CombineFn<TCombinedResult> = (
result: Array<QueryObserverResult>,
) => TCombinedResult
/**
* Options for a `QueriesObserver` that apply to all of its queries at once.
*/
export interface QueriesObserverOptions<
TCombinedResult = Array<QueryObserverResult>,
> {
/**
* A function that combines the array of `QueryObserverResult`s (one per
* observed query) into a single value. The combined value is memoized and
* only recomputed when one of the underlying results, the query hashes, or
* the `combine` function itself changes.
*
* Defaults to returning the array of `QueryObserverResult`s unchanged.
*/
combine?: CombineFn<TCombinedResult>
}
/**
* A `QueriesObserver` watches an array of queries at once, exposing them as
* a single array of `QueryObserverResult`s (or, when a `combine` option is
* given, as a combined value derived from that array). It manages one
* internal `QueryObserver` per query, and is the primitive that framework
* adapters (e.g. `useQueries`) build their hooks on top of.
* @example
* ```ts
* const observer = new QueriesObserver(queryClient, [
* { queryKey: ['post', 1], queryFn: fetchPost },
* { queryKey: ['post', 2], queryFn: fetchPost },
* ])
*
* const unsubscribe = observer.subscribe((result) => {
* console.log(result)
* })
* ```
*/
export class QueriesObserver<
TCombinedResult = Array<QueryObserverResult>,
> extends Subscribable<QueriesObserverListener> {
#client: QueryClient
#result!: Array<QueryObserverResult>
#queries: Array<QueryObserverOptions>
#options?: QueriesObserverOptions<TCombinedResult>
#observers: Array<QueryObserver>
#combinedResult!: TCombinedResult
#lastCombine?: CombineFn<TCombinedResult>
#lastResult?: Array<QueryObserverResult>
#lastQueryHashes?: Array<string>
#observerMatches: Array<QueryObserverMatch> = []
constructor(
client: QueryClient,
queries: Array<QueryObserverOptions<any, any, any, any, any>>,
options?: QueriesObserverOptions<TCombinedResult>,
) {
super()
this.#client = client
this.#options = options
this.#queries = []
this.#observers = []
this.#result = []
this.setQueries(queries)
}
protected override onSubscribe(): void {
if (this.listeners.size === 1) {
this.#observers.forEach((observer) => {
observer.subscribe((result) => {
this.#onUpdate(observer, result)
})
})
}
}
protected override onUnsubscribe(): void {
if (!this.listeners.size) {
this.destroy()
}
}
/**
* Stops observing all queries: clears all listeners and destroys every
* underlying `QueryObserver` this observer manages.
*/
destroy(): void {
this.listeners = new Set()
this.#observers.forEach((observer) => {
observer.destroy()
})
}
/**
* Replaces the set of queries being observed. Existing `QueryObserver`s
* are reused for queries that match an already-observed query hash;
* observers for queries that are no longer present are destroyed, and new
* observers are created and subscribed to for newly added queries.
* @param queries - The options of the queries to observe.
* @param options - Replaces the observer's options, e.g. its `combine` function.
* @example
* ```ts
* observer.setQueries([
* { queryKey: ['post', 1], queryFn: fetchPost },
* { queryKey: ['post', 3], queryFn: fetchPost },
* ])
* ```
*/
setQueries(
queries: Array<QueryObserverOptions>,
options?: QueriesObserverOptions<TCombinedResult>,
): void {
this.#queries = queries
this.#options = options
if (process.env.NODE_ENV !== 'production') {
const queryHashes = queries.map(
(query) => this.#client.defaultQueryOptions(query).queryHash,
)
if (new Set(queryHashes).size !== queryHashes.length) {
console.warn(
'[QueriesObserver]: Duplicate Queries found. This might result in unexpected behavior.',
)
}
}
notifyManager.batch(() => {
const prevObservers = this.#observers
const newObserverMatches = this.#findMatchingObservers(this.#queries)
// set options for the new observers to notify of changes
newObserverMatches.forEach((match) =>
match.observer.setOptions(match.defaultedQueryOptions),
)
const newObservers = newObserverMatches.map((match) => match.observer)
const newResult = newObservers.map((observer) =>
observer.getCurrentResult(),
)
const hasLengthChange = prevObservers.length !== newObservers.length
const hasIndexChange = newObservers.some(
(observer, index) => observer !== prevObservers[index],
)
const hasStructuralChange = hasLengthChange || hasIndexChange
const hasResultChange = hasStructuralChange
? true
: newResult.some((result, index) => {
const prev = this.#result[index]
return !prev || !shallowEqualObjects(result, prev)
})
if (!hasStructuralChange && !hasResultChange) return
if (hasStructuralChange) {
this.#observerMatches = newObserverMatches
this.#observers = newObservers
}
this.#result = newResult
if (!this.hasListeners()) return
if (hasStructuralChange) {
difference(prevObservers, newObservers).forEach((observer) => {
observer.destroy()
})
difference(newObservers, prevObservers).forEach((observer) => {
observer.subscribe((result) => {
this.#onUpdate(observer, result)
})
})
}
this.#notify()
})
}
/**
* Returns the most recently computed array of `QueryObserverResult`s, one
* per observed query, in the same order as the queries passed to the
* constructor or `setQueries`.
* @returns The current results.
* @example
* ```ts
* const results = observer.getCurrentResult()
* const data = results.map((result) => result.data)
* ```
*/
getCurrentResult(): Array<QueryObserverResult> {
return this.#result
}
/**
* Returns the underlying `Query` instances currently being observed, in
* the same order as the queries passed to the constructor or `setQueries`.
* @returns The observed queries.
*/
getQueries() {
return this.#observers.map((observer) => observer.getCurrentQuery())
}
/**
* Returns the underlying `QueryObserver` instances this observer manages,
* in the same order as the queries passed to the constructor or
* `setQueries`.
* @returns The managed query observers.
*/
getObservers() {
return this.#observers
}
/**
* The `QueriesObserver` counterpart of {@link QueryObserver#getOptimisticResult} — computes
* the result for the given (already-defaulted) queries right now, synchronously. Called by
* framework adapters (e.g. `useQueries`) ahead of subscribing, returning a tuple of the raw
* per-query results, a function to compute the combined result from them, and a function to
* wrap the results for property-access tracking.
* @param queries - The defaulted options of the queries to compute the result for.
* @param combine - The `combine` function used by the returned `combineResult`, if any.
* @returns A tuple of the per-query results, a function that computes the combined result, and a
* function that returns the results wrapped for property-access tracking.
*/
getOptimisticResult(
queries: Array<QueryObserverOptions>,
combine: CombineFn<TCombinedResult> | undefined,
): [
rawResult: Array<QueryObserverResult>,
combineResult: (r?: Array<QueryObserverResult>) => TCombinedResult,
trackResult: () => Array<QueryObserverResult>,
] {
const matches = this.#findMatchingObservers(queries)
const result = matches.map((match) =>
match.observer.getOptimisticResult(match.defaultedQueryOptions),
)
const queryHashes = matches.map(
(match) => match.defaultedQueryOptions.queryHash,
)
return [
result,
(r?: Array<QueryObserverResult>) => {
return this.#combineResult(r ?? result, combine, queryHashes)
},
() => {
return this.#trackResult(result, matches)
},
]
}
#trackResult(
result: Array<QueryObserverResult>,
matches: Array<QueryObserverMatch>,
) {
const trackedProps = new Set<keyof QueryObserverResult>()
return matches.map((match, index) => {
const observerResult = result[index]!
return !match.defaultedQueryOptions.notifyOnChangeProps
? match.observer.trackResult(observerResult, (accessedProp) => {
// track property on all observers to ensure proper (synchronized) tracking (#7000)
if (!trackedProps.has(accessedProp)) {
trackedProps.add(accessedProp)
matches.forEach((m) => {
m.observer.trackProp(accessedProp)
})
}
})
: observerResult
})
}
#combineResult(
input: Array<QueryObserverResult>,
combine: CombineFn<TCombinedResult> | undefined,
queryHashes?: Array<string>,
): TCombinedResult {
if (combine) {
const lastHashes = this.#lastQueryHashes
const queryHashesChanged =
queryHashes !== undefined &&
lastHashes !== undefined &&
(lastHashes.length !== queryHashes.length ||
queryHashes.some((hash, i) => hash !== lastHashes[i]))
if (
this.#result !== this.#lastResult ||
queryHashesChanged ||
combine !== this.#lastCombine
) {
this.#lastCombine = combine
this.#lastResult = this.#result
if (queryHashes !== undefined) {
this.#lastQueryHashes = queryHashes
}
this.#combinedResult = replaceEqualDeep(
this.#combinedResult,
combine(input),
)
}
return this.#combinedResult
}
return input as any
}
#shouldSkipCombine(): boolean {
return (
!this.#options?.combine ||
this.#observers.some((observer, index) => {
return (
observer.options.suspense && this.#result[index]?.data === undefined
)
})
)
}
#findMatchingObservers(
queries: Array<QueryObserverOptions>,
): Array<QueryObserverMatch> {
const prevObserversMap = new Map<string, Array<QueryObserver>>()
this.#observers.forEach((observer) => {
const key = observer.options.queryHash
if (!key) return
const previousObservers = prevObserversMap.get(key)
if (previousObservers) {
previousObservers.push(observer)
} else {
prevObserversMap.set(key, [observer])
}
})
const observers: Array<QueryObserverMatch> = []
queries.forEach((options) => {
const defaultedOptions = this.#client.defaultQueryOptions(options)
const match = prevObserversMap.get(defaultedOptions.queryHash)?.shift()
const observer =
match ?? new QueryObserver(this.#client, defaultedOptions)
observers.push({
defaultedQueryOptions: defaultedOptions,
observer,
})
})
return observers
}
#onUpdate(observer: QueryObserver, result: QueryObserverResult): void {
const index = this.#observers.indexOf(observer)
if (index !== -1) {
this.#result = this.#result.slice()
this.#result[index] = result
this.#notify()
}
}
#notify(): void {
if (this.hasListeners()) {
const shouldSkipCombine = this.#shouldSkipCombine()
const previousResult = this.#combinedResult
const newResult = shouldSkipCombine
? previousResult
: this.#combineResult(
this.#trackResult(this.#result, this.#observerMatches),
this.#options?.combine,
)
if (shouldSkipCombine || previousResult !== newResult) {
notifyManager.batch(() => {
this.listeners.forEach((listener) => {
listener(this.#result)
})
})
}
}
}
}
type QueryObserverMatch = {
defaultedQueryOptions: DefaultedQueryObserverOptions
observer: QueryObserver
}