Skip to content

Commit

Permalink
feat(use-data-query): use react-query to cache queries
Browse files Browse the repository at this point in the history
The useDataQuery hook now uses react-query internally to add caching,
query deduplication, automatic retries on errors, automatic refetching
when the window regains focus and several other optimizations.

There are no breaking changes to the useDataQuery api, so you should
be able to update to this version without making any changes to your
app's code.
  • Loading branch information
ismay committed Jul 6, 2021
1 parent 5bc031c commit 87fdcd8
Show file tree
Hide file tree
Showing 8 changed files with 1,072 additions and 269 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@dhis2/cli-style": "^7.2.2",
"@dhis2/cli-utils-docsite": "^2.0.3",
"@testing-library/jest-dom": "^5.0.2",
"@testing-library/react": "^9.4.0",
"@testing-library/react": "^10.0.0",
"@testing-library/react-hooks": "^3.2.1",
"@types/jest": "^24.9.0",
"@types/node": "^13.1.8",
Expand Down
3 changes: 3 additions & 0 deletions services/data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@
"type-check:watch": "yarn type-check --watch",
"test": "d2-app-scripts test",
"coverage": "yarn test --coverage"
},
"dependencies": {
"react-query": "^3.13.11"
}
}
206 changes: 83 additions & 123 deletions services/data/src/__tests__/integration.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,44 @@
import { render, waitForElement, act } from '@testing-library/react'
import React from 'react'
import {
FetchType,
DataEngineLinkExecuteOptions,
ResolvedResourceQuery,
} from '../engine'
import { render, waitFor } from '@testing-library/react'
import React, { ReactNode } from 'react'
import { QueryClient, QueryClientProvider, setLogger } from 'react-query'
import { CustomDataProvider, DataQuery } from '../react'
import { QueryRenderInput } from '../types'

const customData = {
answer: 42,
// eslint-disable-next-line react/display-name
const createWrapper = (mockData, queryClientOptions = {}) => ({
children,
}: {
children?: ReactNode
}) => {
const queryClient = new QueryClient(queryClientOptions)

return (
<QueryClientProvider client={queryClient}>
<CustomDataProvider data={mockData}>{children}</CustomDataProvider>
</QueryClientProvider>
)
}

beforeAll(() => {
// Prevent the react-query logger from logging to the console
setLogger({
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
})
})

afterAll(() => {
// Restore the original react-query logger
setLogger(console)
})

describe('Testing custom data provider and useQuery hook', () => {
it('Should render without failing', async () => {
const data = {
answer: 42,
}
const wrapper = createWrapper(data)
const renderFunction = jest.fn(
({ loading, error, data }: QueryRenderInput) => {
if (loading) return 'loading'
Expand All @@ -23,37 +48,54 @@ describe('Testing custom data provider and useQuery hook', () => {
)

const { getByText } = render(
<CustomDataProvider data={customData}>
<DataQuery query={{ answer: { resource: 'answer' } }}>
{renderFunction}
</DataQuery>
</CustomDataProvider>
<DataQuery query={{ answer: { resource: 'answer' } }}>
{renderFunction}
</DataQuery>,
{ wrapper }
)

expect(getByText(/loading/i)).not.toBeUndefined()
expect(renderFunction).toHaveBeenCalledTimes(1)
expect(renderFunction).toHaveBeenLastCalledWith({
called: true,
data: undefined,
engine: expect.any(Object),
error: undefined,
loading: true,
refetch: expect.any(Function),
engine: expect.any(Object),
})
await waitForElement(() => getByText(/data: /i))

await waitFor(() => {
getByText(/data: /i)
})

expect(getByText(/data: /i)).toHaveTextContent(`data: ${data.answer}`)
expect(renderFunction).toHaveBeenCalledTimes(2)
expect(renderFunction).toHaveBeenLastCalledWith({
called: true,
data,
engine: expect.any(Object),
error: undefined,
loading: false,
data: customData,
refetch: expect.any(Function),
engine: expect.any(Object),
})

expect(getByText(/data: /i)).toHaveTextContent(
`data: ${customData.answer}`
)
})

it('Should render an error', async () => {
const expectedError = new Error('Something went wrong')
const data = {
test: () => {
throw expectedError
},
}
// Disable automatic retries, see: https://react-query.tanstack.com/reference/useQuery
const wrapper = createWrapper(data, {
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderFunction = jest.fn(
({ loading, error, data }: QueryRenderInput) => {
if (loading) return 'loading'
Expand All @@ -63,120 +105,38 @@ describe('Testing custom data provider and useQuery hook', () => {
)

const { getByText } = render(
<CustomDataProvider data={customData}>
<DataQuery query={{ test: { resource: 'test' } }}>
{renderFunction}
</DataQuery>
</CustomDataProvider>
<DataQuery query={{ test: { resource: 'test' } }}>
{renderFunction}
</DataQuery>,
{ wrapper }
)

expect(getByText(/loading/i)).not.toBeUndefined()
expect(renderFunction).toHaveBeenCalledTimes(1)
expect(renderFunction).toHaveBeenLastCalledWith({
called: true,
data: undefined,
engine: expect.any(Object),
error: undefined,
loading: true,
refetch: expect.any(Function),
engine: expect.any(Object),
})
await waitForElement(() => getByText(/error: /i))
expect(renderFunction).toHaveBeenCalledTimes(2)
expect(String(renderFunction.mock.calls[1][0].error)).toBe(
'Error: No data provided for resource type test!'
)
// expect(getByText(/data: /i)).toHaveTextContent(
// `data: ${customData.answer}`
// )
})

it('Should abort the fetch when unmounted', async () => {
const renderFunction = jest.fn(
({ loading, error, data }: QueryRenderInput) => {
if (loading) return 'loading'
if (error) return <div>error: {error.message}</div>
return <div>data: {data && data.test}</div>
}
)

let signal: AbortSignal | null | undefined
const mockData = {
factory: jest.fn(
async (
type: FetchType,
_: ResolvedResourceQuery,
options?: DataEngineLinkExecuteOptions
) => {
if (options && options.signal && !signal) {
signal = options.signal
}
return 'done'
}
),
}

const { unmount } = render(
<CustomDataProvider data={mockData}>
<DataQuery query={{ test: { resource: 'factory' } }}>
{renderFunction}
</DataQuery>
</CustomDataProvider>
)

expect(renderFunction).toHaveBeenCalledTimes(1)
expect(mockData.factory).toHaveBeenCalledTimes(1)
act(() => {
unmount()
await waitFor(() => {
getByText(/error: /i)
})
expect(signal && signal.aborted).toBe(true)
})

it('Should abort the fetch when refetching', async () => {
let refetch: any
const renderFunction = jest.fn(
({ loading, error, data, refetch: _refetch }: QueryRenderInput) => {
refetch = _refetch
if (loading) return 'loading'
if (error) return <div>error: {error.message}</div>
return <div>data: {data && data.test}</div>
}
)

let signal: any
const mockData = {
factory: jest.fn(
async (
type: FetchType,
q: ResolvedResourceQuery,
options?: DataEngineLinkExecuteOptions
) => {
if (options && options.signal && !signal) {
signal = options.signal
}
return 'test'
}
),
}

const { getByText } = render(
<CustomDataProvider data={mockData}>
<DataQuery query={{ test: { resource: 'factory' } }}>
{renderFunction}
</DataQuery>
</CustomDataProvider>
expect(renderFunction).toHaveBeenCalledTimes(2)
expect(getByText(/error: /i)).toHaveTextContent(
`error: ${expectedError.message}`
)

expect(renderFunction).toHaveBeenCalledTimes(1)
expect(mockData.factory).toHaveBeenCalledTimes(1)

expect(signal.aborted).toBe(false)
expect(refetch).not.toBeUndefined()
act(() => {
refetch()
expect(renderFunction).toHaveBeenLastCalledWith({
called: true,
data: undefined,
engine: expect.any(Object),
error: expectedError,
loading: false,
refetch: expect.any(Function),
})

expect(signal.aborted).toBe(true)
await waitForElement(() => getByText(/data: /i))

expect(renderFunction).toHaveBeenCalledTimes(2)
expect(mockData.factory).toHaveBeenCalledTimes(2)
})
})
4 changes: 2 additions & 2 deletions services/data/src/__tests__/mutations.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, act, waitForElement } from '@testing-library/react'
import { render, act, waitFor } from '@testing-library/react'
import React from 'react'
import { Mutation, FetchType, ResolvedResourceQuery, JsonMap } from '../engine'
import { CustomDataProvider, DataMutation } from '../react'
Expand Down Expand Up @@ -71,7 +71,7 @@ describe('Test mutations', () => {
])
expect(mockBackend.target).toHaveBeenCalledTimes(1)

await waitForElement(() => getByText(/data: /i))
await waitFor(() => getByText(/data: /i))
expect(renderFunction).toHaveBeenCalledTimes(3)
expect(renderFunction).toHaveBeenLastCalledWith([
doMutation,
Expand Down
13 changes: 9 additions & 4 deletions services/data/src/react/components/DataProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useConfig } from '@dhis2/app-service-config'
import React from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { DataEngine } from '../../engine'
import { RestAPILink } from '../../links'
import { DataContext } from '../context/DataContext'
Expand All @@ -9,6 +10,9 @@ export interface ProviderInput {
apiVersion?: number
children: React.ReactNode
}

const queryClient = new QueryClient()

export const DataProvider = (props: ProviderInput) => {
const config = {
...useConfig(),
Expand All @@ -17,12 +21,13 @@ export const DataProvider = (props: ProviderInput) => {

const link = new RestAPILink(config)
const engine = new DataEngine(link)

const context = { engine }

return (
<DataContext.Provider value={context}>
{props.children}
</DataContext.Provider>
<QueryClientProvider client={queryClient}>
<DataContext.Provider value={context}>
{props.children}
</DataContext.Provider>
</QueryClientProvider>
)
}
Loading

0 comments on commit 87fdcd8

Please sign in to comment.