diff --git a/README.md b/README.md index c4b70e0c..924b6acc 100644 --- a/README.md +++ b/README.md @@ -964,6 +964,7 @@ Todos - .json() ['application/json'] - .text() ['text/plain'] - .blob() ['image/png', 'application/octet-stream'] +- [ ] is making a [gitpod](https://www.gitpod.io/docs/configuration/) useful here? 🤔 - [ ] suspense - [ ] triggering it from outside the `` component. - add `.read()` to `request` @@ -997,21 +998,6 @@ Todos - [ ] show comparison with Apollo - [ ] figure out a good way to show side-by-side comparisons - [ ] show comparison with Axios -- [ ] maybe add syntax for middle helpers for inline `headers` or `queries` like this: - - ```jsx - const request = useFetch('https://example.com') - - request - .headers({ - auth: jwt // this would inline add the `auth` header - }) - .query({ // might have to use .params({ }) since we're using .query() for GraphQL - no: 'way' // this would inline make the url: https://example.com?no=way - }) - .get() - ``` - - [ ] potential option ideas ```jsx @@ -1021,6 +1007,14 @@ Todos // to overwrite those of `useFetch` for // `useMutation` and `useQuery` }, + responseType: 'json', // similar to axios + // OR can be an array. We will try to get the `data` + // by attempting to extract it via these body interface + // methods, one by one in this order + responseType: ['json', 'text', 'blob', 'formData', 'arrayBuffer'], + // ALSO, maybe there's a way to guess the proper `body interface method` for the correct response content-type. + // here's a stackoverflow with someone who's tried: https://bit.ly/2X8iaVG + // Allows you to pass in your own cache to useFetch // This is controversial though because `cache` is an option in the requestInit // and it's value is a string. See: https://developer.mozilla.org/en-US/docs/Web/API/Request/cache diff --git a/package.json b/package.json index 696aec34..7dd8dc20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "use-http", - "version": "0.4.4", + "version": "0.4.5", "homepage": "http://use-http.com", "main": "dist/index.js", "license": "MIT", diff --git a/src/__tests__/doFetchArgs.test.tsx b/src/__tests__/doFetchArgs.test.tsx index e819b01c..01b356bb 100644 --- a/src/__tests__/doFetchArgs.test.tsx +++ b/src/__tests__/doFetchArgs.test.tsx @@ -1,6 +1,6 @@ import doFetchArgs from '../doFetchArgs' import { HTTPMethod } from '../types' -import { defaults } from '../useFetchArgs' +import defaults from '../defaults' import useCache from '../useCache' describe('doFetchArgs: general usages', (): void => { diff --git a/src/__tests__/useFetch.test.tsx b/src/__tests__/useFetch.test.tsx index fb47a70d..867149d5 100644 --- a/src/__tests__/useFetch.test.tsx +++ b/src/__tests__/useFetch.test.tsx @@ -1,14 +1,16 @@ /* eslint-disable no-var */ /* eslint-disable camelcase */ /* eslint-disable @typescript-eslint/camelcase */ -import React, { ReactElement, ReactNode } from 'react' +import React, { ReactElement, ReactNode, useEffect } from 'react' import { useFetch, Provider } from '..' import { cleanup } from '@testing-library/react' +import * as test from '@testing-library/react' import { FetchMock } from 'jest-fetch-mock' import { toCamel } from 'convert-keys' import { renderHook, act } from '@testing-library/react-hooks' import mockConsole from 'jest-mock-console' import * as mockdate from 'mockdate' +import defaults from '../defaults' import { Res, Options, CachePolicies } from '../types' import { emptyCustomResponse, sleep, makeError } from '../utils' @@ -92,11 +94,42 @@ describe('useFetch - BROWSER - basic functionality', (): void => { var formData = new FormData() formData.append('username', 'AlexCory') await result.current.post(formData) - const options = fetch.mock.calls[0][1] || {} + const options = fetch.mock.calls[0][1] || { headers: {} } expect(options.method).toBe('POST') - expect(options.headers).toBeUndefined() + expect('Content-Type' in (options as any).headers).toBe(false) }) }) + + it('should not cause infinite loop with `[request]` as dependency', async () => { + function Section() { + const { request, data } = useFetch('https://a.co') + useEffect(() => { + request.get() + }, [request]) + return
{JSON.stringify(data)}
+ } + const { container } = test.render(
) + + await test.act(async (): Promise => await sleep(100)) + expect(JSON.parse(container.textContent as string)).toEqual(expected) + }) + + it('should not cause infinite loop with `[response]` as dependency', async () => { + function Section() { + const { request, response, data } = useFetch('https://a.co') + useEffect(() => { + (async () => { + await request.get() + if (!response.ok) console.error('no okay') + })() + }, [request, response]) + return
{JSON.stringify(data)}
+ } + const { container } = test.render(
) + + await test.act(async (): Promise => await sleep(100)) + expect(JSON.parse(container.textContent as string)).toEqual(expected) + }) }) describe('useFetch - BROWSER - with ', (): void => { @@ -591,8 +624,9 @@ describe('useFetch - BROWSER - Overwrite Global Options set in Provider', (): vo }) it('should only add Content-Type: application/json for POST and PUT by default', async (): Promise => { - const expectedHeadersGET = providerHeaders + const expectedHeadersGET = { ...defaults.headers, ...providerHeaders } const expectedHeadersPOSTandPUT = { + ...defaults.headers, ...providerHeaders, 'Content-Type': 'application/json' } @@ -613,7 +647,10 @@ describe('useFetch - BROWSER - Overwrite Global Options set in Provider', (): vo }) it('should have the correct headers set in the options set in the Provider', async (): Promise => { - const expectedHeaders = providerHeaders + const expectedHeaders = { + ...defaults.headers, + ...providerHeaders + } const { result } = renderHook( () => useFetch(), { wrapper } @@ -625,7 +662,7 @@ describe('useFetch - BROWSER - Overwrite Global Options set in Provider', (): vo }) it('should overwrite url and options set in the Provider', async (): Promise => { - const expectedHeaders = undefined + const expectedHeaders = defaults.headers const expectedURL = 'https://example2.com' const { result, waitForNextUpdate } = renderHook( () => useFetch(expectedURL, globalOptions => { @@ -644,7 +681,7 @@ describe('useFetch - BROWSER - Overwrite Global Options set in Provider', (): vo }) it('should overwrite options set in the Provider', async (): Promise => { - const expectedHeaders = undefined + const expectedHeaders = defaults.headers const { result, waitForNextUpdate } = renderHook( () => useFetch(globalOptions => { // TODO: fix the generics here so it knows when a header diff --git a/src/__tests__/useFetchArgs.test.tsx b/src/__tests__/useFetchArgs.test.tsx index 790cde07..701bc2f5 100644 --- a/src/__tests__/useFetchArgs.test.tsx +++ b/src/__tests__/useFetchArgs.test.tsx @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react-hooks' -import useFetchArgs, { useFetchArgsDefaults } from '../useFetchArgs' +import useFetchArgs from '../useFetchArgs' +import defaults, { useFetchArgsDefaults } from '../defaults' import React, { ReactElement, ReactNode } from 'react' import { Provider } from '..' @@ -183,7 +184,11 @@ describe('useFetchArgs: general usages', (): void => { url: 'https://example.com' }, requestInit: { - ...options + ...options, + headers: { + ...defaults.headers, + ...options.headers + } } }) }) @@ -201,7 +206,10 @@ describe('useFetchArgs: general usages', (): void => { url: 'http://localhost' }, requestInit: { - ...options + headers: { + ...defaults.headers, + ...options.headers + } } }) }) @@ -231,7 +239,11 @@ describe('useFetchArgs: general usages', (): void => { url: 'http://localhost' }, requestInit: { - ...overwriteProviderOptions + ...overwriteProviderOptions, + headers: { + ...defaults.headers, + ...overwriteProviderOptions.headers + } } }) }) diff --git a/src/defaults.ts b/src/defaults.ts new file mode 100644 index 00000000..f06f7052 --- /dev/null +++ b/src/defaults.ts @@ -0,0 +1,38 @@ +import { Flatten, CachePolicies, UseFetchArgsReturn } from './types' +import { isObject } from './utils' + + +export const useFetchArgsDefaults: UseFetchArgsReturn = { + customOptions: { + cacheLife: 0, + cachePolicy: CachePolicies.CACHE_FIRST, + interceptors: {}, + onAbort: () => { /* do nothing */ }, + onNewData: (currData: any, newData: any) => newData, + onTimeout: () => { /* do nothing */ }, + path: '', + perPage: 0, + persist: false, + retries: 0, + retryDelay: 1000, + retryOn: [], + suspense: false, + timeout: 0, + url: '', + }, + requestInit: { + headers: { + Accept: 'application/json, text/plain, */*' + } + }, + defaults: { + data: undefined, + loading: false + }, + dependencies: undefined +} + +export default Object.entries(useFetchArgsDefaults).reduce((acc, [key, value]) => { + if (isObject(value)) return { ...acc, ...value } + return { ...acc, [key]: value } +}, {} as Flatten) diff --git a/src/doFetchArgs.ts b/src/doFetchArgs.ts index 0999dcce..3489b434 100644 --- a/src/doFetchArgs.ts +++ b/src/doFetchArgs.ts @@ -44,8 +44,8 @@ export default async function doFetchArgs( ((bodyAs2ndParam as any) instanceof FormData || (bodyAs2ndParam as any) instanceof URLSearchParams) ) return bodyAs2ndParam as any - if (isBodyObject(bodyAs2ndParam)) return JSON.stringify(bodyAs2ndParam) - if (isBodyObject(initialOptions.body)) return JSON.stringify(initialOptions.body) + if (isBodyObject(bodyAs2ndParam) || isString(bodyAs2ndParam)) return JSON.stringify(bodyAs2ndParam) + if (isBodyObject(initialOptions.body) || isString(bodyAs2ndParam)) return JSON.stringify(initialOptions.body) return null })() diff --git a/src/types.ts b/src/types.ts index e96d7340..4d44dcd6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -206,6 +206,32 @@ export type OverwriteGlobalOptions = (options: Options) => Options export type RetryOn = (({ attempt, error, response }: { attempt: number, error: Error, response: Res | null }) => boolean) | number[] export type RetryDelay = (({ attempt, error, response }: { attempt: number, error: Error, response: Res | null }) => number) | number +export type UseFetchArgsReturn = { + customOptions: { + cacheLife: number + cachePolicy: CachePolicies + interceptors: Interceptors + onAbort: () => void + onNewData: (currData: any, newData: any) => any + onTimeout: () => void + path: string + perPage: number + persist: boolean + retries: number + retryDelay: RetryDelay + retryOn: RetryOn | undefined + suspense: boolean + timeout: number + url: string + } + requestInit: RequestInit + defaults: { + loading: boolean + data?: any + } + dependencies?: any[] +} + /** * Helpers */ diff --git a/src/useFetch.ts b/src/useFetch.ts index d3a237ef..9a9bccbc 100644 --- a/src/useFetch.ts +++ b/src/useFetch.ts @@ -59,15 +59,18 @@ function useFetch(...args: UseFetchArgs): UseFetch { const suspender = useRef>() const mounted = useRef(false) - const [loading, setLoading] = useState(defaults.loading) + const loading = useRef(defaults.loading) + const setLoadingState = useState(defaults.loading)[1] + const setLoading = (v: boolean) => { + if (!mounted.current) return + loading.current = v + setLoadingState(v) + } const forceUpdate = useReducer(() => ({}), [])[1] const makeFetch = useDeepCallback((method: HTTPMethod): FetchData => { - const doFetch = async ( - routeOrBody?: RouteOrBody, - body?: Body - ): Promise => { + const doFetch = async (routeOrBody?: RouteOrBody, body?: Body): Promise => { if (isServer) return // for now, we don't do anything on the server controller.current = new AbortController() controller.current.signal.onabort = onAbort @@ -91,8 +94,9 @@ function useFetch(...args: UseFetchArgs): UseFetch { if (response.isCached && cachePolicy === CACHE_FIRST) { try { res.current = response.cached as Res - res.current.data = await tryGetData(response.cached, defaults.data) - data.current = res.current.data as TData + const d = await tryGetData(response.cached, defaults.data) + res.current.data = d + data.current = d if (!suspense && mounted.current) forceUpdate() return data.current } catch (err) { @@ -101,7 +105,7 @@ function useFetch(...args: UseFetchArgs): UseFetch { } } - if (!suspense && mounted.current) setLoading(true) + if (!suspense) setLoading(true) // don't perform the request if there is no more data to fetch (pagination) if (perPage > 0 && !hasMore.current && !error.current) return data.current @@ -137,8 +141,8 @@ function useFetch(...args: UseFetchArgs): UseFetch { ) && retries > 0 && retries > attempt.current if (shouldRetry) { - const data = await retry(opts, routeOrBody, body) - return data + const theData = await retry(opts, routeOrBody, body) + return theData } if (cachePolicy === CACHE_FIRST) { @@ -158,8 +162,8 @@ function useFetch(...args: UseFetchArgs): UseFetch { ) && retries > 0 && retries > attempt.current if (shouldRetry) { - const temp = await retry(opts, routeOrBody, body) - return temp + const theData = await retry(opts, routeOrBody, body) + return theData } if (err.name !== 'AbortError') error.current = makeError(err.name, err.message) @@ -170,7 +174,7 @@ function useFetch(...args: UseFetchArgs): UseFetch { } if (newRes && !newRes.ok && !error.current) error.current = makeError(newRes.status, newRes.statusText) - if (!suspense && mounted.current) setLoading(false) + if (!suspense) setLoading(false) if (attempt.current === retries) attempt.current = 0 return data.current @@ -210,21 +214,22 @@ function useFetch(...args: UseFetchArgs): UseFetch { const post = useCallback(makeFetch(HTTPMethod.POST), [makeFetch]) const del = useCallback(makeFetch(HTTPMethod.DELETE), [makeFetch]) - const request: Req = { - get: useCallback(makeFetch(HTTPMethod.GET), [makeFetch]), + const request: Req = useMemo(() => Object.defineProperties({ + get: makeFetch(HTTPMethod.GET), post, - patch: useCallback(makeFetch(HTTPMethod.PATCH), [makeFetch]), - put: useCallback(makeFetch(HTTPMethod.PUT), [makeFetch]), + patch: makeFetch(HTTPMethod.PATCH), + put: makeFetch(HTTPMethod.PUT), del, delete: del, abort: () => controller.current && controller.current.abort(), - query: (query, variables) => post({ query, variables }), - mutate: (mutation, variables) => post({ mutation, variables }), - loading, - error: error.current, - data: data.current, + query: (query: any, variables: any) => post({ query, variables }), + mutate: (mutation: any, variables: any) => post({ mutation, variables }), cache - } + }, { + loading: { get: () => loading.current }, + error: { get: () => error.current }, + data: { get: () => data.current }, + }), [makeFetch]) const response = useMemo(() => toResponseObject(res, data), []) @@ -254,8 +259,8 @@ function useFetch(...args: UseFetchArgs): UseFetch { } } return Object.assign, UseFetchObjectReturn>( - [request, response, loading, error.current], - { request, response, ...request } + [request, response, loading.current, error.current], + { request, response, ...request, loading: loading.current, data: data.current, error: error.current } ) } diff --git a/src/useFetchArgs.ts b/src/useFetchArgs.ts index 47b36a4d..c5cf6642 100644 --- a/src/useFetchArgs.ts +++ b/src/useFetchArgs.ts @@ -1,64 +1,9 @@ -import { OptionsMaybeURL, NoUrlOptions, Flatten, CachePolicies, Interceptors, OverwriteGlobalOptions, Options, RetryOn, RetryDelay } from './types' +import { OptionsMaybeURL, NoUrlOptions, CachePolicies, Interceptors, OverwriteGlobalOptions, Options, RetryOn, RetryDelay, UseFetchArgsReturn } from './types' import { isString, isObject, invariant, pullOutRequestInit, isFunction, isPositiveNumber } from './utils' import { useContext, useMemo } from 'react' import FetchContext from './FetchContext' +import defaults from './defaults' -type UseFetchArgsReturn = { - customOptions: { - cacheLife: number - cachePolicy: CachePolicies - interceptors: Interceptors - onAbort: () => void - onNewData: (currData: any, newData: any) => any - onTimeout: () => void - path: string - perPage: number - persist: boolean - retries: number - retryDelay: RetryDelay - retryOn: RetryOn | undefined - suspense: boolean - timeout: number - url: string - } - requestInit: RequestInit - defaults: { - loading: boolean - data?: any - } - dependencies?: any[] -} - -export const useFetchArgsDefaults: UseFetchArgsReturn = { - customOptions: { - cacheLife: 0, - cachePolicy: CachePolicies.CACHE_FIRST, - interceptors: {}, - onAbort: () => { /* do nothing */ }, - onNewData: (currData: any, newData: any) => newData, - onTimeout: () => { /* do nothing */ }, - path: '', - perPage: 0, - persist: false, - retries: 0, - retryDelay: 1000, - retryOn: [], - suspense: false, - timeout: 0, - url: '', - }, - requestInit: { headers: {} }, - defaults: { - data: undefined, - loading: false - }, - dependencies: undefined -} - -export const defaults = Object.entries(useFetchArgsDefaults).reduce((acc, [key, value]) => { - if (isObject(value)) return { ...acc, ...value } - return { ...acc, [key]: value } -}, {} as Flatten) const useField = ( field: keyof OptionsMaybeURL | keyof NoUrlOptions, @@ -171,6 +116,7 @@ export default function useFetchArgs( ...contextRequestInit, ...requestInit, headers: { + ...defaults.headers, ...contextRequestInit.headers, ...requestInit.headers } diff --git a/src/utils.ts b/src/utils.ts index 2ba89ea9..26c41c08 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -155,7 +155,7 @@ export const tryGetData = async (res: Response | undefined, defaultData: any) => data = (await response.text()) as any // FIXME: should not be `any` type } catch (er) {} } - return (defaultData && isEmpty(data)) ? defaultData : data + return !isEmpty(defaultData) && isEmpty(data) ? defaultData : data } /**