316 lines
8.7 KiB
TypeScript
316 lines
8.7 KiB
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'
|
|
|
|
function difference<T>(array1: Array<T>, array2: Array<T>): Array<T> {
|
|
const excludeSet = new Set(array2)
|
|
return array1.filter((x) => !excludeSet.has(x))
|
|
}
|
|
|
|
function replaceAt<T>(array: Array<T>, index: number, value: T): Array<T> {
|
|
const copy = array.slice(0)
|
|
copy[index] = value
|
|
return copy
|
|
}
|
|
|
|
type QueriesObserverListener = (result: Array<QueryObserverResult>) => void
|
|
|
|
type CombineFn<TCombinedResult> = (
|
|
result: Array<QueryObserverResult>,
|
|
) => TCombinedResult
|
|
|
|
export interface QueriesObserverOptions<
|
|
TCombinedResult = Array<QueryObserverResult>,
|
|
> {
|
|
combine?: CombineFn<TCombinedResult>
|
|
}
|
|
|
|
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 onSubscribe(): void {
|
|
if (this.listeners.size === 1) {
|
|
this.#observers.forEach((observer) => {
|
|
observer.subscribe((result) => {
|
|
this.#onUpdate(observer, result)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
protected onUnsubscribe(): void {
|
|
if (!this.listeners.size) {
|
|
this.destroy()
|
|
}
|
|
}
|
|
|
|
destroy(): void {
|
|
this.listeners = new Set()
|
|
this.#observers.forEach((observer) => {
|
|
observer.destroy()
|
|
})
|
|
}
|
|
|
|
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()
|
|
})
|
|
}
|
|
|
|
getCurrentResult(): Array<QueryObserverResult> {
|
|
return this.#result
|
|
}
|
|
|
|
getQueries() {
|
|
return this.#observers.map((observer) => observer.getCurrentQuery())
|
|
}
|
|
|
|
getObservers() {
|
|
return this.#observers
|
|
}
|
|
|
|
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>,
|
|
) {
|
|
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)
|
|
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.#combinedResult ||
|
|
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
|
|
}
|
|
|
|
#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 = replaceAt(this.#result, index, result)
|
|
this.#notify()
|
|
}
|
|
}
|
|
|
|
#notify(): void {
|
|
if (this.hasListeners()) {
|
|
const previousResult = this.#combinedResult
|
|
const newTracked = this.#trackResult(this.#result, this.#observerMatches)
|
|
const newResult = this.#combineResult(newTracked, this.#options?.combine)
|
|
|
|
if (previousResult !== newResult) {
|
|
notifyManager.batch(() => {
|
|
this.listeners.forEach((listener) => {
|
|
listener(this.#result)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type QueryObserverMatch = {
|
|
defaultedQueryOptions: DefaultedQueryObserverOptions
|
|
observer: QueryObserver
|
|
}
|