From 1956f424c52b49df10e769ff56d679f6e51d0b2a Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Fri, 6 Feb 2026 21:35:17 +0100 Subject: [PATCH 1/6] feat(query-core): add structuralSharing option to useQueries Add a `structuralSharing` option to useQueries/createQueries/injectQueries that allows disabling structural sharing for the combined result. When set to `false`, the combined result will not use `replaceEqualDeep` for referential stability. Defaults to `true`. --- .changeset/silver-coins-mix.md | 10 ++++++ docs/framework/react/reference/useQueries.md | 5 ++- .../src/inject-queries.ts | 8 +++++ .../src/__tests__/queriesObserver.test.tsx | 7 +++++ packages/query-core/src/queriesObserver.ts | 31 +++++++++++++++---- packages/react-query/src/useQueries.ts | 7 +++++ packages/solid-query/src/useQueries.ts | 23 ++++++++------ .../svelte-query/src/createQueries.svelte.ts | 21 +++++++++---- packages/vue-query/src/useQueries.ts | 8 +++++ 9 files changed, 97 insertions(+), 23 deletions(-) create mode 100644 .changeset/silver-coins-mix.md diff --git a/.changeset/silver-coins-mix.md b/.changeset/silver-coins-mix.md new file mode 100644 index 0000000000..d02adb65ee --- /dev/null +++ b/.changeset/silver-coins-mix.md @@ -0,0 +1,10 @@ +--- +'@tanstack/angular-query-experimental': minor +'@tanstack/svelte-query': minor +'@tanstack/react-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..1de3f37b77 100644 --- a/docs/framework/react/reference/useQueries.md +++ b/docs/framework/react/reference/useQueries.md @@ -24,6 +24,9 @@ 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` + - Set this to `false` to disable structural sharing between query results when `combine` is provided. + - Defaults to `true`. > 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 +40,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`. ```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..f96c104d48 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -211,6 +211,12 @@ export interface InjectQueriesOptions< ...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries }, ] combine?: (result: QueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: boolean } /** @@ -271,6 +277,8 @@ export function injectQueries< observerSignal().getOptimisticResult( defaultedQueries(), (optionsSignal() as QueriesObserverOptions).combine, + (optionsSignal() as QueriesObserverOptions) + .structuralSharing, ), ) diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index 24b80aaf81..3e8c46119d 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,6 +511,7 @@ describe('queriesObserver', () => { const [newRaw, getNewCombined] = observer.getOptimisticResult( [{ queryKey: key2, queryFn: queryFn2 }], combine, + undefined, ) const newCombined = getNewCombined(newRaw) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 67dd088f9a..99fec07333 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -30,6 +30,12 @@ export interface QueriesObserverOptions< TCombinedResult = Array, > { combine?: CombineFn + /** + * Set this to `false` to disable structural sharing between query results. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: boolean } export class QueriesObserver< @@ -172,6 +178,7 @@ export class QueriesObserver< getOptimisticResult( queries: Array, combine: CombineFn | undefined, + structuralSharing: boolean | undefined, ): [ rawResult: Array, combineResult: (r?: Array) => TCombinedResult, @@ -188,7 +195,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 +228,7 @@ export class QueriesObserver< #combineResult( input: Array, combine: CombineFn | undefined, + structuralSharing: boolean | undefined = true, queryHashes?: Array, ): TCombinedResult { if (combine) { @@ -238,10 +251,12 @@ export class QueriesObserver< if (queryHashes !== undefined) { this.#lastQueryHashes = queryHashes } - this.#combinedResult = replaceEqualDeep( - this.#combinedResult, - combine(input), - ) + + const combined = combine(input) + + this.#combinedResult = structuralSharing + ? replaceEqualDeep(this.#combinedResult, combined) + : combined } return this.#combinedResult @@ -296,7 +311,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..3d8b02f23e 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -217,6 +217,12 @@ 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. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: boolean subscribed?: boolean }, queryClient?: QueryClient, @@ -264,6 +270,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..84800c39ad 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -193,6 +193,12 @@ 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. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: boolean }>, queryClient?: Accessor, ): TCombinedResult { @@ -218,6 +224,7 @@ export function useQueries< queriesOptions().combine ? ({ combine: queriesOptions().combine, + structuralSharing: queriesOptions().structuralSharing, } as QueriesObserverOptions) : undefined, ) @@ -226,6 +233,8 @@ export function useQueries< observer.getOptimisticResult( defaultedQueries(), (queriesOptions() as QueriesObserverOptions).combine, + (queriesOptions() as QueriesObserverOptions) + .structuralSharing, )[1](), ) @@ -238,6 +247,8 @@ export function useQueries< defaultedQueries(), (queriesOptions() as QueriesObserverOptions) .combine, + (queriesOptions() as QueriesObserverOptions) + .structuralSharing, )[1](), ), ), @@ -303,22 +314,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..da266089d3 100644 --- a/packages/svelte-query/src/createQueries.svelte.ts +++ b/packages/svelte-query/src/createQueries.svelte.ts @@ -197,13 +197,20 @@ export function createQueries< ...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries }, ] combine?: (result: QueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: boolean }>, 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 +227,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 +252,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..932c91e02b 100644 --- a/packages/vue-query/src/useQueries.ts +++ b/packages/vue-query/src/useQueries.ts @@ -248,6 +248,12 @@ export function useQueries< ] > combine?: (result: UseQueriesResults) => TCombinedResult + /** + * Set this to `false` to disable structural sharing between query results. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: boolean }, queryClient?: QueryClient, ): Readonly> { @@ -296,6 +302,7 @@ export function useQueries< const [results, getCombinedResult] = observer.getOptimisticResult( defaultedQueries.value, (options as QueriesObserverOptions).combine, + options.structuralSharing, ) return getCombinedResult( @@ -306,6 +313,7 @@ export function useQueries< const [{ [index]: query }] = observer.getOptimisticResult( defaultedQueries.value, (options as QueriesObserverOptions).combine, + options.structuralSharing, ) return query!.refetch(...args) From 4a13c69103142b7127c3f264f6abf5c7140ce695 Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Tue, 10 Feb 2026 19:31:41 +0100 Subject: [PATCH 2/6] Add structuralSharing to useQueries in the preact-adapter. --- packages/preact-query/src/useQueries.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/preact-query/src/useQueries.ts b/packages/preact-query/src/useQueries.ts index 96b41e08df..80808ed7ee 100644 --- a/packages/preact-query/src/useQueries.ts +++ b/packages/preact-query/src/useQueries.ts @@ -217,6 +217,12 @@ 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. + * Only applies when `combine` is provided. + * Defaults to `true`. + */ + structuralSharing?: boolean subscribed?: boolean }, queryClient?: QueryClient, @@ -263,6 +269,7 @@ export function useQueries< observer.getOptimisticResult( defaultedQueries, (options as QueriesObserverOptions).combine, + options.structuralSharing, ) const shouldSubscribe = !isRestoring && options.subscribed !== false From 9950db747b5a5b21a2e02ec9cf63e91de4290101 Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Tue, 10 Feb 2026 19:40:09 +0100 Subject: [PATCH 3/6] Refactor structuralSharing for useQueries to accept a function. Match the useQuery logic and allow a function to override the structural sharing. --- docs/framework/react/reference/useQueries.md | 8 +++-- .../src/inject-queries.ts | 5 +++- packages/preact-query/src/useQueries.ts | 5 +++- packages/query-core/src/queriesObserver.ts | 29 +++++++++++++++---- packages/react-query/src/useQueries.ts | 5 +++- packages/solid-query/src/useQueries.ts | 5 +++- .../svelte-query/src/createQueries.svelte.ts | 5 +++- packages/vue-query/src/useQueries.ts | 5 +++- 8 files changed, 52 insertions(+), 15 deletions(-) diff --git a/docs/framework/react/reference/useQueries.md b/docs/framework/react/reference/useQueries.md index 1de3f37b77..7e42e460d4 100644 --- a/docs/framework/react/reference/useQueries.md +++ b/docs/framework/react/reference/useQueries.md @@ -24,9 +24,11 @@ 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` - - Set this to `false` to disable structural sharing between query results when `combine` is provided. +- `structuralSharing?: boolean | ((oldData: unknown | undefined, newData: unknown) => unknown)` + - Optional - Defaults to `true`. + - 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. @@ -40,7 +42,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 disable structural sharing for the combined result, you can set the `structuralSharing` option to `false`. +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 f96c104d48..1fc8b9288f 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -213,10 +213,13 @@ export interface InjectQueriesOptions< 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 + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) } /** diff --git a/packages/preact-query/src/useQueries.ts b/packages/preact-query/src/useQueries.ts index 80808ed7ee..974d930abf 100644 --- a/packages/preact-query/src/useQueries.ts +++ b/packages/preact-query/src/useQueries.ts @@ -219,10 +219,13 @@ export function useQueries< 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 + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) subscribed?: boolean }, queryClient?: QueryClient, diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 99fec07333..086e677bd0 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -32,10 +32,13 @@ export interface QueriesObserverOptions< 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 + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) } export class QueriesObserver< @@ -178,7 +181,10 @@ export class QueriesObserver< getOptimisticResult( queries: Array, combine: CombineFn | undefined, - structuralSharing: boolean | undefined, + structuralSharing: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) + | undefined, ): [ rawResult: Array, combineResult: (r?: Array) => TCombinedResult, @@ -228,7 +234,10 @@ export class QueriesObserver< #combineResult( input: Array, combine: CombineFn | undefined, - structuralSharing: boolean | undefined = true, + structuralSharing: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) + | undefined = true, queryHashes?: Array, ): TCombinedResult { if (combine) { @@ -254,9 +263,17 @@ export class QueriesObserver< const combined = combine(input) - this.#combinedResult = structuralSharing - ? replaceEqualDeep(this.#combinedResult, combined) - : combined + 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 diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 3d8b02f23e..5e91920448 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -219,10 +219,13 @@ export function useQueries< 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 + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) subscribed?: boolean }, queryClient?: QueryClient, diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index 84800c39ad..d634fcfccf 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -195,10 +195,13 @@ export function useQueries< 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 + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) }>, queryClient?: Accessor, ): TCombinedResult { diff --git a/packages/svelte-query/src/createQueries.svelte.ts b/packages/svelte-query/src/createQueries.svelte.ts index da266089d3..8ddd064c66 100644 --- a/packages/svelte-query/src/createQueries.svelte.ts +++ b/packages/svelte-query/src/createQueries.svelte.ts @@ -199,10 +199,13 @@ export function createQueries< 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 + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) }>, queryClient?: Accessor, ): TCombinedResult { diff --git a/packages/vue-query/src/useQueries.ts b/packages/vue-query/src/useQueries.ts index 932c91e02b..2a1f8e118e 100644 --- a/packages/vue-query/src/useQueries.ts +++ b/packages/vue-query/src/useQueries.ts @@ -250,10 +250,13 @@ 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 + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) }, queryClient?: QueryClient, ): Readonly> { From f1815fcb509e12b8a67a9e61d5b2c6b89b18b797 Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Tue, 10 Feb 2026 19:54:24 +0100 Subject: [PATCH 4/6] Add tests for the structuralSharing option in QueriesObserver. --- .../src/__tests__/queriesObserver.test.tsx | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index 3e8c46119d..ef4dd877a6 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -517,4 +517,266 @@ describe('queriesObserver', () => { 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) + }) }) From c6399f01f2c510436d378d989b236a442dca0006 Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Tue, 10 Feb 2026 19:55:32 +0100 Subject: [PATCH 5/6] Add preact adapter to changeset. --- .changeset/silver-coins-mix.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/silver-coins-mix.md b/.changeset/silver-coins-mix.md index d02adb65ee..efc41ecea9 100644 --- a/.changeset/silver-coins-mix.md +++ b/.changeset/silver-coins-mix.md @@ -2,6 +2,7 @@ '@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 From eabd1fcecc96fddfc6fde177351f62904276c78b Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Tue, 10 Feb 2026 20:18:59 +0100 Subject: [PATCH 6/6] Fix docs nitpicks from coderabbit for structuralSharing for useQueries. --- docs/framework/react/reference/useQueries.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/framework/react/reference/useQueries.md b/docs/framework/react/reference/useQueries.md index 7e42e460d4..f330291e21 100644 --- a/docs/framework/react/reference/useQueries.md +++ b/docs/framework/react/reference/useQueries.md @@ -27,7 +27,8 @@ The `useQueries` hook accepts an options object with a **queries** key whose val - `structuralSharing?: boolean | ((oldData: unknown | undefined, newData: unknown) => unknown)` - Optional - Defaults to `true`. - - If set to false, structural sharing between query results will be disabled. + - 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.