Skip to content
Draft
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
2 changes: 1 addition & 1 deletion docs/framework/react/guides/render-optimizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ React Query uses a technique called "structural sharing" to ensure that as many

### referential identity

The top level object returned from `useQuery`, `useInfiniteQuery`, `useMutation` and the Array returned from `useQueries` is **not referentially stable**. It will be a new reference on every render. However, the `data` properties returned from these hooks will be as stable as possible.
The top level object returned from `useQuery`, `useInfiniteQuery`, `useMutation` and the Array returned from `useQueries` is referentially stable unless React Query has triggered a re-render.

## tracked properties

Expand Down
119 changes: 112 additions & 7 deletions packages/query-core/src/mutationObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ export class MutationObserver<
> = undefined!
#currentMutation?: Mutation<TData, TError, TVariables, TOnMutateResult>
#mutateOptions?: MutateOptions<TData, TError, TVariables, TOnMutateResult>
#trackedProps = new Set<keyof MutationObserverResult>()
#lastTrackedResult?: MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
>
#resultProxyCache = new WeakMap<
MutationObserverResult<TData, TError, TVariables, TOnMutateResult>, // un-proxied result
MutationObserverResult<TData, TError, TVariables, TOnMutateResult> // proxied result
>()

constructor(
client: QueryClient,
Expand Down Expand Up @@ -102,9 +113,47 @@ export class MutationObserver<
onMutationUpdate(
action: Action<TData, TError, TVariables, TOnMutateResult>,
): void {
const prevResult = this.#currentResult as
| MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
| undefined

this.#updateResult()

this.#notify(action)
const shouldNotifyListeners = (): boolean => {
if (!prevResult) {
return true
}

const { notifyOnChangeProps } = this.options
const notifyOnChangePropsValue =
typeof notifyOnChangeProps === 'function'
? notifyOnChangeProps()
: notifyOnChangeProps

if (
notifyOnChangePropsValue === 'all' ||
(!notifyOnChangePropsValue && !this.#trackedProps.size)
) {
return true
}

const includedProps = new Set(
notifyOnChangePropsValue ?? this.#trackedProps,
)

if (this.options.throwOnError) {
includedProps.add('error')
}

return Object.keys(this.#currentResult).some((key) => {
const typedKey = key as keyof MutationObserverResult
const changed = this.#currentResult[typedKey] !== prevResult[typedKey]

return changed && includedProps.has(typedKey)
})
}

this.#notify(action, { listeners: shouldNotifyListeners() })
}

getCurrentResult(): MutationObserverResult<
Expand All @@ -116,13 +165,54 @@ export class MutationObserver<
return this.#currentResult
}

trackResult(
nextResult: MutationObserverResult<
TData,
TError,
TVariables,
TOnMutateResult
>,
onPropTracked?: (key: keyof MutationObserverResult) => void,
): MutationObserverResult<TData, TError, TVariables, TOnMutateResult> {
let resultProxy = this.#resultProxyCache.get(nextResult)

if (resultProxy) {
return resultProxy
}

if (this.#lastTrackedResult) {
if (shallowEqualObjects(this.#lastTrackedResult, nextResult)) {
resultProxy = this.#resultProxyCache.get(this.#lastTrackedResult)
}
}

if (!resultProxy) {
resultProxy = new Proxy(nextResult, {
get: (target, key) => {
this.trackProp(key as keyof MutationObserverResult)
onPropTracked?.(key as keyof MutationObserverResult)
return Reflect.get(target, key)
},
})
}

this.#resultProxyCache.set(nextResult, resultProxy)
this.#lastTrackedResult = nextResult

return resultProxy
}

trackProp(key: keyof MutationObserverResult) {
this.#trackedProps.add(key)
}

reset(): void {
// reset needs to remove the observer from the mutation because there is no way to "get it back"
// another mutate call will yield a new mutation!
this.#currentMutation?.removeObserver(this)
this.#currentMutation = undefined
this.#updateResult()
this.#notify()
this.#notify(undefined, { listeners: true })
}

mutate(
Expand All @@ -143,11 +233,15 @@ export class MutationObserver<
}

#updateResult(): void {
const prevResult = this.#currentResult as
| MutationObserverResult<TData, TError, TVariables, TOnMutateResult>
| undefined

const state =
this.#currentMutation?.state ??
getDefaultState<TData, TError, TVariables, TOnMutateResult>()

this.#currentResult = {
const nextResult = {
...state,
isPending: state.status === 'pending',
isSuccess: state.status === 'success',
Expand All @@ -156,9 +250,18 @@ export class MutationObserver<
mutate: this.mutate,
reset: this.reset,
} as MutationObserverResult<TData, TError, TVariables, TOnMutateResult>

if (shallowEqualObjects(nextResult, prevResult)) {
return
}

this.#currentResult = nextResult
}

#notify(action?: Action<TData, TError, TVariables, TOnMutateResult>): void {
#notify(
action?: Action<TData, TError, TVariables, TOnMutateResult>,
notifyOptions?: { listeners?: boolean },
): void {
notifyManager.batch(() => {
// First trigger the mutate callbacks
if (this.#mutateOptions && this.hasListeners()) {
Expand Down Expand Up @@ -219,9 +322,11 @@ export class MutationObserver<
}

// Then trigger the listeners
this.listeners.forEach((listener) => {
listener(this.#currentResult)
})
if (notifyOptions?.listeners) {
this.listeners.forEach((listener) => {
listener(this.#currentResult)
})
}
})
}
}
26 changes: 19 additions & 7 deletions packages/query-core/src/queriesObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,21 +185,33 @@ export class QueriesObserver<
(match) => match.defaultedQueryOptions.queryHash,
)

const prevResult = this.#lastResult

const getStableResult = (r: Array<QueryObserverResult>) =>
prevResult && shallowEqualObjects(prevResult, r) ? prevResult : r

const nextResult = getStableResult(result)

return [
result,
(r?: Array<QueryObserverResult>) => {
return this.#combineResult(r ?? result, combine, queryHashes)
},
() => {
return this.#trackResult(result, matches)
},
nextResult,
(r?: Array<QueryObserverResult>) =>
this.#combineResult(
(r && getStableResult(r)) ?? nextResult,
combine,
queryHashes,
),
() => this.#trackResult(nextResult, matches),
]
}

#trackResult(
result: Array<QueryObserverResult>,
matches: Array<QueryObserverMatch>,
) {
if (this.#lastResult && shallowEqualObjects(this.#lastResult, result)) {
return this.#lastResult
}

return matches.map((match, index) => {
const observerResult = result[index]!
return !match.defaultedQueryOptions.notifyOnChangeProps
Expand Down
66 changes: 45 additions & 21 deletions packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ export class QueryObserver<
#refetchIntervalId?: ManagedTimerId
#currentRefetchInterval?: number | false
#trackedProps = new Set<keyof QueryObserverResult>()
#lastTrackedResult?: QueryObserverResult<TData, TError>
#resultProxyCache = new WeakMap<
QueryObserverResult<TData, TError>, // un-proxied result
QueryObserverResult<TData, TError> // proxied result
>()

constructor(
client: QueryClient,
Expand Down Expand Up @@ -253,37 +258,56 @@ export class QueryObserver<
this.#currentResultOptions = this.options
this.#currentResultState = this.#currentQuery.state
}
return result
return this.#currentResult
}

getCurrentResult(): QueryObserverResult<TData, TError> {
return this.#currentResult
}

trackResult(
result: QueryObserverResult<TData, TError>,
nextResult: QueryObserverResult<TData, TError>,
onPropTracked?: (key: keyof QueryObserverResult) => void,
): QueryObserverResult<TData, TError> {
return new Proxy(result, {
get: (target, key) => {
this.trackProp(key as keyof QueryObserverResult)
onPropTracked?.(key as keyof QueryObserverResult)
if (key === 'promise') {
this.trackProp('data')
if (
!this.options.experimental_prefetchInRender &&
this.#currentThenable.status === 'pending'
) {
this.#currentThenable.reject(
new Error(
'experimental_prefetchInRender feature flag is not enabled',
),
)
let resultProxy = this.#resultProxyCache.get(nextResult)

if (resultProxy) {
return resultProxy
}

if (this.#lastTrackedResult) {
if (shallowEqualObjects(this.#lastTrackedResult, nextResult)) {
resultProxy = this.#resultProxyCache.get(this.#lastTrackedResult)
}
}

if (!resultProxy) {
resultProxy = new Proxy(nextResult, {
get: (target, key) => {
this.trackProp(key as keyof QueryObserverResult)
onPropTracked?.(key as keyof QueryObserverResult)
if (key === 'promise') {
this.trackProp('data')
if (
!this.options.experimental_prefetchInRender &&
this.#currentThenable.status === 'pending'
) {
this.#currentThenable.reject(
new Error(
'experimental_prefetchInRender feature flag is not enabled',
),
)
}
}
}
return Reflect.get(target, key)
},
})
return Reflect.get(target, key)
},
})
}

this.#resultProxyCache.set(nextResult, resultProxy)
this.#lastTrackedResult = nextResult

return resultProxy
}

trackProp(key: keyof QueryObserverResult) {
Expand Down
14 changes: 14 additions & 0 deletions packages/query-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@ export type NotifyOnChangeProps =
| undefined
| (() => Array<keyof InfiniteQueryObserverResult> | 'all' | undefined)

export type NotifyOnMutationChangeProps =
| Array<keyof MutationObserverResult>
| 'all'
| undefined
| (() => Array<keyof MutationObserverResult> | 'all' | undefined)

export interface QueryOptions<
TQueryFnData = unknown,
TError = DefaultError,
Expand Down Expand Up @@ -1153,6 +1159,14 @@ export interface MutationObserverOptions<
TOnMutateResult = unknown,
> extends MutationOptions<TData, TError, TVariables, TOnMutateResult> {
throwOnError?: boolean | ((error: TError) => boolean)
/**
* If set, the component will only re-render if any of the listed properties change.
* When set to `['data', 'error']`, the component will only re-render when the `data` or `error` properties change.
* When set to `'all'`, the component will re-render whenever a mutation is updated.
* When set to a function, the function will be executed to compute the list of properties.
* By default, access to properties will be tracked, and the component will only re-render when one of the tracked properties change.
*/
notifyOnChangeProps?: NotifyOnMutationChangeProps
}

export interface MutateOptions<
Expand Down
9 changes: 7 additions & 2 deletions packages/react-query/src/useMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function useMutation<
observer.setOptions(options)
}, [observer, options])

const result = React.useSyncExternalStore(
const r = React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
observer.subscribe(notifyManager.batchCalls(onStoreChange)),
Expand All @@ -49,6 +49,9 @@ export function useMutation<
() => observer.getCurrentResult(),
)

// Handle result property usage tracking
const result = !options.notifyOnChangeProps ? observer.trackResult(r) : r

const mutate = React.useCallback<
UseMutateFunction<TData, TError, TVariables, TOnMutateResult>
>(
Expand All @@ -65,5 +68,7 @@ export function useMutation<
throw result.error
}

return { ...result, mutate, mutateAsync: result.mutate }
return React.useMemo(() => {
return Object.assign(result, { mutate, mutateAsync: result.mutate })
}, [mutate, result])
}
Loading