From 0522245fcf33228cfa578facfb81ba0160b03b96 Mon Sep 17 00:00:00 2001 From: Abdul Wasay Date: Fri, 21 Aug 2026 04:51:27 +0500 Subject: [PATCH 1/2] fix(solid-query): support combine functions returning any object shape The store backing useQueries held whatever combine returned, while the resources, proxies and subscription updates around it are keyed by query index. A combine function that returned anything but the results array therefore failed to type check (TCombinedResult was constrained to QueriesResults) and threw 'state.map is not a function' at runtime. Keep the raw results in the store and derive the combined result from the tracked proxies instead, matching how the React adapter separates the two. Fixes #7522 --- .../solid-query-combine-result-shape.md | 5 ++ .../src/__tests__/useQueries.test-d.tsx | 21 +++++ .../src/__tests__/useQueries.test.tsx | 86 +++++++++++++++++++ packages/solid-query/src/useQueries.ts | 68 +++++++++++---- 4 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 .changeset/solid-query-combine-result-shape.md diff --git a/.changeset/solid-query-combine-result-shape.md b/.changeset/solid-query-combine-result-shape.md new file mode 100644 index 00000000000..bb7348aa6d8 --- /dev/null +++ b/.changeset/solid-query-combine-result-shape.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-query': patch +--- + +Fix `useQueries`/`createQueries` rejecting a `combine` function that returns a shape other than the results array, which previously failed to type check and threw `state.map is not a function` at runtime. diff --git a/packages/solid-query/src/__tests__/useQueries.test-d.tsx b/packages/solid-query/src/__tests__/useQueries.test-d.tsx index eaa0a42d45f..450c4ab1911 100644 --- a/packages/solid-query/src/__tests__/useQueries.test-d.tsx +++ b/packages/solid-query/src/__tests__/useQueries.test-d.tsx @@ -296,6 +296,27 @@ describe('useQueries', () => { })) }) + it('should allow combine to return a shape other than the results array', () => { + const result = useQueries(() => ({ + queries: [ + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }, + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(2), + }, + ], + combine: (results) => ({ + data: results.every((queryResult) => queryResult.data), + pending: results.some((queryResult) => queryResult.isPending), + }), + })) + + expectTypeOf(result).toEqualTypeOf<{ data: boolean; pending: boolean }>() + }) + describe('type parameters', () => { it('should handle type parameter - tuple of tuples', () => { const key1 = queryKey() diff --git a/packages/solid-query/src/__tests__/useQueries.test.tsx b/packages/solid-query/src/__tests__/useQueries.test.tsx index acc344782f9..76dba45e6d3 100644 --- a/packages/solid-query/src/__tests__/useQueries.test.tsx +++ b/packages/solid-query/src/__tests__/useQueries.test.tsx @@ -159,6 +159,92 @@ describe('useQueries', () => { expect(rendered.getByText('data: custom client')).toBeInTheDocument() }) + it('should support a combine function that returns a shape other than the results array', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const result = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + }, + { + queryKey: key2, + queryFn: () => sleep(20).then(() => 2), + }, + ], + combine: (results) => ({ + data: results.map((queryResult) => queryResult.data), + pending: results.some((queryResult) => queryResult.isPending), + }), + })) + + return ( +
+
{JSON.stringify(result.data)}
+
{String(result.pending)}
+
+ ) + } + + const rendered = renderWithClient(queryClient, () => ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByTestId('data')).toHaveTextContent('[null,null]') + expect(rendered.getByTestId('pending')).toHaveTextContent('true') + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByTestId('data')).toHaveTextContent('[1,null]') + expect(rendered.getByTestId('pending')).toHaveTextContent('true') + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByTestId('data')).toHaveTextContent('[1,2]') + expect(rendered.getByTestId('pending')).toHaveTextContent('false') + }) + + it('should keep a combine function that returns the results array array-like', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const result = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + }, + { + queryKey: key2, + queryFn: () => sleep(20).then(() => 2), + }, + ], + combine: (results) => results, + })) + + return ( +
+
{String(Array.isArray(result))}
+
{result.length}
+
+ {JSON.stringify(result.map((r) => r.data))} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, () => ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByTestId('isArray')).toHaveTextContent('true') + expect(rendered.getByTestId('length')).toHaveTextContent('2') + expect(rendered.getByTestId('data')).toHaveTextContent('[null,null]') + + await vi.advanceTimersByTimeAsync(20) + expect(rendered.getByTestId('data')).toHaveTextContent('[1,2]') + }) + it('should not fetch for the duration of the restoring period when isRestoring is true', async () => { const key1 = queryKey() const key2 = queryKey() diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index 9bf909634b2..df6766911cb 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -186,7 +186,7 @@ type QueriesResults< export function useQueries< T extends Array, - TCombinedResult extends QueriesResults = QueriesResults, + TCombinedResult extends object = QueriesResults, >( queriesOptions: Accessor<{ queries: @@ -223,24 +223,20 @@ export function useQueries< : undefined, ) - const [state, setState] = createStore( - observer.getOptimisticResult( - defaultedQueries(), - (queriesOptions() as QueriesObserverOptions).combine, - )[1](), - ) + // The store always holds the raw, uncombined results, because the resources + // and proxies below are keyed by query index. `combine` is applied on top of + // it right before the result is handed to the caller, so the combined result + // of the observer is not needed here. + const optimisticResult = () => + observer.getOptimisticResult(defaultedQueries(), undefined)[0] + + const [state, setState] = + createStore>(optimisticResult()) createRenderEffect( on( () => queriesOptions().queries.length, - () => - setState( - observer.getOptimisticResult( - defaultedQueries(), - (queriesOptions() as QueriesObserverOptions) - .combine, - )[1](), - ), + () => setState(optimisticResult()), ), ) @@ -277,7 +273,6 @@ export function useQueries< for (let index = 0; index < dataResources_.length; index++) { const dataResource = dataResources_[index]! const unwrappedResult = { ...unwrap(result[index]) } - // @ts-expect-error typescript pedantry regarding the possible range of index setState(index, unwrap(unwrappedResult)) dataResource[1].mutate(() => unwrap(state[index]!.data)) dataResource[1].refetch() @@ -340,5 +335,44 @@ export function useQueries< const [proxyState, setProxyState] = createStore(getProxies()) createRenderEffect(() => setProxyState(getProxies())) - return proxyState as TCombinedResult + if (!queriesOptions().combine) { + return proxyState as unknown as TCombinedResult + } + + // `combine` may return any shape, so the combined result cannot live in a + // store. It is derived from the tracked results instead, and read through a + // proxy so that consumers stay subscribed to the properties they access. + const combinedResult = createMemo(() => { + const combine = queriesOptions().combine + return combine + ? combine(proxyState as unknown as QueriesResults) + : (proxyState as unknown as TCombinedResult) + }) + + // The target is only there to keep `Array.isArray` and friends in sync with + // what `combine` returns - every read is forwarded to the memo. + const target = (Array.isArray(combinedResult()) ? [] : {}) as TCombinedResult + + return new Proxy(target, { + get: (_, property) => Reflect.get(combinedResult(), property), + has: (_, property) => Reflect.has(combinedResult(), property), + ownKeys: () => Reflect.ownKeys(combinedResult()), + getOwnPropertyDescriptor: (_, property) => { + const descriptor = Reflect.getOwnPropertyDescriptor( + combinedResult(), + property, + ) + + if (descriptor === undefined) { + return undefined + } + + // Properties the target does not own itself (all of them, unless the + // target is an array reporting its `length`) have to stay configurable to + // satisfy the proxy invariants. + return Reflect.getOwnPropertyDescriptor(target, property) + ? descriptor + : { ...descriptor, configurable: true } + }, + }) } From 7cdc713687da102e57daf4c94798f712be3f039e Mon Sep 17 00:00:00 2001 From: Abdul Wasay Date: Fri, 21 Aug 2026 05:17:09 +0500 Subject: [PATCH 2/2] fix(solid-query): keep the combined result proxy safe across shape changes A proxy target cannot be swapped, so the target kind is picked from the first combined result. If a later combined result was no longer an array, the non-configurable 'length' the array target still owns made ownKeys (and everything built on it, such as Object.keys and spreading) throw a TypeError. Report the properties the target owns itself with the target's own flags, so no trap can violate a proxy invariant once the shape changes. --- .../src/__tests__/useQueries.test.tsx | 48 +++++++++++++++++++ packages/solid-query/src/useQueries.ts | 43 ++++++++++++----- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/solid-query/src/__tests__/useQueries.test.tsx b/packages/solid-query/src/__tests__/useQueries.test.tsx index 76dba45e6d3..cd93ff3c4b0 100644 --- a/packages/solid-query/src/__tests__/useQueries.test.tsx +++ b/packages/solid-query/src/__tests__/useQueries.test.tsx @@ -245,6 +245,54 @@ describe('useQueries', () => { expect(rendered.getByTestId('data')).toHaveTextContent('[1,2]') }) + it('should not throw when the shape returned by combine changes', async () => { + const key1 = queryKey() + + function Page() { + const [asArray, setAsArray] = createSignal(true) + + const result = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + }, + ], + combine: ( + results, + ): + | Array> + | { data: Array } => + asArray() + ? results + : { data: results.map((queryResult) => queryResult.data) }, + })) + + return ( +
+ +
{Object.keys(result).join(',')}
+
+ {JSON.stringify( + 'data' in result ? result.data : (result[0]?.data ?? null), + )} +
+
+ ) + } + + const rendered = renderWithClient(queryClient, () => ) + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByTestId('keys')).toHaveTextContent('0') + expect(rendered.getByTestId('data')).toHaveTextContent('1') + + fireEvent.click(rendered.getByRole('button', { name: /to object/i })) + + expect(rendered.getByTestId('keys')).toHaveTextContent('data') + expect(rendered.getByTestId('data')).toHaveTextContent('[1]') + }) + it('should not fetch for the duration of the restoring period when isRestoring is true', async () => { const key1 = queryKey() const key2 = queryKey() diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index df6766911cb..4ad9a1fdd45 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -335,6 +335,9 @@ export function useQueries< const [proxyState, setProxyState] = createStore(getProxies()) createRenderEffect(() => setProxyState(getProxies())) + // Whether `combine` is used has to be decided once, because a component + // cannot hand out a different value later on. Removing it after the fact is + // still handled by the memo below, which falls back to the results array. if (!queriesOptions().combine) { return proxyState as unknown as TCombinedResult } @@ -349,30 +352,48 @@ export function useQueries< : (proxyState as unknown as TCombinedResult) }) - // The target is only there to keep `Array.isArray` and friends in sync with - // what `combine` returns - every read is forwarded to the memo. + // A proxy target cannot be swapped later on, so its kind is taken from the + // first combined result to keep `Array.isArray` and `JSON.stringify` in line + // with what `combine` returns. Every read is forwarded to the memo, but the + // properties the target owns itself - `length`, if it is an array - have to + // keep being reported even when a later combined result no longer has them, + // because they are non-configurable. const target = (Array.isArray(combinedResult()) ? [] : {}) as TCombinedResult + const getTargetDescriptor = (property: PropertyKey) => + Reflect.getOwnPropertyDescriptor(target, property) + return new Proxy(target, { get: (_, property) => Reflect.get(combinedResult(), property), - has: (_, property) => Reflect.has(combinedResult(), property), - ownKeys: () => Reflect.ownKeys(combinedResult()), + has: (_, property) => + Reflect.has(combinedResult(), property) || + getTargetDescriptor(property) !== undefined, + ownKeys: () => [ + ...new Set([ + ...Reflect.ownKeys(combinedResult()), + ...Reflect.ownKeys(target), + ]), + ], getOwnPropertyDescriptor: (_, property) => { const descriptor = Reflect.getOwnPropertyDescriptor( combinedResult(), property, ) + const targetDescriptor = getTargetDescriptor(property) - if (descriptor === undefined) { - return undefined + if (targetDescriptor) { + // Report the target's own flags, or the proxy invariants are violated. + return descriptor + ? { + ...targetDescriptor, + value: Reflect.get(combinedResult(), property), + } + : targetDescriptor } - // Properties the target does not own itself (all of them, unless the - // target is an array reporting its `length`) have to stay configurable to + // Properties the target does not own have to stay configurable, again to // satisfy the proxy invariants. - return Reflect.getOwnPropertyDescriptor(target, property) - ? descriptor - : { ...descriptor, configurable: true } + return descriptor && { ...descriptor, configurable: true } }, }) }