Skip to content

Commit

Permalink
feat: support network request aborting and refetching (#34)
Browse files Browse the repository at this point in the history
* feat: support network request aborting

* fix: also include alert() in DataQuery renderprop

* fix: don't expose imperative abort, refactor useDataQuery

* feat: add ability to refetch data, upgrade react, tests

* chore: run yarn autoclean for all type deps

* chore: avoid Object Rest Spread in reduce callback

* docs: update README to reference required polyfills
  • Loading branch information
amcgee authored Aug 15, 2019
1 parent fcddcf7 commit dcb4a70
Show file tree
Hide file tree
Showing 10 changed files with 196 additions and 59 deletions.
5 changes: 5 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@ const { config } = require('@dhis2/cli-style')

module.exports = {
extends: [config.eslint],
plugins: ['react-hooks'],
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
},
}
1 change: 1 addition & 0 deletions .yarnclean
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@types/**/node_modules
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,19 @@
"@testing-library/react": "^9.1.1",
"@types/jest": "^24.0.17",
"@types/node": "^12.7.1",
"@types/react": "^16.8.8",
"@types/react-dom": "^16.8.2",
"@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5",
"@typescript-eslint/eslint-plugin": "^1.13.0",
"@typescript-eslint/parser": "^1.13.0",
"concurrently": "^4.1.0",
"eslint": "^5",
"eslint-config-prettier": "^6.0.0",
"eslint-plugin-react-hooks": "^1.6.1",
"jest": "^24.8.0",
"loop": "^3.3.2",
"prop-types": "^15.7.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"rimraf": "^2.6.3",
"rollup": "^1.19.4",
"rollup-plugin-analyzer": "^3.1.2",
Expand Down
9 changes: 9 additions & 0 deletions runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ yarn add @dhis2/app-runtime

This library uses the official React Context API (introduced in 16.3) and React Hooks (introduced in 16.8), so **React >= 16.8 is required** to use it

## Required Polyfills

The following must be polyfilled to support older and non-compliant browsers (i.e. IE11):

* es6.promise (i.e. [core-js/features/promise](https://github.com/zloirock/core-js))
* window.fetch (i.e. [whatwg-fetch](https://github.com/github/fetch))
* AbortController / AbortSignal (i.e. [abortcontroller-polyfill](https://www.npmjs.com/package/abortcontroller-polyfill))


## Usage

The `@dhis2/app-runtime` library is a thin wrapper around application services. See each service's README for usage instructions. Currently, the included services are:
Expand Down
88 changes: 82 additions & 6 deletions services/data/src/__tests__/integration.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React from 'react'
import { useDataQuery } from '../hooks/useDataQuery'
import { render, waitForElement } from '@testing-library/react'
import { render, waitForElement, act } from '@testing-library/react'
import { CustomDataProvider } from '../components/CustomDataProvider'
import { DataQuery } from '../components/DataQuery'
import { QueryRenderInput } from '../types/Query'
import { QueryRenderInput, QueryDefinition, RefetchCallback } from '../types/Query'

const customData = {
answer: 42,
Expand Down Expand Up @@ -31,26 +30,26 @@ describe('Testing custom data provider and useQuery hook', () => {
expect(renderFunction).toHaveBeenCalledTimes(1)
expect(renderFunction).toHaveBeenLastCalledWith({
loading: true,
refetch: expect.any(Function)
})
await waitForElement(() => getByText(/data: /i))
expect(renderFunction).toHaveBeenCalledTimes(2)
expect(renderFunction).toHaveBeenLastCalledWith({
loading: false,
data: customData,
refetch: expect.any(Function)
})
expect(getByText(/data: /i)).toHaveTextContent(
`data: ${customData.answer}`
)
})
})

describe('Testing custom data provider and useQuery hook', () => {
it('Should render an error', 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}</div>
return <div>data: {data.test}</div>
}
)

Expand All @@ -66,6 +65,7 @@ describe('Testing custom data provider and useQuery hook', () => {
expect(renderFunction).toHaveBeenCalledTimes(1)
expect(renderFunction).toHaveBeenLastCalledWith({
loading: true,
refetch: expect.any(Function)
})
await waitForElement(() => getByText(/error: /i))
expect(renderFunction).toHaveBeenCalledTimes(2)
Expand All @@ -76,4 +76,80 @@ describe('Testing custom data provider and useQuery hook', () => {
// `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.test}</div>
}
)

let signal: AbortSignal | null | undefined
const mockData = {
factory: jest.fn((_: QueryDefinition, options: RequestInit) => {
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() })
expect(signal && signal.aborted).toBe(true)
})

it('Should abort the fetch when refetching', async () => {
let refetch: RefetchCallback | undefined;
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.test}</div>
}
)

let signal: AbortSignal | null | undefined
const mockData = {
factory: jest.fn((_: QueryDefinition, options: RequestInit) => {
if (!signal) {
signal = options.signal // only capture first signal
}
return 'test'
})
}

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

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

expect(refetch).not.toBeUndefined();
act(() => {
if (!refetch) {
throw 'help'
}
refetch()
})

expect(renderFunction).toHaveBeenCalledTimes(2)
expect(mockData.factory).toHaveBeenCalledTimes(2)
expect(signal && signal.aborted).toBe(true)
})
})
4 changes: 2 additions & 2 deletions services/data/src/components/DataQuery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface QueryInput {
}

export const DataQuery = ({ query, children }: QueryInput) => {
const { loading, error, data } = useDataQuery(query)
const queryState = useDataQuery(query)

return children({ loading, error, data })
return children(queryState)
}
11 changes: 7 additions & 4 deletions services/data/src/context/makeCustomContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export type CustomResourceLiteral =
| object
| FetchErrorPayload
export type CustomResourceFactory = (
query: QueryDefinition
query: QueryDefinition,
options?: RequestInit
) => Promise<CustomResourceLiteral>
export type CustomResource = CustomResourceLiteral | CustomResourceFactory
export interface CustomContextData {
Expand All @@ -22,12 +23,13 @@ export interface CustomContextData {
export interface CustomContextOptions {
loadForever?: boolean
failOnMiss?: boolean
options?: RequestInit
}

const resolveCustomResource = async (
customResource: CustomResource,
query: QueryDefinition,
{ failOnMiss }: CustomContextOptions
{ failOnMiss, options }: CustomContextOptions
): Promise<CustomResource> => {
switch (typeof customResource) {
case 'string':
Expand All @@ -37,7 +39,7 @@ const resolveCustomResource = async (
return customResource
case 'function':
// function
const result = await customResource(query)
const result = await customResource(query, options)
if (!result && failOnMiss) {
throw new Error(
`The custom function for resource ${query.resource} must always return a value but returned ${result}`
Expand All @@ -55,7 +57,7 @@ export const makeCustomContext = (
{ failOnMiss = true, loadForever = false }: CustomContextOptions = {}
): ContextType => {
const apiUrl = joinPath(baseUrl, 'api', String(apiVersion))
const customFetch: FetchFunction = async query => {
const customFetch: FetchFunction = async (query, options) => {
const customResource = customData[query.resource]
if (!customResource) {
if (failOnMiss) {
Expand All @@ -68,6 +70,7 @@ export const makeCustomContext = (

return await resolveCustomResource(customResource, query, {
failOnMiss,
options,
})
}
const foreverLoadingFetch: FetchFunction = async () => {
Expand Down
71 changes: 52 additions & 19 deletions services/data/src/hooks/useDataQuery.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,62 @@
import { useState, useContext, useEffect } from 'react'
import { useState, useContext, useEffect, useCallback } from 'react'
import { DataContext } from '../components/DataContext'
import { QueryState, QueryMap } from '../types/Query'
import {
QueryState,
QueryMap,
RefetchCallback,
QueryRenderInput,
} from '../types/Query'
import { ContextType } from '../types/Context'

export const useDataQuery = (query: QueryMap): QueryState => {
const reduceResponses = (responses: any[], names: string[]) =>
responses.reduce((out, response, idx) => {
out[names[idx]] = response
}, {})

const fetchData = (
context: ContextType,
query: QueryMap,
signal: AbortSignal
) => {
const names = Object.keys(query)
const requests = names.map(name => query[name])

const context = useContext(DataContext)
const requestPromises = requests.map(q =>
context.fetch(q, {
signal: signal,
})
)

return Promise.all(requestPromises).then(responses =>
reduceResponses(responses, names)
)
}

export const useDataQuery = (query: QueryMap): QueryRenderInput => {
const context = useContext(DataContext)
const [state, setState] = useState<QueryState>({ loading: true })
const [refetchCount, setRefetchCount] = useState(0)
const refetch: RefetchCallback = useCallback(
() => setRefetchCount(count => count + 1),
[]
)

useEffect(() => {
Promise.all(requests.map(q => context.fetch(q)))
.then(responses =>
responses.reduce(
(out, response, idx) => ({
...out,
[names[idx]]: response,
}),
[]
)
)
.then(data => setState({ loading: false, data }))
.catch(error => setState({ loading: false, error }))
}, [])

return state
const controller = new AbortController()
const abort = () => controller.abort()

fetchData(context, query, controller.signal)
.then(data => {
!controller.signal.aborted && setState({ loading: false, data })
})
.catch(error => {
!controller.signal.aborted &&
setState({ loading: false, error })
})

// Cleanup inflight requests
return abort
}, [context, query, refetchCount])

return { refetch, ...state }
}
8 changes: 6 additions & 2 deletions services/data/src/types/Query.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@ export interface QueryDefinition extends QueryParameters {

export type QueryMap = Record<string, QueryDefinition>

export type RefetchCallback = () => void

export interface QueryState {
loading: boolean
error?: FetchError
data?: any
}

export interface QueryRenderInput extends QueryState {
refetch: RefetchCallback
}

/*
// TODO: Use Union type for better static typeguards in consumer
export interface QueryStateLoading {
Expand All @@ -49,6 +55,4 @@ export interface QueryStateData {
export type QueryState = QueryStateLoading | QueryStateError | QueryStateData
*/

export type QueryRenderInput = QueryState

export type QueryResult = any
Loading

0 comments on commit dcb4a70

Please sign in to comment.