Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
134 changes: 134 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,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 (
<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 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<UseQueryResult<number, Error>>
| { data: Array<number | undefined> } =>
asArray()
? results
: { data: results.map((queryResult) => queryResult.data) },
}))

return (
<div>
<button onClick={() => setAsArray(false)}>to object</button>
<div data-testid="keys">{Object.keys(result).join(',')}</div>
<div data-testid="data">
{JSON.stringify(
'data' in result ? result.data : (result[0]?.data ?? null),
)}
</div>
</div>
)
}

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

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