From 1fd39ec408c3b86aa72fede51e01312fd4b8ac5d Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 1 Apr 2023 12:49:20 +0200 Subject: [PATCH 01/12] attempt at adding combine on observer level (doesn't work) --- packages/query-core/src/index.ts | 1 + packages/query-core/src/queriesObserver.ts | 35 ++++++++++++++---- packages/react-query/src/useQueries.ts | 43 +++++++++++++++------- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 23a9b44681..9c7bf48073 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -36,3 +36,4 @@ export type { DehydratedState, HydrateOptions, } from './hydration' +export type { QueriesObserverOptions } from './queriesObserver' diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index bff26d6b40..15a72d100a 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -21,13 +21,26 @@ function replaceAt(array: T[], index: number, value: T): T[] { type QueriesObserverListener = (result: QueryObserverResult[]) => void -export class QueriesObserver extends Subscribable { +export interface QueriesObserverOptions< + TCombinedResult = QueryObserverResult[], +> { + combine?: (result: QueryObserverResult[]) => TCombinedResult +} + +export class QueriesObserver< + TCombinedResult = QueryObserverResult[], +> extends Subscribable { #client: QueryClient #result: QueryObserverResult[] #queries: QueryObserverOptions[] #observers: QueryObserver[] + #options?: QueriesObserverOptions - constructor(client: QueryClient, queries?: QueryObserverOptions[]) { + constructor( + client: QueryClient, + queries: QueryObserverOptions[], + options?: QueriesObserverOptions, + ) { super() this.#client = client @@ -35,9 +48,7 @@ export class QueriesObserver extends Subscribable { this.#result = [] this.#observers = [] - if (queries) { - this.setQueries(queries) - } + this.setQueries(queries, options) } protected onSubscribe(): void { @@ -65,9 +76,11 @@ export class QueriesObserver extends Subscribable { setQueries( queries: QueryObserverOptions[], + options?: QueriesObserverOptions, notifyOptions?: NotifyOptions, ): void { this.#queries = queries + this.#options = options notifyManager.batch(() => { const prevObservers = this.#observers @@ -124,12 +137,18 @@ export class QueriesObserver extends Subscribable { return this.#observers } - getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] { - return this.#findMatchingObservers(queries).map((match) => - match.observer.getOptimisticResult(match.defaultedQueryOptions), + getOptimisticResult(queries: QueryObserverOptions[]): TCombinedResult { + return this.#combineResult( + this.#findMatchingObservers(queries).map((match) => + match.observer.getOptimisticResult(match.defaultedQueryOptions), + ), ) } + #combineResult(input: QueryObserverResult[]): TCombinedResult { + return (this.#options?.combine?.(input) ?? input) as TCombinedResult + } + #findMatchingObservers( queries: QueryObserverOptions[], ): QueryObserverMatch[] { diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index f46c57288c..91a47a41e1 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -7,6 +7,7 @@ import type { QueriesPlaceholderDataFunction, QueryClient, DefaultError, + QueriesObserverOptions, } from '@tanstack/query-core' import { notifyManager, QueriesObserver } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' @@ -156,21 +157,26 @@ export type QueriesResults< : // Fallback UseQueryResult[] -export function useQueries( +export function useQueries< + T extends any[], + TCombinedResult = QueriesResults, +>( { queries, + ...options }: { queries: readonly [...QueriesOptions] + combine?: (result: QueriesResults) => TCombinedResult }, queryClient?: QueryClient, -): QueriesResults { +): TCombinedResult { const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() const defaultedQueries = React.useMemo( () => - queries.map((options) => { - const defaultedOptions = client.defaultQueryOptions(options) + queries.map((opts) => { + const defaultedOptions = client.defaultQueryOptions(opts) // Make sure the results are already in fetching state before subscribing or updating options defaultedOptions._optimisticResults = isRestoring @@ -183,7 +189,12 @@ export function useQueries( ) const [observer] = React.useState( - () => new QueriesObserver(client, defaultedQueries), + () => + new QueriesObserver( + client, + defaultedQueries, + options as QueriesObserverOptions, + ), ) const optimisticResult = observer.getOptimisticResult(defaultedQueries) @@ -203,8 +214,14 @@ export function useQueries( React.useEffect(() => { // Do not notify on updates because of changes in the options because // these changes should already be reflected in the optimistic result. - observer.setQueries(defaultedQueries, { listeners: false }) - }, [defaultedQueries, observer]) + observer.setQueries( + defaultedQueries, + options as QueriesObserverOptions, + { + listeners: false, + }, + ) + }, [defaultedQueries, options, observer]) const errorResetBoundary = useQueryErrorResetBoundary() @@ -221,14 +238,14 @@ export function useQueries( const suspensePromises = shouldAtLeastOneSuspend ? optimisticResult.flatMap((result, index) => { - const options = defaultedQueries[index] + const opts = defaultedQueries[index] const queryObserver = observer.getObservers()[index] - if (options && queryObserver) { - if (shouldSuspend(options, result, isRestoring)) { - return fetchOptimistic(options, queryObserver, errorResetBoundary) + if (opts && queryObserver) { + if (shouldSuspend(opts, result, isRestoring)) { + return fetchOptimistic(opts, queryObserver, errorResetBoundary) } else if (willFetch(result, isRestoring)) { - void fetchOptimistic(options, queryObserver, errorResetBoundary) + void fetchOptimistic(opts, queryObserver, errorResetBoundary) } } return [] @@ -253,5 +270,5 @@ export function useQueries( throw firstSingleResultWhichShouldThrow.error } - return optimisticResult as QueriesResults + return optimisticResult } From e94617180e51ed55ac64709fd6ec2e2728f65721 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 2 Apr 2023 10:40:14 +0200 Subject: [PATCH 02/12] feat(useQueries): combine adapt getOptimisticResult to return both the result array and a combined result getter --- packages/query-core/src/queriesObserver.ts | 25 +++++++++++++------ packages/react-query/src/useQueries.ts | 5 ++-- .../src/__tests__/createQueries.test.tsx | 2 +- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 15a72d100a..f3e4c3c893 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -8,6 +8,7 @@ import type { QueryClient } from './queryClient' import type { NotifyOptions } from './queryObserver' import { QueryObserver } from './queryObserver' import { Subscribable } from './subscribable' +import { replaceEqualDeep } from './utils' function difference(array1: T[], array2: T[]): T[] { return array1.filter((x) => array2.indexOf(x) === -1) @@ -35,6 +36,7 @@ export class QueriesObserver< #queries: QueryObserverOptions[] #observers: QueryObserver[] #options?: QueriesObserverOptions + #combinedResult?: TCombinedResult constructor( client: QueryClient, @@ -125,8 +127,8 @@ export class QueriesObserver< }) } - getCurrentResult(): QueryObserverResult[] { - return this.#result + getCurrentResult(): TCombinedResult | undefined { + return this.#combinedResult } getQueries() { @@ -137,16 +139,23 @@ export class QueriesObserver< return this.#observers } - getOptimisticResult(queries: QueryObserverOptions[]): TCombinedResult { - return this.#combineResult( - this.#findMatchingObservers(queries).map((match) => - match.observer.getOptimisticResult(match.defaultedQueryOptions), - ), + getOptimisticResult( + queries: QueryObserverOptions[], + ): [QueryObserverResult[], () => TCombinedResult] { + const result = this.#findMatchingObservers(queries).map((match) => + match.observer.getOptimisticResult(match.defaultedQueryOptions), ) + + return [result, () => this.#combineResult(result)] } #combineResult(input: QueryObserverResult[]): TCombinedResult { - return (this.#options?.combine?.(input) ?? input) as TCombinedResult + const newResult = (this.#options?.combine?.(input) ?? + input) as TCombinedResult + + this.#combinedResult = replaceEqualDeep(this.#combinedResult, newResult) + + return this.#combinedResult } #findMatchingObservers( diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 91a47a41e1..48127be006 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -197,7 +197,8 @@ export function useQueries< ), ) - const optimisticResult = observer.getOptimisticResult(defaultedQueries) + const [optimisticResult, getCombinedResult] = + observer.getOptimisticResult(defaultedQueries) React.useSyncExternalStore( React.useCallback( @@ -270,5 +271,5 @@ export function useQueries< throw firstSingleResultWhichShouldThrow.error } - return optimisticResult + return getCombinedResult() } diff --git a/packages/solid-query/src/__tests__/createQueries.test.tsx b/packages/solid-query/src/__tests__/createQueries.test.tsx index db16ceb6cb..49cd1cb532 100644 --- a/packages/solid-query/src/__tests__/createQueries.test.tsx +++ b/packages/solid-query/src/__tests__/createQueries.test.tsx @@ -743,7 +743,7 @@ describe('useQueries', () => { const QueriesObserverSpy = vi .spyOn(QueriesObserverModule, 'QueriesObserver') .mockImplementation((fn) => { - return new QueriesObserverMock(fn) + return new QueriesObserverMock(fn, []) }) function Queries() { From 539417248511b0da145da91af89656ae675dad7e Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 2 Apr 2023 10:52:45 +0200 Subject: [PATCH 03/12] feat(useQueries): combine make sure combinedResult stays in sync with result --- packages/query-core/src/queriesObserver.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index f3e4c3c893..70dbb45807 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -32,11 +32,11 @@ export class QueriesObserver< TCombinedResult = QueryObserverResult[], > extends Subscribable { #client: QueryClient - #result: QueryObserverResult[] + #result!: QueryObserverResult[] #queries: QueryObserverOptions[] #observers: QueryObserver[] #options?: QueriesObserverOptions - #combinedResult?: TCombinedResult + #combinedResult!: TCombinedResult constructor( client: QueryClient, @@ -47,12 +47,17 @@ export class QueriesObserver< this.#client = client this.#queries = [] - this.#result = [] this.#observers = [] + this.#setResult([]) this.setQueries(queries, options) } + #setResult(value: QueryObserverResult[]) { + this.#result = value + this.#combinedResult = this.#combineResult(value) + } + protected onSubscribe(): void { if (this.listeners.length === 1) { this.#observers.forEach((observer) => { @@ -107,7 +112,7 @@ export class QueriesObserver< } this.#observers = newObservers - this.#result = newResult + this.#setResult(newResult) if (!this.hasListeners()) { return @@ -127,7 +132,7 @@ export class QueriesObserver< }) } - getCurrentResult(): TCombinedResult | undefined { + getCurrentResult(): TCombinedResult { return this.#combinedResult } @@ -153,9 +158,7 @@ export class QueriesObserver< const newResult = (this.#options?.combine?.(input) ?? input) as TCombinedResult - this.#combinedResult = replaceEqualDeep(this.#combinedResult, newResult) - - return this.#combinedResult + return replaceEqualDeep(this.#combinedResult, newResult) } #findMatchingObservers( @@ -220,7 +223,7 @@ export class QueriesObserver< #onUpdate(observer: QueryObserver, result: QueryObserverResult): void { const index = this.#observers.indexOf(observer) if (index !== -1) { - this.#result = replaceAt(this.#result, index, result) + this.#setResult(replaceAt(this.#result, index, result)) this.#notify() } } From 00bcd2a3caf1257a74bbbca22ad85ff08c671c8d Mon Sep 17 00:00:00 2001 From: Damian Osipiuk Date: Tue, 11 Apr 2023 23:05:26 +0200 Subject: [PATCH 04/12] feat(vue-query): combine results for useQueries hook --- .../src/__tests__/useQueries.test.ts | 36 +++++++++++++++ packages/vue-query/src/useQueries.ts | 46 ++++++++++++++----- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/packages/vue-query/src/__tests__/useQueries.test.ts b/packages/vue-query/src/__tests__/useQueries.test.ts index 106d84ac39..890b0117de 100644 --- a/packages/vue-query/src/__tests__/useQueries.test.ts +++ b/packages/vue-query/src/__tests__/useQueries.test.ts @@ -217,4 +217,40 @@ describe('useQueries', () => { expect(useQueryClient).toHaveBeenCalledTimes(0) }) + + test('should combine queries', async () => { + const firstResult = 'first result' + const secondResult = 'second result' + + const queryClient = new QueryClient() + const queries = [ + { + queryKey: ['key41'], + queryFn: getSimpleFetcherWithReturnData(firstResult), + }, + { + queryKey: ['key42'], + queryFn: getSimpleFetcherWithReturnData(secondResult), + }, + ] + + const queriesResult = useQueries( + { + queries, + combine: (results) => { + return { + combined: true, + res: results.map((res) => res.data), + } + }, + }, + queryClient, + ) + await flushPromises() + + expect(queriesResult.value).toMatchObject({ + combined: true, + res: [firstResult, secondResult], + }) + }) }) diff --git a/packages/vue-query/src/useQueries.ts b/packages/vue-query/src/useQueries.ts index 9d29fa5bdd..f7ed69763d 100644 --- a/packages/vue-query/src/useQueries.ts +++ b/packages/vue-query/src/useQueries.ts @@ -3,6 +3,7 @@ import { QueriesObserver } from '@tanstack/query-core' import type { QueriesPlaceholderDataFunction, QueryKey, + QueriesObserverOptions, } from '@tanstack/query-core' import type { Ref } from 'vue-demi' import { computed, onScopeDispose, readonly, ref, watch } from 'vue-demi' @@ -145,19 +146,24 @@ export type UseQueriesResults< type UseQueriesOptionsArg = readonly [...UseQueriesOptions] -export function useQueries( +export function useQueries< + T extends any[], + TCombinedResult = UseQueriesResults, +>( { queries, + ...options }: { queries: MaybeRefDeep> + combine?: (result: UseQueriesResults) => TCombinedResult }, queryClient?: QueryClient, -): Readonly>> { +): Readonly> { const client = queryClient || useQueryClient() const defaultedQueries = computed(() => - cloneDeepUnref(queries).map((options) => { - const defaulted = client.defaultQueryOptions(options) + cloneDeepUnref(queries).map((queryOptions) => { + const defaulted = client.defaultQueryOptions(queryOptions) defaulted._optimisticResults = client.isRestoring.value ? 'isRestoring' : 'optimistic' @@ -166,8 +172,15 @@ export function useQueries( }), ) - const observer = new QueriesObserver(client, defaultedQueries.value) - const state = ref(observer.getCurrentResult()) + const observer = new QueriesObserver( + client, + defaultedQueries.value, + options as QueriesObserverOptions, + ) + const [, getCombinedResult] = observer.getOptimisticResult( + defaultedQueries.value, + ) + const state = ref(getCombinedResult()) as Ref const unsubscribe = ref(() => { // noop @@ -178,20 +191,29 @@ export function useQueries( (isRestoring) => { if (!isRestoring) { unsubscribe.value() - unsubscribe.value = observer.subscribe((result) => { - state.value.splice(0, result.length, ...result) + unsubscribe.value = observer.subscribe(() => { + const [, getCombinedResultRestoring] = observer.getOptimisticResult( + defaultedQueries.value, + ) + state.value = getCombinedResultRestoring() }) // Subscription would not fire for persisted results - state.value = observer.getOptimisticResult(defaultedQueries.value) + const [, getCombinedResultPersisted] = observer.getOptimisticResult( + defaultedQueries.value, + ) + state.value = getCombinedResultPersisted() } }, { immediate: true }, ) watch( - defaultedQueries, + [defaultedQueries], () => { - observer.setQueries(defaultedQueries.value) + observer.setQueries( + defaultedQueries.value, + options as QueriesObserverOptions, + ) state.value = observer.getCurrentResult() }, { deep: true }, @@ -201,5 +223,5 @@ export function useQueries( unsubscribe.value() }) - return readonly(state) as Readonly>> + return readonly(state) as Readonly> } From b2d3666514b35fcdde8e1dedd50988aef86560ed Mon Sep 17 00:00:00 2001 From: Lachlan Collins <1667261+lachlancollins@users.noreply.github.com> Date: Mon, 1 May 2023 20:50:42 +1000 Subject: [PATCH 05/12] Add new options to svelte-query --- packages/svelte-query/src/createQueries.ts | 39 ++++++++++++++-------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/svelte-query/src/createQueries.ts b/packages/svelte-query/src/createQueries.ts index 868670ae01..ac5e0de48c 100644 --- a/packages/svelte-query/src/createQueries.ts +++ b/packages/svelte-query/src/createQueries.ts @@ -5,6 +5,7 @@ import type { QueriesPlaceholderDataFunction, QueryObserverResult, DefaultError, + QueriesObserverOptions, } from '@tanstack/query-core' import { notifyManager, QueriesObserver } from '@tanstack/query-core' @@ -150,44 +151,56 @@ export type QueriesResults< : // Fallback QueryObserverResult[] -export type CreateQueriesResult = Readable> - -export function createQueries( +export function createQueries< + T extends any[], + TCombinedResult = QueriesResults, +>( { queries, + ...options }: { queries: WritableOrVal<[...QueriesOptions]> + combine?: (result: QueriesResults) => TCombinedResult }, queryClient?: QueryClient, -): CreateQueriesResult { +): Readable { const client = useQueryClient(queryClient) // const isRestoring = useIsRestoring() const queriesStore = isWritable(queries) ? queries : writable(queries) const defaultedQueriesStore = derived(queriesStore, ($queries) => { - return $queries.map((options) => { - const defaultedOptions = client.defaultQueryOptions(options) + return $queries.map((opts) => { + const defaultedOptions = client.defaultQueryOptions(opts) // Make sure the results are already in fetching state before subscribing or updating options defaultedOptions._optimisticResults = 'optimistic' return defaultedOptions }) }) - const observer = new QueriesObserver(client, get(defaultedQueriesStore)) + const observer = new QueriesObserver( + client, + get(defaultedQueriesStore), + options as QueriesObserverOptions, + ) defaultedQueriesStore.subscribe(($defaultedQueries) => { // Do not notify on updates because of changes in the options because // these changes should already be reflected in the optimistic result. - observer.setQueries($defaultedQueries, { listeners: false }) + observer.setQueries( + $defaultedQueries, + options as QueriesObserverOptions, + { listeners: false }, + ) }) - const { subscribe } = readable( - observer.getOptimisticResult(get(defaultedQueriesStore)) as any, - (set) => { - return observer.subscribe(notifyManager.batchCalls(set)) - }, + const [, getCombinedResult] = observer.getOptimisticResult( + get(defaultedQueriesStore), ) + const { subscribe } = readable(getCombinedResult() as any, (set) => { + return observer.subscribe(notifyManager.batchCalls(set)) + }) + return { subscribe } } From 51b8d42d99c488f368acffc307c33d3245d10acc Mon Sep 17 00:00:00 2001 From: Aryan Deora Date: Tue, 2 May 2023 00:13:51 -0400 Subject: [PATCH 06/12] Add new options to solid-query --- packages/solid-query/src/createQueries.ts | 44 ++++++++++++++++------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/solid-query/src/createQueries.ts b/packages/solid-query/src/createQueries.ts index ac94422099..0faa242834 100644 --- a/packages/solid-query/src/createQueries.ts +++ b/packages/solid-query/src/createQueries.ts @@ -3,11 +3,12 @@ import type { QueryFunction, QueryKey, DefaultError, + QueriesObserverOptions, } from '@tanstack/query-core' import { notifyManager, QueriesObserver } from '@tanstack/query-core' import type { QueryClient } from './QueryClient' import type { Accessor } from 'solid-js' -import { createComputed, onCleanup, onMount } from 'solid-js' +import { createComputed, onCleanup } from 'solid-js' import { createStore, unwrap } from 'solid-js/store' import { useQueryClient } from './QueryClientProvider' import type { CreateQueryResult, SolidQueryOptions } from './types' @@ -148,12 +149,16 @@ export type QueriesResults< : // Fallback CreateQueryResult[] -export function createQueries( +export function createQueries< + T extends any[], + TCombinedResult = QueriesResults, +>( queriesOptions: Accessor<{ queries: readonly [...QueriesOptions] + combine?: (result: QueriesResults) => TCombinedResult }>, queryClient?: Accessor, -): QueriesResults { +): TCombinedResult { const client = useQueryClient(queryClient?.()) const defaultedQueries = queriesOptions().queries.map((options) => { @@ -162,32 +167,45 @@ export function createQueries( return defaultedOptions }) - const observer = new QueriesObserver(client, defaultedQueries) + const observer = new QueriesObserver( + client, + defaultedQueries, + queriesOptions().combine + ? ({ + combine: queriesOptions().combine, + } as QueriesObserverOptions) + : undefined, + ) - const [state, setState] = createStore( - observer.getOptimisticResult(defaultedQueries), + // @ts-expect-error - Types issue with solid-js createStore + const [state, setState] = createStore( + observer.getOptimisticResult(defaultedQueries)[1](), ) const unsubscribe = observer.subscribe((result) => { notifyManager.batchCalls(() => { - setState(unwrap(result)) + setState(unwrap(result) as unknown as TCombinedResult) })() }) onCleanup(unsubscribe) - onMount(() => { - observer.setQueries(defaultedQueries, { listeners: false }) - }) - createComputed(() => { const updatedQueries = queriesOptions().queries.map((options) => { const defaultedOptions = client.defaultQueryOptions(options) defaultedOptions._optimisticResults = 'optimistic' return defaultedOptions }) - observer.setQueries(updatedQueries) + observer.setQueries( + updatedQueries, + queriesOptions().combine + ? ({ + combine: queriesOptions().combine, + } as QueriesObserverOptions) + : undefined, + { listeners: false }, + ) }) - return state as QueriesResults + return state as TCombinedResult } From 8b6533ce8de99de52c8b46aed0989aee245434ab Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 13 May 2023 21:33:29 +0200 Subject: [PATCH 07/12] fix: enable property tracking for useQueries --- packages/query-core/src/queriesObserver.ts | 26 +++++++--- .../src/__tests__/useQueries.test.tsx | 50 ++++++++++++++++++- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index e7f3accecc..18ffdd085c 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -147,18 +147,32 @@ export class QueriesObserver< getOptimisticResult( queries: QueryObserverOptions[], ): [QueryObserverResult[], () => TCombinedResult] { - const result = this.#findMatchingObservers(queries).map((match) => + const matches = this.#findMatchingObservers(queries) + const result = matches.map((match) => match.observer.getOptimisticResult(match.defaultedQueryOptions), ) - return [result, () => this.#combineResult(result)] + return [ + result, + () => { + return this.#combineResult( + matches.map((match, index) => { + const observerResult = result[index]! + return !match.defaultedQueryOptions.notifyOnChangeProps + ? match.observer.trackResult(observerResult) + : observerResult + }), + ) + }, + ] } #combineResult(input: QueryObserverResult[]): TCombinedResult { - const newResult = (this.#options?.combine?.(input) ?? - input) as TCombinedResult - - return replaceEqualDeep(this.#combinedResult, newResult) + const combine = this.#options?.combine + if (combine) { + return replaceEqualDeep(this.#combinedResult, combine(input)) + } + return input as any } #findMatchingObservers( diff --git a/packages/react-query/src/__tests__/useQueries.test.tsx b/packages/react-query/src/__tests__/useQueries.test.tsx index f86b9d2301..a1abef673a 100644 --- a/packages/react-query/src/__tests__/useQueries.test.tsx +++ b/packages/react-query/src/__tests__/useQueries.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react' +import { fireEvent, render, waitFor } from '@testing-library/react' import * as React from 'react' import { ErrorBoundary } from 'react-error-boundary' @@ -71,6 +71,54 @@ describe('useQueries', () => { expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) }) + it('should track results', async () => { + const key1 = queryKey() + const results: UseQueryResult[][] = [] + let count = 0 + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(10) + count++ + return count + }, + }, + ], + }) + results.push(result) + + return ( +
+
data: {String(result[0].data ?? 'null')}
+ +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('data: 1')) + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject([{ data: undefined }]) + expect(results[1]).toMatchObject([{ data: 1 }]) + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await waitFor(() => rendered.getByText('data: 2')) + + console.log(results) + + // only one render for data update, no render for isFetching transition + expect(results.length).toBe(3) + + expect(results[2]).toMatchObject([{ data: 2 }]) + }) + it('handles type parameter - tuple of tuples', async () => { const key1 = queryKey() const key2 = queryKey() From 0a959249f01f597318d048d871b46a0d74e3f4a1 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 13 May 2023 21:52:19 +0200 Subject: [PATCH 08/12] fix: move property tracking to react layer --- packages/query-core/src/queriesObserver.ts | 23 +++++++++++++--------- packages/react-query/src/useQueries.ts | 4 ++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 18ffdd085c..6ca8337802 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -146,7 +146,11 @@ export class QueriesObserver< getOptimisticResult( queries: QueryObserverOptions[], - ): [QueryObserverResult[], () => TCombinedResult] { + ): [ + rawResult: QueryObserverResult[], + combineResult: (r?: QueryObserverResult[]) => TCombinedResult, + trackResult: () => QueryObserverResult[], + ] { const matches = this.#findMatchingObservers(queries) const result = matches.map((match) => match.observer.getOptimisticResult(match.defaultedQueryOptions), @@ -154,15 +158,16 @@ export class QueriesObserver< return [ result, + (r?: QueryObserverResult[]) => { + return this.#combineResult(r ?? result) + }, () => { - return this.#combineResult( - matches.map((match, index) => { - const observerResult = result[index]! - return !match.defaultedQueryOptions.notifyOnChangeProps - ? match.observer.trackResult(observerResult) - : observerResult - }), - ) + return matches.map((match, index) => { + const observerResult = result[index]! + return !match.defaultedQueryOptions.notifyOnChangeProps + ? match.observer.trackResult(observerResult) + : observerResult + }) }, ] } diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 15e8560a14..50843fc5a4 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -205,7 +205,7 @@ export function useQueries< ), ) - const [optimisticResult, getCombinedResult] = + const [optimisticResult, getCombinedResult, trackResult] = observer.getOptimisticResult(defaultedQueries) React.useSyncExternalStore( @@ -270,5 +270,5 @@ export function useQueries< throw firstSingleResultWhichShouldThrow.error } - return getCombinedResult() + return getCombinedResult(trackResult()) } From ef14f0308521636ec17f65b76df993d3b03f110e Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 13 May 2023 21:58:39 +0200 Subject: [PATCH 09/12] chore: remove logging --- packages/react-query/src/__tests__/useQueries.test.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react-query/src/__tests__/useQueries.test.tsx b/packages/react-query/src/__tests__/useQueries.test.tsx index a1abef673a..1a87996e41 100644 --- a/packages/react-query/src/__tests__/useQueries.test.tsx +++ b/packages/react-query/src/__tests__/useQueries.test.tsx @@ -111,8 +111,6 @@ describe('useQueries', () => { await waitFor(() => rendered.getByText('data: 2')) - console.log(results) - // only one render for data update, no render for isFetching transition expect(results.length).toBe(3) From c65287a6ba6b921aca049bed285626c97d5aa771 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 13 May 2023 21:59:34 +0200 Subject: [PATCH 10/12] chore: remove unnecessary type assertion --- packages/solid-query/src/createQueries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solid-query/src/createQueries.ts b/packages/solid-query/src/createQueries.ts index 0faa242834..64889f80c1 100644 --- a/packages/solid-query/src/createQueries.ts +++ b/packages/solid-query/src/createQueries.ts @@ -207,5 +207,5 @@ export function createQueries< ) }) - return state as TCombinedResult + return state } From ef49355f8a9515ec6fbd3d4b9ca47d30cd6d4b97 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 15 May 2023 09:07:22 +0200 Subject: [PATCH 11/12] test: tests for combined data --- .../src/__tests__/useQueries.test.tsx | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/packages/react-query/src/__tests__/useQueries.test.tsx b/packages/react-query/src/__tests__/useQueries.test.tsx index 1a87996e41..8ddfa512be 100644 --- a/packages/react-query/src/__tests__/useQueries.test.tsx +++ b/packages/react-query/src/__tests__/useQueries.test.tsx @@ -848,4 +848,152 @@ describe('useQueries', () => { await waitFor(() => rendered.getByText('data: custom client')) }) + + it('should combine queries', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const queries = useQueries( + { + queries: [ + { + queryKey: key1, + queryFn: () => Promise.resolve('first result'), + }, + { + queryKey: key2, + queryFn: () => Promise.resolve('second result'), + }, + ], + combine: (results) => { + return { + combined: true, + res: results.map((res) => res.data).join(','), + } + }, + }, + queryClient, + ) + + return ( +
+
+ data: {String(queries.combined)} {queries.res} +
+
+ ) + } + + const rendered = render() + + await waitFor(() => + rendered.getByText('data: true first result,second result'), + ) + }) + + it('should track property access through combine function', async () => { + const key1 = queryKey() + const key2 = queryKey() + let count = 0 + const results: Array = [] + + function Page() { + const queries = useQueries( + { + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(10) + return Promise.resolve('first result ' + count) + }, + }, + { + queryKey: key2, + queryFn: async () => { + await sleep(20) + return Promise.resolve('second result ' + count) + }, + }, + ], + combine: (queryResults) => { + return { + combined: true, + refetch: () => queryResults.forEach((res) => res.refetch()), + res: queryResults + .flatMap((res) => (res.data ? [res.data] : [])) + .join(','), + } + }, + }, + queryClient, + ) + + results.push(queries) + + return ( +
+
+ data: {String(queries.combined)} {queries.res} +
+ +
+ ) + } + + const rendered = render() + + await waitFor(() => + rendered.getByText('data: true first result 0,second result 0'), + ) + + expect(results.length).toBe(3) + + expect(results[0]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: '', + }) + + expect(results[1]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 0', + }) + + expect(results[2]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 0,second result 0', + }) + + count++ + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await waitFor(() => + rendered.getByText('data: true first result 1,second result 1'), + ) + + expect(results.length).toBe(5) + + expect(results[3]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 1,second result 0', + }) + + expect(results[4]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 1,second result 1', + }) + + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) + + await sleep(50) + // no further re-render because data didn't change + expect(results.length).toBe(5) + }) }) From 35149d072b3052fbe5e45913bdf3b8fcfd987e31 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 15 May 2023 09:23:33 +0200 Subject: [PATCH 12/12] docs: combine --- docs/react/reference/useQueries.md | 41 +++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/react/reference/useQueries.md b/docs/react/reference/useQueries.md index df71e025e0..37e8004e13 100644 --- a/docs/react/reference/useQueries.md +++ b/docs/react/reference/useQueries.md @@ -6,23 +6,50 @@ title: useQueries The `useQueries` hook can be used to fetch a variable number of queries: ```tsx +const ids = [1,2,3] const results = useQueries({ - queries: [ - { queryKey: ['post', 1], queryFn: fetchPost, staleTime: Infinity}, - { queryKey: ['post', 2], queryFn: fetchPost, staleTime: Infinity} - ] + queries: ids.map(id => [ + { queryKey: ['post', id], queryFn: () => fetchPost(id), staleTime: Infinity }, + ]), }) ``` **Options** -The `useQueries` hook accepts an options object with a **queries** key whose value is an array with query option objects identical to the [`useQuery` hook](../reference/useQuery) (excluding the `context` option). +The `useQueries` hook accepts an options object with a **queries** key whose value is an array with query option objects identical to the [`useQuery` hook](../reference/useQuery) (excluding the `queryClient` option - because the `QueryClient` can be passed in on the top level). - `queryClient?: QueryClient`, - - Use this to use a custom QueryClient. Otherwise, the one from the nearest context will be used. + - Use this to provide a custom QueryClient. Otherwise, the one from the nearest context will be used. +- `combine?`: (result: UseQueriesResults) => TCombinedResult + - Use this to combine the results of the queries into a single value. -> Having the same query key more than once in the array of query objects may cause some data to be shared between queries, e.g. when using `placeholderData` and `select`. To avoid this, consider de-duplicating the queries and map the results back to the desired structure. +> Having the same query key more than once in the array of query objects may cause some data to be shared between queries. To avoid this, consider de-duplicating the queries and map the results back to the desired structure. + +**placeholderData** + +The `placeholderData` option exists for `useQueries` as well, but it doesn't get information passed from previously rendered Queries like `useQuery` does, because the input to `useQueries` can be a different number of Queries on each render. **Returns** The `useQueries` hook returns an array with all the query results. The order returned is the same as the input order. + +## Combine + +If you want to combine `data` (or other Query information) from the results into a single value, you can use the `combine` option. The result will be structurally shared to be as referentially stable as possible. + +```tsx +const ids = [1,2,3] +const combinedQueries = useQueries({ + queries: ids.map(id => [ + { queryKey: ['post', id], queryFn: () => fetchPost(id) }, + ]), + combine: (results) => { + return ({ + data: results.map(result => result.data), + pending: results.some(result => result.isPending), + }) + } +}) +``` + +In the above example, `combinedQueries` will be an object with a `data` and a `pending` property. Note that all other properties of the Query results will be lost.