diff --git a/.changeset/silver-coins-mix.md b/.changeset/silver-coins-mix.md new file mode 100644 index 0000000000..efc41ecea9 --- /dev/null +++ b/.changeset/silver-coins-mix.md @@ -0,0 +1,11 @@ +--- +'@tanstack/angular-query-experimental': minor +'@tanstack/svelte-query': minor +'@tanstack/react-query': minor +'@tanstack/preact-query': minor +'@tanstack/solid-query': minor +'@tanstack/query-core': minor +'@tanstack/vue-query': minor +--- + +feat(query-core): Allow disabling structuralSharing for useQueries. diff --git a/docs/framework/react/reference/useQueries.md b/docs/framework/react/reference/useQueries.md index d41a73a2b1..f330291e21 100644 --- a/docs/framework/react/reference/useQueries.md +++ b/docs/framework/react/reference/useQueries.md @@ -24,6 +24,12 @@ The `useQueries` hook accepts an options object with a **queries** key whose val - 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. +- `structuralSharing?: boolean | ((oldData: unknown | undefined, newData: unknown) => unknown)` + - Optional + - Defaults to `true`. + - Only applies when `combine` is provided. + - If set to `false`, structural sharing between query results will be disabled. + - If set to a function, the old and new data values will be passed through this function, which should combine them into resolved data for the query. This way, you can retain references from the old data to improve performance even when that data contains non-serializable values. > 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. @@ -37,7 +43,7 @@ The `useQueries` hook returns an array with all the query results. The order ret ## 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. +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. If you want to disable structural sharing for the combined result, you can set the `structuralSharing` option to `false`, or provide a custom function to implement your own structural sharing logic. ```tsx const ids = [1, 2, 3] diff --git a/packages/angular-query-experimental/src/inject-queries.ts b/packages/angular-query-experimental/src/inject-queries.ts index 2f201799e7..1fc8b9288f 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -211,6 +211,15 @@ export interface InjectQueriesOptions< ...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries }, ] combine?: (result: QueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) } /** @@ -271,6 +280,8 @@ export function injectQueries< observerSignal().getOptimisticResult( defaultedQueries(), (optionsSignal() as QueriesObserverOptions).combine, + (optionsSignal() as QueriesObserverOptions) + .structuralSharing, ), ) diff --git a/packages/preact-query/src/useQueries.ts b/packages/preact-query/src/useQueries.ts index 96b41e08df..974d930abf 100644 --- a/packages/preact-query/src/useQueries.ts +++ b/packages/preact-query/src/useQueries.ts @@ -217,6 +217,15 @@ export function useQueries< | readonly [...QueriesOptions] | readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries }] combine?: (result: QueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) subscribed?: boolean }, queryClient?: QueryClient, @@ -263,6 +272,7 @@ export function useQueries< observer.getOptimisticResult( defaultedQueries, (options as QueriesObserverOptions).combine, + options.structuralSharing, ) const shouldSubscribe = !isRestoring && options.subscribed !== false diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index 24b80aaf81..ef4dd877a6 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -300,6 +300,7 @@ describe('queriesObserver', () => { { queryKey: key1, queryFn: queryFn1 }, ], undefined, + undefined, )[0], ) @@ -414,6 +415,7 @@ describe('queriesObserver', () => { const [initialRaw, getInitialCombined] = observer.getOptimisticResult( [{ queryKey: key1, queryFn: queryFn1 }], combine, + undefined, ) const initialCombined = getInitialCombined(initialRaw) @@ -426,6 +428,7 @@ describe('queriesObserver', () => { const [newRaw, getNewCombined] = observer.getOptimisticResult( newQueries, combine, + undefined, ) const newCombined = getNewCombined(newRaw) @@ -461,6 +464,7 @@ describe('queriesObserver', () => { { queryKey: key2, queryFn: queryFn2 }, ], combine, + undefined, ) const initialCombined = getInitialCombined(initialRaw) @@ -470,6 +474,7 @@ describe('queriesObserver', () => { const [newRaw, getNewCombined] = observer.getOptimisticResult( newQueries, combine, + undefined, ) const newCombined = getNewCombined(newRaw) @@ -497,6 +502,7 @@ describe('queriesObserver', () => { const [initialRaw, getInitialCombined] = observer.getOptimisticResult( [{ queryKey: key1, queryFn: queryFn1 }], combine, + undefined, ) const initialCombined = getInitialCombined(initialRaw) @@ -505,9 +511,272 @@ describe('queriesObserver', () => { const [newRaw, getNewCombined] = observer.getOptimisticResult( [{ queryKey: key2, queryFn: queryFn2 }], combine, + undefined, ) const newCombined = getNewCombined(newRaw) expect(newCombined.keys).toEqual(['pending']) }) + + test('should not use structural sharing when structuralSharing is false', () => { + const key1 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + + queryClient.setQueryData(key1, 'cached-1') + + // Create a combine function that returns a new object with a nested array + const nestedArray = ['a', 'b', 'c'] + const combine = vi.fn((_results: Array) => ({ + nested: nestedArray, + })) + + const observer = new QueriesObserver<{ + nested: Array + }>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { + combine, + structuralSharing: false, + }) + + const [initialRaw, getInitialCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine, + false, + ) + const initialCombined = getInitialCombined(initialRaw) + + // Create a new combine function reference to trigger re-combine + // but with the same nested array content + const combine2 = vi.fn((_results: Array) => ({ + nested: ['a', 'b', 'c'], // Same content, different reference + })) + + const [newRaw, getNewCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine2, + false, + ) + const newCombined = getNewCombined(newRaw) + + // With structuralSharing: false, even though the nested array has the same content, + // the reference should NOT be preserved (no replaceEqualDeep optimization) + expect(newCombined.nested).toEqual(initialCombined.nested) + expect(newCombined.nested).not.toBe(initialCombined.nested) + }) + + test('should use structural sharing when structuralSharing is true', () => { + const key1 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + + queryClient.setQueryData(key1, 'cached-1') + + // Create a combine function that returns a new object with a nested array + const combine = vi.fn((_results: Array) => ({ + nested: ['a', 'b', 'c'], + })) + + const observer = new QueriesObserver<{ + nested: Array + }>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { + combine, + structuralSharing: true, + }) + + const [initialRaw, getInitialCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine, + true, + ) + const initialCombined = getInitialCombined(initialRaw) + + // Create a new combine function reference to trigger re-combine + // but with the same nested array content + const combine2 = vi.fn((_results: Array) => ({ + nested: ['a', 'b', 'c'], // Same content, different reference + })) + + const [newRaw, getNewCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine2, + true, + ) + const newCombined = getNewCombined(newRaw) + + // With structuralSharing: true, replaceEqualDeep should preserve the reference + // since the nested array has the same content + expect(newCombined.nested).toEqual(initialCombined.nested) + expect(newCombined.nested).toBe(initialCombined.nested) + }) + + test('should use custom structuralSharing function when provided', () => { + const combine = vi.fn((results: Array) => ({ + count: results.length, + data: results.map((r) => r.data), + })) + + const customStructuralSharing = vi.fn( + (_oldData: unknown, newData: unknown) => { + // Custom logic: always return the new data but with a marker + return { ...(newData as object), customShared: true } + }, + ) + + const key1 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + + queryClient.setQueryData(key1, 'cached-1') + + const observer = new QueriesObserver<{ + count: number + data: Array + customShared?: boolean + }>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { + combine, + structuralSharing: customStructuralSharing, + }) + + const [initialRaw, getInitialCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine, + customStructuralSharing, + ) + const initialCombined = getInitialCombined(initialRaw) + + expect(initialCombined.count).toBe(1) + expect(initialCombined.customShared).toBe(true) + expect(customStructuralSharing).toHaveBeenCalledTimes(1) + }) + + test('should pass old and new data to custom structuralSharing function', () => { + const combine = vi.fn((results: Array) => ({ + count: results.length, + data: results.map((r) => r.data), + })) + + const customStructuralSharing = vi.fn( + (_oldData: unknown, newData: unknown) => { + // Return new data with reference to old data for testing + return newData + }, + ) + + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + const queryFn2 = vi.fn().mockReturnValue(2) + + queryClient.setQueryData(key1, 'cached-1') + + const observer = new QueriesObserver<{ + count: number + data: Array + }>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { + combine, + structuralSharing: customStructuralSharing, + }) + + // First call + const [initialRaw, getInitialCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine, + customStructuralSharing, + ) + const initialCombined = getInitialCombined(initialRaw) + + expect(initialCombined.count).toBe(1) + + // Second call with different queries - should trigger combine again + const [newRaw, getNewCombined] = observer.getOptimisticResult( + [ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ], + combine, + customStructuralSharing, + ) + const newCombined = getNewCombined(newRaw) + + expect(newCombined.count).toBe(2) + // Custom structural sharing function should have been called twice + expect(customStructuralSharing).toHaveBeenCalledTimes(2) + // First call should have undefined as oldData (no previous combined result) + expect(customStructuralSharing).toHaveBeenNthCalledWith( + 1, + undefined, + expect.objectContaining({ count: 1 }), + ) + // Second call should have the previous combined result as oldData + expect(customStructuralSharing).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ count: 1 }), + expect.objectContaining({ count: 2 }), + ) + }) + + test('should retain references with custom structuralSharing function', () => { + // This test verifies that a custom structuralSharing function can retain references + const existingArray = [1, 2, 3] + + const combine = vi.fn((results: Array) => ({ + count: results.length, + data: results.map((r) => r.data), + existingArray, + })) + + const customStructuralSharing = vi.fn( + (oldData: unknown, newData: unknown) => { + const oldTyped = oldData as + | { existingArray?: Array } + | undefined + const newTyped = newData as { existingArray: Array } + // Retain the existingArray reference from old data if it deeply equals + if ( + oldTyped?.existingArray && + JSON.stringify(oldTyped.existingArray) === + JSON.stringify(newTyped.existingArray) + ) { + return { ...newTyped, existingArray: oldTyped.existingArray } + } + return newTyped + }, + ) + + const key1 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + + queryClient.setQueryData(key1, 'cached-1') + + const observer = new QueriesObserver<{ + count: number + data: Array + existingArray: Array + }>(queryClient, [{ queryKey: key1, queryFn: queryFn1 }], { + combine, + structuralSharing: customStructuralSharing, + }) + + const [initialRaw, getInitialCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine, + customStructuralSharing, + ) + const initialCombined = getInitialCombined(initialRaw) + const initialArrayRef = initialCombined.existingArray + + // Trigger a re-combine by changing the combine function reference + const combine2 = vi.fn((results: Array) => ({ + count: results.length, + data: results.map((r) => r.data), + existingArray, // Same array content + })) + + const [newRaw, getNewCombined] = observer.getOptimisticResult( + [{ queryKey: key1, queryFn: queryFn1 }], + combine2, + customStructuralSharing, + ) + const newCombined = getNewCombined(newRaw) + + // The existingArray reference should be retained from the old data + expect(newCombined.existingArray).toBe(initialArrayRef) + }) }) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 67dd088f9a..086e677bd0 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -30,6 +30,15 @@ export interface QueriesObserverOptions< TCombinedResult = Array, > { combine?: CombineFn + /** + * Set this to `false` to disable structural sharing between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) } export class QueriesObserver< @@ -172,6 +181,10 @@ export class QueriesObserver< getOptimisticResult( queries: Array, combine: CombineFn | undefined, + structuralSharing: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) + | undefined, ): [ rawResult: Array, combineResult: (r?: Array) => TCombinedResult, @@ -188,7 +201,12 @@ export class QueriesObserver< return [ result, (r?: Array) => { - return this.#combineResult(r ?? result, combine, queryHashes) + return this.#combineResult( + r ?? result, + combine, + structuralSharing, + queryHashes, + ) }, () => { return this.#trackResult(result, matches) @@ -216,6 +234,10 @@ export class QueriesObserver< #combineResult( input: Array, combine: CombineFn | undefined, + structuralSharing: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) + | undefined = true, queryHashes?: Array, ): TCombinedResult { if (combine) { @@ -238,10 +260,20 @@ export class QueriesObserver< if (queryHashes !== undefined) { this.#lastQueryHashes = queryHashes } - this.#combinedResult = replaceEqualDeep( - this.#combinedResult, - combine(input), - ) + + const combined = combine(input) + + if (typeof structuralSharing === 'function') { + this.#combinedResult = structuralSharing( + this.#combinedResult, + combined, + ) as TCombinedResult + } else { + this.#combinedResult = + structuralSharing !== false + ? replaceEqualDeep(this.#combinedResult, combined) + : combined + } } return this.#combinedResult @@ -296,7 +328,11 @@ export class QueriesObserver< if (this.hasListeners()) { const previousResult = this.#combinedResult const newTracked = this.#trackResult(this.#result, this.#observerMatches) - const newResult = this.#combineResult(newTracked, this.#options?.combine) + const newResult = this.#combineResult( + newTracked, + this.#options?.combine, + this.#options?.structuralSharing, + ) if (previousResult !== newResult) { notifyManager.batch(() => { diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 6eabef4060..5e91920448 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -217,6 +217,15 @@ export function useQueries< | readonly [...QueriesOptions] | readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries }] combine?: (result: QueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) subscribed?: boolean }, queryClient?: QueryClient, @@ -264,6 +273,7 @@ export function useQueries< observer.getOptimisticResult( defaultedQueries, (options as QueriesObserverOptions).combine, + options.structuralSharing, ) const shouldSubscribe = !isRestoring && options.subscribed !== false diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index 1e5592775d..d634fcfccf 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -193,6 +193,15 @@ export function useQueries< | readonly [...QueriesOptions] | readonly [...{ [K in keyof T]: GetOptions }] combine?: (result: QueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) }>, queryClient?: Accessor, ): TCombinedResult { @@ -218,6 +227,7 @@ export function useQueries< queriesOptions().combine ? ({ combine: queriesOptions().combine, + structuralSharing: queriesOptions().structuralSharing, } as QueriesObserverOptions) : undefined, ) @@ -226,6 +236,8 @@ export function useQueries< observer.getOptimisticResult( defaultedQueries(), (queriesOptions() as QueriesObserverOptions).combine, + (queriesOptions() as QueriesObserverOptions) + .structuralSharing, )[1](), ) @@ -238,6 +250,8 @@ export function useQueries< defaultedQueries(), (queriesOptions() as QueriesObserverOptions) .combine, + (queriesOptions() as QueriesObserverOptions) + .structuralSharing, )[1](), ), ), @@ -303,22 +317,14 @@ export function useQueries< onMount(() => { observer.setQueries( defaultedQueries(), - queriesOptions().combine - ? ({ - combine: queriesOptions().combine, - } as QueriesObserverOptions) - : undefined, + queriesOptions() as QueriesObserverOptions, ) }) createComputed(() => { observer.setQueries( defaultedQueries(), - queriesOptions().combine - ? ({ - combine: queriesOptions().combine, - } as QueriesObserverOptions) - : undefined, + queriesOptions() as QueriesObserverOptions, ) }) diff --git a/packages/svelte-query/src/createQueries.svelte.ts b/packages/svelte-query/src/createQueries.svelte.ts index dec5756129..8ddd064c66 100644 --- a/packages/svelte-query/src/createQueries.svelte.ts +++ b/packages/svelte-query/src/createQueries.svelte.ts @@ -197,13 +197,23 @@ export function createQueries< ...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries }, ] combine?: (result: QueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) }>, queryClient?: Accessor, ): TCombinedResult { const client = $derived(useQueryClient(queryClient?.())) const isRestoring = useIsRestoring() - const { queries, combine } = $derived.by(createQueriesOptions) + const { queries, ...derivedCreateQueriesOptions } = + $derived.by(createQueriesOptions) const resolvedQueryOptions = $derived( queries.map((opts) => { const resolvedOptions = client.defaultQueryOptions(opts) @@ -220,14 +230,15 @@ export function createQueries< new QueriesObserver( client, resolvedQueryOptions, - combine as QueriesObserverOptions, + derivedCreateQueriesOptions as QueriesObserverOptions, ), ) function createResult() { const [_, getCombinedResult, trackResult] = observer.getOptimisticResult( resolvedQueryOptions, - combine as QueriesObserverOptions['combine'], + derivedCreateQueriesOptions.combine as QueriesObserverOptions['combine'], + derivedCreateQueriesOptions.structuralSharing, ) return getCombinedResult(trackResult()) } @@ -244,9 +255,10 @@ export function createQueries< }) $effect.pre(() => { - observer.setQueries(resolvedQueryOptions, { - combine, - } as QueriesObserverOptions) + observer.setQueries( + resolvedQueryOptions, + derivedCreateQueriesOptions as QueriesObserverOptions, + ) update(createResult()) }) diff --git a/packages/vue-query/src/useQueries.ts b/packages/vue-query/src/useQueries.ts index f290976a14..2a1f8e118e 100644 --- a/packages/vue-query/src/useQueries.ts +++ b/packages/vue-query/src/useQueries.ts @@ -248,6 +248,15 @@ export function useQueries< ] > combine?: (result: UseQueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) }, queryClient?: QueryClient, ): Readonly> { @@ -296,6 +305,7 @@ export function useQueries< const [results, getCombinedResult] = observer.getOptimisticResult( defaultedQueries.value, (options as QueriesObserverOptions).combine, + options.structuralSharing, ) return getCombinedResult( @@ -306,6 +316,7 @@ export function useQueries< const [{ [index]: query }] = observer.getOptimisticResult( defaultedQueries.value, (options as QueriesObserverOptions).combine, + options.structuralSharing, ) return query!.refetch(...args)