diff --git a/.changeset/solid-query-combine-result-shape.md b/.changeset/solid-query-combine-result-shape.md new file mode 100644 index 0000000000..bb7348aa6d --- /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 eaa0a42d45..450c4ab191 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 acc344782f..cd93ff3c4b 100644 --- a/packages/solid-query/src/__tests__/useQueries.test.tsx +++ b/packages/solid-query/src/__tests__/useQueries.test.tsx @@ -159,6 +159,140 @@ 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 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 9bf909634b..4ad9a1fdd4 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,65 @@ export function useQueries< const [proxyState, setProxyState] = createStore(getProxies()) createRenderEffect(() => setProxyState(getProxies())) - return proxyState as TCombinedResult + // 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 + } + + // `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) + }) + + // 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) || + 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 (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 have to stay configurable, again to + // satisfy the proxy invariants. + return descriptor && { ...descriptor, configurable: true } + }, + }) }