Skip to content

Feature/use queries suspense #2109

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
2 changes: 2 additions & 0 deletions docs/src/pages/reference/useQueries.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const results = useQueries([
])
```

Note that if _any_ query in the array of query option objects is configured with `suspense: true` or `useQueryBoundary: true`, that configuration will apply to _all_ queries handled by that `useQueries` hook.

**Options**

The `useQueries` hook accepts an array with query option objects identical to the [`useQuery` hook](/reference/useQuery).
Expand Down
20 changes: 19 additions & 1 deletion src/core/queriesObserver.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { difference, replaceAt } from './utils'
import { notifyManager } from './notifyManager'
import type { QueryObserverOptions, QueryObserverResult } from './types'
import type {
QueryObserverOptions,
QueryObserverResult,
RefetchOptions,
} from './types'
import type { QueryClient } from './queryClient'
import { NotifyOptions, QueryObserver } from './queryObserver'
import { Subscribable } from './subscribable'

type QueriesObserverListener = (result: QueryObserverResult[]) => void

interface QueriesRefetchOptions extends RefetchOptions {
filter: (observer: QueryObserver) => boolean
}

export class QueriesObserver extends Subscribable<QueriesObserverListener> {
private client: QueryClient
private result: QueryObserverResult[]
Expand Down Expand Up @@ -63,6 +71,16 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
return this.result
}

refetch(options?: QueriesRefetchOptions): Promise<QueryObserverResult[]> {
let observers = this.observers

if (options?.filter) {
observers = this.observers.filter(options.filter)
}
Comment on lines +75 to +79
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this just be:

const observers = options?.filter ? this.observers.filter(options.filter) : this.observers


return Promise.all(observers.map(observer => observer.refetch(options)))
}

getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] {
return queries.map((options, index) => {
const defaultedOptions = this.client.defaultQueryObserverOptions(options)
Expand Down
156 changes: 151 additions & 5 deletions src/react/tests/useQueries.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { waitFor } from '@testing-library/react'
import { fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
import { ErrorBoundary } from 'react-error-boundary'

import { queryKey, renderWithClient, setActTimeout, sleep } from './utils'
import { useQueries, QueryClient, UseQueryResult, QueryCache } from '../..'
import {
mockConsoleError,
queryKey,
renderWithClient,
setActTimeout,
sleep,
} from './utils'
import {
useQueries,
QueryClient,
UseQueryResult,
QueryCache,
useQueryErrorResetBoundary,
useQueryClient,
} from '../..'
import { QueryState } from '../../core/query'

describe('useQueries', () => {
const queryCache = new QueryCache()
Expand All @@ -25,7 +40,7 @@ describe('useQueries', () => {
{
queryKey: key2,
queryFn: async () => {
await sleep(10)
await sleep(7)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this just to make the tests faster?

return 2
},
},
Expand All @@ -36,14 +51,145 @@ describe('useQueries', () => {

renderWithClient(queryClient, <Page />)

await sleep(30)
await sleep(10)

expect(results.length).toBe(3)
expect(results[0]).toMatchObject([{ data: undefined }, { data: undefined }])
expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }])
expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }])
})

it('should return the correct states with suspense', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you maybe add a separate test for only useErrorBoundary without suspense ?

const key1 = queryKey()
const key2 = queryKey()
const results: (QueryState<unknown, undefined>[] | UseQueryResult[])[] = []

function Fallback() {
const qc = useQueryClient()
const queryStates = [qc.getQueryState(key1)!, qc.getQueryState(key2)!]

results.push(queryStates)

return null
}

function Page() {
const result = useQueries([
{
queryKey: key1,
queryFn: async () => {
await sleep(5)
return 1
},
suspense: true,
},
{
queryKey: key2,
queryFn: async () => {
await sleep(7)
return 2
},
},
])
results.push(result)
return null
}

renderWithClient(
queryClient,
<React.Suspense fallback={<Fallback />}>
<Page />
</React.Suspense>
)

await waitFor(() => {
expect(results[0]).toMatchObject([
{ data: undefined },
{ data: undefined },
])
expect(results[1]).toMatchObject([{ data: 1 }, { data: 2 }])
expect(results.length).toBe(2)
})
})

it('should retry fetch if the reset error boundary has been reset with global hook', async () => {
const key1 = queryKey()
const key2 = queryKey()

let succeed = false
const consoleMock = mockConsoleError()

function Page() {
const results = useQueries([
{
queryKey: key1,
queryFn: async () => {
await sleep(5)
if (!succeed) {
throw new Error('Suspense Error Bingo')
} else {
return 'data1'
}
},
retry: false,
suspense: true,
},
{
queryKey: key2,
queryFn: () => 'data2',
staleTime: Infinity,
},
])

return (
<div>
<div>data1: {results[0]?.data}</div>
<div>data2: {results[1]?.data}</div>
</div>
)
}

function App() {
const { reset } = useQueryErrorResetBoundary()
return (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<div>error boundary</div>
<button
onClick={() => {
resetErrorBoundary()
}}
>
retry
</button>
</div>
)}
>
<React.Suspense fallback="Loading...">
<Page />
</React.Suspense>
</ErrorBoundary>
)
}

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

await waitFor(() => rendered.getByText('Loading...'))
await waitFor(() => rendered.getByText('error boundary'))
await waitFor(() => rendered.getByText('retry'))
fireEvent.click(rendered.getByText('retry'))
await waitFor(() => rendered.getByText('error boundary'))
await waitFor(() => rendered.getByText('retry'))
succeed = true
fireEvent.click(rendered.getByText('retry'))
await waitFor(() => rendered.getByText('data1: data1'))
await waitFor(() => rendered.getByText('data2: data2'))

consoleMock.mockRestore()
})

it('should keep previous data if amount of queries is the same', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regarding the test failures: could it be that the tests somehow influence each other? I see no failures on master, neither locally nor in CI 🤔

const key1 = queryKey()
const key2 = queryKey()
Expand Down
52 changes: 41 additions & 11 deletions src/react/useQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import React from 'react'
import { notifyManager } from '../core/notifyManager'
import { QueriesObserver } from '../core/queriesObserver'
import { useQueryClient } from './QueryClientProvider'
import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
import { UseQueryOptions, UseQueryResult } from './types'

export function useQueries(queries: UseQueryOptions[]): UseQueryResult[] {
const mountedRef = React.useRef(false)
const [, forceUpdate] = React.useState(0)

const queryClient = useQueryClient()
const errorResetBoundary = useQueryErrorResetBoundary()

const defaultedQueries = queries.map(options => {
const defaultedOptions = queryClient.defaultQueryObserverOptions(options)
Expand All @@ -20,18 +22,30 @@ export function useQueries(queries: UseQueryOptions[]): UseQueryResult[] {
return defaultedOptions
})

const [observer] = React.useState(
() => new QueriesObserver(queryClient, defaultedQueries)
const observerRef = React.useRef(
new QueriesObserver(queryClient, defaultedQueries)
Comment on lines -23 to +26
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we moving away from state to refs please? I explicitly made that change not too long ago :) It's the preferred way of doing one-time initializations - I don't see us writing to observerRef.current anyhwere?

)

const result = observer.getOptimisticResult(defaultedQueries)
const result = observerRef.current.getOptimisticResult(defaultedQueries)

React.useEffect(() => {
// Do not notify on updates because of changes in the options because
// these changes should already be reflected in the optimistic result.
observerRef.current.setQueries(defaultedQueries, { listeners: false })
}, [defaultedQueries])

const someSuspense = defaultedQueries.some(q => q.suspense)
const someUseErrorBoundary = defaultedQueries.some(q => q.useErrorBoundary)
const firstResultWithError = result.find(r => r.error)
const someError = firstResultWithError?.error
const someIsLoading = result.some(r => r.isLoading)

React.useEffect(() => {
mountedRef.current = true

const unsubscribe = observer.subscribe(
const unsubscribe = observerRef.current.subscribe(
notifyManager.batchCalls(() => {
if (mountedRef.current) {
if (mountedRef.current && someIsLoading) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this check for someIsLoading here please? This looks like we're never force-updating if nothing is loading, which doesn't seem right 🤔

forceUpdate(x => x + 1)
}
})
Expand All @@ -41,13 +55,29 @@ export function useQueries(queries: UseQueryOptions[]): UseQueryResult[] {
mountedRef.current = false
unsubscribe()
}
}, [observer])
}, [someIsLoading])

React.useEffect(() => {
// Do not notify on updates because of changes in the options because
// these changes should already be reflected in the optimistic result.
observer.setQueries(defaultedQueries, { listeners: false })
}, [defaultedQueries, observer])
const handleReset = React.useCallback(() => {
errorResetBoundary.clearReset()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can just call this during render, as this has a side-effect. In useBaseQuery.ts, we call this in the catch of the throw observer.fetchOptimistic.

const unsubscribe = observerRef.current.subscribe()
throw observerRef.current
.refetch({ filter: x => x.getCurrentResult().isLoading })
.finally(unsubscribe)
Comment on lines +63 to +65
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we not do something like fetchOptimistic does on the QueryObserver, just for multiple queries? We're also not calling onSuccess / onError / onSettled like we do in useBaseQuery ....

}, [errorResetBoundary])

// Handle suspense and error boundaries
if (someSuspense || someUseErrorBoundary) {
if (someError) {
if (errorResetBoundary.isReset()) {
handleReset()
}
throw someError
}

if (someSuspense && someIsLoading) {
handleReset()
}
}

return result
}