Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solid-query-combine-result-shape.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/solid-query/src/__tests__/useQueries.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
86 changes: 86 additions & 0 deletions packages/solid-query/src/__tests__/useQueries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<div data-testid="data">{JSON.stringify(result.data)}</div>
<div data-testid="pending">{String(result.pending)}</div>
</div>
)
}

const rendered = renderWithClient(queryClient, () => <Page />)

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 (
<div>
<div data-testid="isArray">{String(Array.isArray(result))}</div>
<div data-testid="length">{result.length}</div>
<div data-testid="data">
{JSON.stringify(result.map((r) => r.data))}
</div>
</div>
)
}

const rendered = renderWithClient(queryClient, () => <Page />)

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()
Expand Down
68 changes: 51 additions & 17 deletions packages/solid-query/src/useQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ type QueriesResults<

export function useQueries<
T extends Array<any>,
TCombinedResult extends QueriesResults<T> = QueriesResults<T>,
TCombinedResult extends object = QueriesResults<T>,
>(
queriesOptions: Accessor<{
queries:
Expand Down Expand Up @@ -223,24 +223,20 @@ export function useQueries<
: undefined,
)

const [state, setState] = createStore<TCombinedResult>(
observer.getOptimisticResult(
defaultedQueries(),
(queriesOptions() as QueriesObserverOptions<TCombinedResult>).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<Array<QueryObserverResult>>(optimisticResult())

createRenderEffect(
on(
() => queriesOptions().queries.length,
() =>
setState(
observer.getOptimisticResult(
defaultedQueries(),
(queriesOptions() as QueriesObserverOptions<TCombinedResult>)
.combine,
)[1](),
),
() => setState(optimisticResult()),
),
)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<T>)
: (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 }
},
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}