diff --git a/README.md b/README.md index 62fc693f..d327b2ff 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- + @@ -89,7 +89,6 @@ Usage ### Examples + Videos - useFetch - managed state, request, response, etc. [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-request-response-managed-state-ruyi3?file=/src/index.js) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=_-GujYZFCKI&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=6) -- useFetch - route, path, Provider, etc. [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-with-provider-c78w2) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=JWDL_AVOYT0&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=10) - useFetch - request/response interceptors [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-provider-requestresponse-interceptors-s1lex) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=3HauoWh0Jts&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=8) - useFetch - retries, retryOn, retryDelay [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-retryon-retrydelay-s74q9) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=grE3AX-Q9ss&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=9) - useFetch - abort, timeout, onAbort, onTimeout [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=7SuD3ZOfu7E&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=4) @@ -101,7 +100,7 @@ Usage - useFetch - Next.js [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-in-nextjs-nn9fm) - useFetch - create-react-app [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/embed/km04k9k9x5) -

Basic Usage (managed state) useFetch +
Basic Usage Managed State useFetch If the last argument of `useFetch` is not a dependency array `[]`, then it will not fire until you call one of the http methods like `get`, `post`, etc. @@ -147,20 +146,18 @@ function Todos() {
-
Basic Usage (auto managed state) useFetch +
Basic Usage Auto-Managed State useFetch -This fetch is run `onMount/componentDidMount`. The last argument `[]` means it will run `onMount`. If you pass it a variable like `[someVariable]`, it will run `onMount` and again whenever `someVariable` changes values (aka `onUpdate`). **If no method is specified, GET is the default** +This fetch is run `onMount/componentDidMount`. The last argument `[]` means it will run `onMount`. If you pass it a variable like `[someVariable]`, it will run `onMount` and again whenever `someVariable` changes values (aka `onUpdate`). If no method is specified, GET is the default. ```js import useFetch from 'use-http' function Todos() { - // accepts all `fetch` options - const options = { - data: [], // setting default for `data` as array instead of undefined - } - - const { loading, error, data } = useFetch('https://example.com/todos', options, []) // onMount (GET by default) + const { loading, error, data } = useFetch('https://example.com/todos', { + // these options accept all native `fetch` options + data: [] // defaults the `data` to an array instead of `undefined` + }, []) // <- this [] means it will fire onMount (GET by default) return ( <> @@ -179,43 +176,7 @@ function Todos() {
- -
Basic Usage (auto managed state) with Provider - -```js -import useFetch, { Provider } from 'use-http' - -function Todos() { - const { loading, error, data } = useFetch({ - path: '/todos', - data: [] - }, []) // onMount - - return ( - <> - {error && 'Error!'} - {loading && 'Loading...'} - {data.map(todo => ( -
{todo.title}
- )} - - ) -} - -const App = () => ( - - - -) -``` - - -
- - -
- -
Suspense Mode (auto managed state) +
Suspense Mode(experimental) Auto-Managed State Can put `suspense` in 2 places. Either `useFetch` (A) or `Provider` (B). @@ -223,8 +184,7 @@ Can put `suspense` in 2 places. Either `useFetch` (A) or `Provider` (B). import useFetch, { Provider } from 'use-http' function Todos() { - const { data: todos } = useFetch({ - path: '/todos', + const { data: todos } = useFetch('/todos', { data: [], suspense: true // A. can put `suspense: true` here }, []) // onMount @@ -250,9 +210,9 @@ function App() {
-
Suspense Mode (managed state) +
Suspense Mode(experimental) Managed State -Can put `suspense` in 2 places. Either `useFetch` (A) or `Provider` (B). +Can put `suspense` in 2 places. Either `useFetch` (A) or `Provider` (B). Suspense mode via managed state is very experimental. ```js import useFetch, { Provider } from 'use-http' @@ -328,8 +288,7 @@ import useFetch, { Provider } from 'use-http' const Todos = () => { const [page, setPage] = useState(1) - const { data, loading } = useFetch({ - path: `/todos?page=${page}&amountPerPage=15`, + const { data, loading } = useFetch(`/todos?page=${page}&amountPerPage=15`, { onNewData: (currTodos, newTodos) => [...currTodos, ...newTodos], // appends newly fetched todos perPage: 15, // stops making more requests if last todos fetched < 15 data: [] @@ -430,10 +389,7 @@ var {
Relative routes useFetch -⚠️ `baseUrl` is no longer supported, it is now only `url` ```jsx -var request = useFetch({ url: 'https://example.com' }) -// OR var request = useFetch('https://example.com') request.post('/todos', { @@ -451,17 +407,15 @@ request.post('/todos', { ```jsx -const githubRepos = useFetch({ - url: `https://api.github.com/search/repositories?q=` -}) +const { get, abort, loading, data: repos } = useFetch('https://api.github.com/search/repositories?q=') // the line below is not isomorphic, but for simplicity we're using the browsers `encodeURI` -const searchGithubRepos = e => githubRepos.get(encodeURI(e.target.value)) +const searchGithubRepos = e => get(encodeURI(e.target.value)) <> - - {githubRepos.loading ? 'Loading...' : githubRepos.data.items.map(repo => ( + + {loading ? 'Loading...' : repos.data.items.map(repo => (
{repo.name}
))} @@ -853,7 +807,6 @@ This is exactly what you would pass to the normal js `fetch`, with a little extr | `onError` | Runs when the request get's an error. If retrying, it is only called on the last retry attempt. | empty function | | `onNewData` | Merges the current data with the incoming data. Great for pagination. | `(curr, new) => new` | | `onTimeout` | Called when the request times out. | empty function | -| `path` | When using a global `url` set in the `Provider`, this is useful for adding onto it | `''` | | `persist` | Persists data for the duration of `cacheLife`. If `cacheLife` is not set it defaults to 24h. Currently only available in Browser. | `false` | | `perPage` | Stops making more requests if there is no more data to fetch. (i.e. if we have 25 todos, and the perPage is 10, after fetching 2 times, we will have 20 todos. The last 5 tells us we don't have any more to fetch because it's less than 10) For pagination. | `0` | | `responseType` | This will determine how the `data` field is set. If you put `json` then it will try to parse it as JSON. If you set it as an array, it will attempt to parse the `response` in the order of the types you put in the array. Read about why we don't put `formData` in the defaults [in the yellow Note part here](https://developer.mozilla.org/en-US/docs/Web/API/Body/formData). | `['json', 'text', 'blob', 'readableStream']` | @@ -862,7 +815,6 @@ This is exactly what you would pass to the normal js `fetch`, with a little extr | `retryOn` | You can retry on certain http status codes or have custom logic to decide whether to retry or not via a function. Make sure `retries > 0` otherwise it won't retry. | `[]` | | `suspense` | Enables Experimental React Suspense mode. [example](https://codesandbox.io/s/usefetch-suspense-i22wv) | `false` | | `timeout` | The request will be aborted/cancelled after this amount of time. This is also the interval at which `retries` will be made at. **in milliseconds**. If set to `0`, it will not timeout except for browser defaults. | `0` | -| `url` | Allows you to set a base path so relative paths can be used for each request :) | empty string | ```jsx const options = { @@ -906,9 +858,6 @@ const options = { // called when the request times out onTimeout: () => {}, - // if you have a global `url` set up, this is how you can add to it - path: '/path/to/your/api', - // this will tell useFetch not to run the request if the list doesn't haveMore. (pagination) // i.e. if the last page fetched was < 15, don't run the request again perPage: 15, @@ -957,9 +906,6 @@ const options = { // amount of time before the request get's canceled/aborted timeout: 10000, - - // used to be `baseUrl`. You can set your URL this way instead of as the 1st argument - url: 'https://example.com', } useFetch(options) @@ -1001,6 +947,9 @@ If you have feature requests, [submit an issue][1] to let us know what you would Todos ------ +- [ ] prefetching +- [ ] global cache state management +- [ ] optimistic updates - [ ] `persist` support for React Native - [ ] better loading state management. When using only 1 useFetch in a component and we use `Promise.all([get('/todos/1'), get('/todos/2')])` then don't have a loading true, diff --git a/docs/README.md b/docs/README.md index cd21021c..f9902464 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@

🐶 React hook for making isomorphic http requests

- + @@ -67,7 +67,6 @@ Examples + Videos ========= - useFetch - managed state, request, response, etc. [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-request-response-managed-state-ruyi3?file=/src/index.js) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=_-GujYZFCKI&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=6) -- useFetch - route, path, Provider, etc. [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-with-provider-c78w2) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=JWDL_AVOYT0&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=10) - useFetch - request/response interceptors [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-provider-requestresponse-interceptors-s1lex) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=3HauoWh0Jts&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=8) - useFetch - retries, retryOn, retryDelay [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-retryon-retrydelay-s74q9) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=grE3AX-Q9ss&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=9) - useFetch - abort, timeout, onAbort, onTimeout [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=7SuD3ZOfu7E&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=4) @@ -115,20 +114,19 @@ yarn add use-http or npm i -S use-http Usage ============= -Basic Usage (auto managed state) +Basic Usage Auto-Managed State ------------------- -This fetch is run `onMount/componentDidMount`. The last argument `[]` means it will run `onMount`. If you pass it a variable like `[someVariable]`, it will run `onMount` and again whenever `someVariable` changes values (aka `onUpdate`). **If no method is specified, GET is the default** +This fetch is run `onMount/componentDidMount`. The last argument `[]` means it will run `onMount`. If you pass it a variable like `[someVariable]`, it will run `onMount` and again whenever `someVariable` changes values (aka `onUpdate`). If no method is specified, GET is the default. ```js import useFetch from 'use-http' function Todos() { - const options = { // accepts all `fetch` options - data: [] // default for `data` will be an array instead of undefined - } - - const { loading, error, data } = useFetch('https://example.com/todos', options, []) // onMount (GET by default) + const { loading, error, data } = useFetch('https://example.com/todos', { + // these options accept all native `fetch` options + data: [] // defaults the `data` to an array instead of `undefined` + }, []) // <- this [] means it will fire onMount (GET by default) return ( <> @@ -190,50 +188,14 @@ function Todos() { -Basic Usage With Provider (auto managed state) ---------------------------------------------- - -```js -import useFetch, { Provider } from 'use-http' - -function Todos() { - const { loading, error, data } = useFetch({ - path: '/todos', - data: [] // default for `data` will be an array instead of undefined - }, []) // onMount - - return ( - <> - {error && 'Error!'} - {loading && 'Loading...'} - {data.map(todo => ( -

{todo.title}
- )} - - ) -} - -const App = () => ( - - - -) -``` - - - - - - -Suspense Mode (auto managed state) +Suspense Mode Auto-Managed State ---------------------------------- ```js import useFetch, { Provider } from 'use-http' function Todos() { - const { data: todos } = useFetch({ - path: '/todos', + const { data: todos } = useFetch('/todos', { data: [], suspense: true // can put it in 2 places. Here or in Provider }, []) // onMount @@ -257,7 +219,7 @@ function App() { -Suspense Mode (managed state) +Suspense Mode Managed State ----------------------------- Can put `suspense` in 2 places. Either `useFetch` (A) or `Provider` (B). @@ -309,8 +271,7 @@ import useFetch, { Provider } from 'use-http' const Todos = () => { const [page, setPage] = useState(1) - const { data, loading } = useFetch({ - path: `/todos?page=${page}&amountPerPage=15`, + const { data, loading } = useFetch(`/todos?page=${page}&amountPerPage=15`, { onNewData: (currTodos, newTodos) => [...currTodos, ...newTodos], // appends newly fetched todos perPage: 15, // stops making more requests if last todos fetched < 15 data: [] @@ -407,11 +368,7 @@ var { Relative routes --------------- -⚠️ `baseUrl` is no longer supported, it is now only `url` - ```js -var request = useFetch({ url: 'https://example.com' }) -// OR var request = useFetch('https://example.com') request.post('/todos', { @@ -427,17 +384,15 @@ Abort ```js -const githubRepos = useFetch({ - url: `https://api.github.com/search/repositories?q=` -}) +const { get, abort, loading, data: repos } = useFetch('https://api.github.com/search/repositories?q=') // the line below is not isomorphic, but for simplicity we're using the browsers `encodeURI` -const searchGithubRepos = e => githubRepos.get(encodeURI(e.target.value)) +const searchGithubRepos = e => get(encodeURI(e.target.value)) <> - - {githubRepos.loading ? 'Loading...' : githubRepos.data.items.map(repo => ( + + {loading ? 'Loading...' : repos.data.items.map(repo => (
{repo.name}
))} @@ -804,7 +759,6 @@ This is exactly what you would pass to the normal js `fetch`, with a little extr | `onError` | Runs when the request get's an error. If retrying, it is only called on the last retry attempt. | empty function | | `onNewData` | Merges the current data with the incoming data. Great for pagination. | `(curr, new) => new` | | `onTimeout` | Called when the request times out. | empty function | -| `path` | When using a global `url` set in the `Provider`, this is useful for adding onto it | `''` | | `persist` | Persists data for the duration of `cacheLife`. If `cacheLife` is not set it defaults to 24h. Currently only available in Browser. | `false` | | `responseType` | This will determine how the `data` field is set. If you put `json` then it will try to parse it as JSON. If you set it as an array, it will attempt to parse the `response` in the order of the types you put in the array. Read about why we don't put `formData` in the defaults [in the yellow Note part here](https://developer.mozilla.org/en-US/docs/Web/API/Body/formData). | `['json', 'text', 'blob', 'readableStream']` | | `perPage` | Stops making more requests if there is no more data to fetch. (i.e. if we have 25 todos, and the perPage is 10, after fetching 2 times, we will have 20 todos. The last 5 tells us we don't have any more to fetch because it's less than 10) For pagination. | `0` | @@ -813,7 +767,6 @@ This is exactly what you would pass to the normal js `fetch`, with a little extr | `retryOn` | You can retry on certain http status codes or have custom logic to decide whether to retry or not via a function. Make sure `retries > 0` otherwise it won't retry. | `[]` | | `suspense` | Enables Experimental React Suspense mode. [example](https://codesandbox.io/s/usefetch-suspense-i22wv) | `false` | | `timeout` | The request will be aborted/cancelled after this amount of time. This is also the interval at which `retries` will be made at. **in milliseconds**. If set to `0`, it will not timeout except for browser defaults. | `0` | -| `url` | Allows you to set a base path so relative paths can be used for each request :) | empty string | ```jsx const options = { @@ -857,9 +810,6 @@ const options = { // called when the request times out onTimeout: () => {}, - // if you have a global `url` set up, this is how you can add to it - path: '/path/to/your/api', - // this will tell useFetch not to run the request if the list doesn't haveMore. (pagination) // i.e. if the last page fetched was < 15, don't run the request again perPage: 15, @@ -909,9 +859,6 @@ const options = { // amount of time before the request get's canceled/aborted timeout: 10000, - - // used to be `baseUrl`. You can set your URL this way instead of as the 1st argument - url: 'https://example.com', } useFetch(options) diff --git a/src/__tests__/doFetchArgs.test.tsx b/src/__tests__/doFetchArgs.test.tsx index 55d13dc8..76cea4c1 100644 --- a/src/__tests__/doFetchArgs.test.tsx +++ b/src/__tests__/doFetchArgs.test.tsx @@ -18,12 +18,12 @@ describe('doFetchArgs: general usages', (): void => { }) const { url, options } = await doFetchArgs( {}, - '', - '', HTTPMethod.POST, controller, defaults.cacheLife, cache, + '', + '', expectedRoute, {} ) @@ -47,12 +47,12 @@ describe('doFetchArgs: general usages', (): void => { }) const { options, url } = await doFetchArgs( {}, - 'https://example.com', - '', HTTPMethod.POST, controller, defaults.cacheLife, cache, + 'https://example.com', + '', '/test', [] ) @@ -76,12 +76,12 @@ describe('doFetchArgs: general usages', (): void => { }) const { url } = await doFetchArgs( {}, - 'https://example.com', - '/path', HTTPMethod.POST, controller, defaults.cacheLife, cache, + 'https://example.com', + '/path', '/route', {} ) @@ -103,12 +103,12 @@ describe('doFetchArgs: general usages', (): void => { } const { options } = await doFetchArgs( {}, - '', - '', HTTPMethod.POST, controller, defaults.cacheLife, cache, + undefined, + '', '/test', {}, interceptors.request @@ -154,12 +154,12 @@ describe('doFetchArgs: Errors', (): void => { await expect( doFetchArgs( {}, - '', - '', HTTPMethod.GET, controller, defaults.cacheLife, cache, + '', + '', {}, {} ) @@ -196,12 +196,12 @@ describe('doFetchArgs: Errors', (): void => { await expect( doFetchArgs( {}, - '', - '', HTTPMethod.GET, controller, defaults.cacheLife, cache, + '', + '', [], [] ) diff --git a/src/__tests__/useFetch.test.tsx b/src/__tests__/useFetch.test.tsx index 128ab646..daba0154 100644 --- a/src/__tests__/useFetch.test.tsx +++ b/src/__tests__/useFetch.test.tsx @@ -12,8 +12,8 @@ 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' +import { Res, IncomingOptions, CachePolicies } from '../types' +import { emptyCustomResponse, sleep, makeError, addSlash } from '../utils' const fetch = global.fetch as FetchMock @@ -132,6 +132,22 @@ describe('useFetch - BROWSER - basic functionality', (): void => { }) }) +describe('useFetch - handling host/path/route parsing properly', (): void => { + it ('should have addSlash run properly', (): void => { + expect(addSlash('', '')).toBe('') + expect(addSlash('')).toBe('') + expect(addSlash('?foo=bar', 'a.com')).toBe('?foo=bar') + expect(addSlash('?foo=bar', 'a.com/')).toBe('?foo=bar') + expect(addSlash('?foo=bar')).toBe('?foo=bar') + expect(addSlash('/foo', 'a.com')).toBe('/foo') + expect(addSlash('/foo', 'a.com/')).toBe('foo') + expect(addSlash('foo', 'a.com')).toBe('/foo') + expect(addSlash('foo', 'a.com/')).toBe('foo') + expect(addSlash('foo')).toBe('/foo') + expect(addSlash('/foo')).toBe('/foo') + }) +}) + describe('useFetch - responseType', (): void => { afterEach((): void => { cleanup() @@ -187,7 +203,7 @@ describe('useFetch - BROWSER - with ', (): void => { fetch.mockResponseOnce(JSON.stringify(expected)) }) - it('should work correctly: useFetch({ data: [] }, [])', async (): Promise => { + it(`should work correctly: useFetch({ data: [] }, [])`, async (): Promise => { const { result, waitForNextUpdate } = renderHook( () => useFetch({ data: {} }, []), // onMount === true { wrapper } @@ -232,8 +248,7 @@ describe('useFetch - BROWSER - with ', (): void => { it('should merge the data onNewData for pagination', async (): Promise => { const { result, waitForNextUpdate } = renderHook( - () => useFetch({ - path: '/people', + () => useFetch('/people', { data: { no: 'way' }, onNewData: (currData, newData) => ({ ...currData, ...newData }) }, []), // onMount === true @@ -251,14 +266,13 @@ describe('useFetch - BROWSER - with ', (): void => { it('should not make another request when there is no more data `perPage` pagination', async (): Promise => { fetch.resetMocks() const expected1 = [1, 2, 3] - fetch.mockResponse(JSON.stringify(expected1)) + fetch.mockResponseOnce(JSON.stringify(expected1)) + .mockResponseOnce(JSON.stringify([4])) const { result, rerender, waitForNextUpdate } = renderHook( ({ page }) => useFetch(`https://example.com?page=${page}`, { data: [], perPage: 3, onNewData: (currData, newData) => { - // to imitate getting a response with less items - if (page === 2) return [...currData, 4] return [...currData, ...newData] } }, [page]), // onMount === true @@ -276,16 +290,15 @@ describe('useFetch - BROWSER - with ', (): void => { expect(result.current.data).toEqual([...expected1, 4]) expect(fetch.mock.calls.length).toBe(2) act(() => rerender({ page: 3 })) - await waitForNextUpdate() expect(result.current.data).toEqual([...expected1, 4]) expect(fetch.mock.calls.length).toBe(2) }) - it('should execute GET using Provider url: useFetch({ path: "/people" }, [])', async (): Promise< + it(`should execute GET using Provider url: useFetch('/people', [])`, async (): Promise< void > => { const { result, waitForNextUpdate } = renderHook( - () => useFetch({ path: '/people' }, []), // onMount === true + () => useFetch('/people', []), // onMount === true { wrapper } ) expect(result.current.loading).toBe(true) @@ -349,13 +362,12 @@ describe('timeouts', (): void => { const onAbort = jest.fn() const onTimeout = jest.fn() const { result, waitForNextUpdate } = renderHook( - () => useFetch({ + () => useFetch('/todos', { retries: 1, // TODO: this test times out if `retryDelay > 0` // works in apps, not sure how to advance the timers correctly retryDelay: 0, timeout, - path: '/todos', onAbort, onTimeout }, []), // onMount === true @@ -496,8 +508,7 @@ describe('useFetch - BROWSER - with - Managed State', (): void => { it('should re-run the request when onUpdate dependencies are updated', async (): Promise => { const { result, waitForNextUpdate, rerender } = renderHook( - ({ initialValue }) => useFetch({ - path: `/${initialValue}`, + ({ initialValue }) => useFetch(`/${initialValue}`, { data: {} }, [initialValue]), // (onMount && onUpdate) === true { @@ -520,8 +531,7 @@ describe('useFetch - BROWSER - with - Managed State', (): void => { it('should fetch cached data when cached path is requested', async (): Promise => { const { result, waitForNextUpdate, rerender } = renderHook( - ({ initialValue }) => useFetch({ - path: `/a/${initialValue}`, + ({ initialValue }) => useFetch(`/a/${initialValue}`, { data: {} }, [initialValue]), // (onMount && onUpdate) === true { @@ -554,21 +564,11 @@ describe('useFetch - BROWSER - interceptors', (): void => { const snake_case = { title: 'Alex Cory', first_name: 'Alex' } const expected = { title: 'Alex Cory', firstName: 'Alex' } + const request = jest.fn(({ options }) => options) const wrapper = ({ children }: { children?: ReactNode }): ReactElement => { - const options: Options = { + const options: IncomingOptions = { interceptors: { - request: async ({ options: opts, url, path, route }) => { - if (path === '/path') { - opts.data = 'path' - } - if (url === 'url') { - opts.data = 'url' - } - if (route === '/route') { - opts.data = 'route' - } - return opts - }, + request, async response({ response: res }) { if (res.data) res.data = toCamel(res.data) return res @@ -584,6 +584,7 @@ describe('useFetch - BROWSER - interceptors', (): void => { afterEach((): void => { fetch.resetMocks() cleanup() + request.mockClear() }) beforeEach((): void => { @@ -612,11 +613,12 @@ describe('useFetch - BROWSER - interceptors', (): void => { it('should pass the proper path string to `interceptors.request`', async (): Promise => { const { result } = renderHook( - () => useFetch({ path: '/path' }), + () => useFetch('/path'), { wrapper } ) await act(result.current.get) - expect((fetch.mock.calls[0][1] as any).data).toEqual('path') + expect(fetch.mock.calls[0][0]).toBe('https://example.com/path') + expect(request.mock.calls[0][0].path).toBe('/path') }) it('should pass the proper route string to `interceptors.request`', async (): Promise => { @@ -627,16 +629,18 @@ describe('useFetch - BROWSER - interceptors', (): void => { await act(async () => { await result.current.get('/route') }) - expect((fetch.mock.calls[0][1] as any).data).toEqual('route') + expect(fetch.mock.calls[0][0]).toBe('https://example.com/route') + expect(request.mock.calls[0][0].route).toBe('/route') }) it('should pass the proper url string to `interceptors.request`', async (): Promise => { const { result } = renderHook( - () => useFetch('url'), + () => useFetch(), { wrapper } ) await act(result.current.get) - expect((fetch.mock.calls[0][1] as any).data).toEqual('url') + expect(fetch.mock.calls[0][0]).toBe('https://example.com') + expect(request.mock.calls[0][0].url).toBe('https://example.com') }) it('should still call both interceptors when using cache', async (): Promise => { @@ -679,7 +683,7 @@ describe('useFetch - BROWSER - Overwrite Global Options set in Provider', (): vo }) beforeEach((): void => { - fetch.mockResponseOnce(JSON.stringify({})) + fetch.mockResponse(JSON.stringify({})) }) it('should only add Content-Type: application/json for POST and PUT by default', async (): Promise => { @@ -739,7 +743,7 @@ describe('useFetch - BROWSER - Overwrite Global Options set in Provider', (): vo expect(fetch).toHaveBeenCalledTimes(1) }) - it('should overwrite options set in the Provider', async (): Promise => { + it('should overwrite options set in the Provider and not every instance of useFetch', async (): Promise => { const expectedHeaders = defaults.headers const { result, waitForNextUpdate } = renderHook( () => useFetch(globalOptions => { @@ -755,6 +759,14 @@ describe('useFetch - BROWSER - Overwrite Global Options set in Provider', (): vo expect(fetch.mock.calls[0][0]).toBe('https://example.com') expect((fetch.mock.calls[0][1] as any).headers).toEqual(expectedHeaders) expect(fetch).toHaveBeenCalledTimes(1) + const expectedHeadersGET = { ...defaults.headers, ...providerHeaders } + const { waitForNextUpdate: wait2 } = renderHook( + () => useFetch('/', []), // onMount === true + { wrapper } + ) + await wait2() + expect((fetch.mock.calls[1][1] as any).headers).toEqual(expectedHeadersGET) + expect(fetch).toHaveBeenCalledTimes(2) }) }) @@ -1060,8 +1072,7 @@ describe('useFetch - BROWSER - errors', (): void => { fetch.resetMocks() fetch.mockResponse('fail', { status: 401 }) const { result } = renderHook( - () => useFetch({ - url: 'https://example.com', + () => useFetch('https://example.com', { data: [], cachePolicy: NO_CACHE }) @@ -1094,8 +1105,7 @@ describe('useFetch - BROWSER - errors', (): void => { fetch.resetMocks() fetch.mockReject(expectedError) const { result } = renderHook( - () => useFetch({ - url: 'https://example.com/2', + () => useFetch('https://example.com/2', { data: [], cachePolicy: NO_CACHE }) @@ -1177,7 +1187,7 @@ describe('useFetch - BROWSER - persistence', (): void => { it('should fetch once', async (): Promise => { const { waitForNextUpdate } = renderHook( - () => useFetch({ url: 'https://persist.com', persist: true }, []) + () => useFetch('https://persist.com', { persist: true }, []) ) await waitForNextUpdate() expect(fetch).toHaveBeenCalledTimes(1) @@ -1187,7 +1197,7 @@ describe('useFetch - BROWSER - persistence', (): void => { fetch.mockResponse(JSON.stringify(unexpected)) const { result, waitForNextUpdate } = renderHook( - () => useFetch({ url: 'https://persist.com', persist: true }, []) + () => useFetch('https://persist.com', { persist: true }, []) ) await waitForNextUpdate() expect(fetch).toHaveBeenCalledTimes(0) @@ -1204,7 +1214,7 @@ describe('useFetch - BROWSER - persistence', (): void => { mockdate.set('2020-01-02 02:00:00') const { waitForNextUpdate } = renderHook( - () => useFetch({ url: 'https://persist.com', persist: true }, []) + () => useFetch('https://persist.com', { persist: true }, []) ) await waitForNextUpdate() expect(fetch).toHaveBeenCalledTimes(1) @@ -1212,7 +1222,7 @@ describe('useFetch - BROWSER - persistence', (): void => { it('should have `cache` in the return of useFetch', async (): Promise => { const { result } = renderHook( - () => useFetch({ url: 'https://persist.com', persist: true }) + () => useFetch('https://persist.com', { persist: true }) ) expect(result.current.cache).toBeDefined() expect(result.current.cache.get).toBeInstanceOf(Function) @@ -1224,14 +1234,14 @@ describe('useFetch - BROWSER - persistence', (): void => { it('should error if passing wrong cachePolicy with persist: true', async (): Promise => { var { result } = renderHook( - () => useFetch({ url: 'https://persist.com', persist: true, cachePolicy: NO_CACHE }, []) + () => useFetch('https://persist.com', { persist: true, cachePolicy: NO_CACHE }, []) ) expect(result.error.name).toBe('Invariant Violation') expect(result.error.message).toBe('You cannot use option \'persist\' with cachePolicy: no-cache 🙅‍♂️') // eslint-disable-next-line var { result } = renderHook( - () => useFetch({ url: 'https://persist.com', persist: true, cachePolicy: NETWORK_ONLY }, []) + () => useFetch('https://persist.com', { persist: true, cachePolicy: NETWORK_ONLY }, []) ) expect(result.error.name).toBe('Invariant Violation') expect(result.error.message).toBe('You cannot use option \'persist\' with cachePolicy: network-only 🙅‍♂️') diff --git a/src/__tests__/useFetchArgs.test.tsx b/src/__tests__/useFetchArgs.test.tsx index 701bc2f5..a3dc1062 100644 --- a/src/__tests__/useFetchArgs.test.tsx +++ b/src/__tests__/useFetchArgs.test.tsx @@ -19,26 +19,22 @@ describe('useFetchArgs: general usages', (): void => { ) expect(result.current).toEqual({ ...useFetchArgsDefaults, + host: 'https://example.com', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'https://example.com' } }) }) it('should create custom options with 1st arg as config object with `onMount: true`', (): void => { const { result } = renderHook((): any => - useFetchArgs({ - url: 'https://example.com' - }, []) // onMount === true + useFetchArgs('https://example.com', []) // onMount === true ) expect(result.current).toEqual({ ...useFetchArgsDefaults, + host: 'https://example.com', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'https://example.com' - }, - defaults: { loading: true, data: undefined }, @@ -50,9 +46,9 @@ describe('useFetchArgs: general usages', (): void => { const { result } = renderHook((): any => useFetchArgs(), { wrapper }) expect(result.current).toStrictEqual({ ...useFetchArgsDefaults, + host: 'https://example.com', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'https://example.com' } }) }) @@ -64,11 +60,9 @@ describe('useFetchArgs: general usages', (): void => { ) expect(result.current).toStrictEqual({ ...useFetchArgsDefaults, + host: 'https://cool.com', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'https://cool.com' - }, - defaults: { loading: true, data: undefined }, @@ -83,11 +77,9 @@ describe('useFetchArgs: general usages', (): void => { ) expect(result.current).toStrictEqual({ ...useFetchArgsDefaults, + host: 'https://example.com', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'https://example.com' - }, - defaults: { loading: false, data: [] } @@ -105,9 +97,9 @@ describe('useFetchArgs: general usages', (): void => { const expected = { ...useFetchArgsDefaults, + host: 'http://localhost', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'http://localhost' } } expect(result.current).toEqual(expected) @@ -179,9 +171,9 @@ describe('useFetchArgs: general usages', (): void => { const { result } = renderHook((): any => useFetchArgs(options), { wrapper }) expect(result.current).toStrictEqual({ ...useFetchArgsDefaults, + host: 'https://example.com', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'https://example.com' }, requestInit: { ...options, @@ -198,12 +190,12 @@ describe('useFetchArgs: general usages', (): void => { const wrapper = ({ children }: { children?: ReactNode }): ReactElement => ( {children} ) - const { result } = renderHook((): any => useFetchArgs(), { wrapper }) + const { result } = renderHook((): any => useFetchArgs('http://localhost'), { wrapper }) expect(result.current).toStrictEqual({ ...useFetchArgsDefaults, + host: 'http://localhost', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'http://localhost' }, requestInit: { headers: { @@ -234,9 +226,9 @@ describe('useFetchArgs: general usages', (): void => { ) expect(result.current).toStrictEqual({ ...useFetchArgsDefaults, + host: 'http://localhost', customOptions: { ...useFetchArgsDefaults.customOptions, - url: 'http://localhost' }, requestInit: { ...overwriteProviderOptions, @@ -250,35 +242,11 @@ describe('useFetchArgs: general usages', (): void => { }) describe('useFetchArgs: Errors', (): void => { - it('should error if no url string is set and no Provider is in place', (): void => { - const { result } = renderHook((): any => useFetchArgs()) - expect(result.error.name).toBe('Invariant Violation') - expect(result.error.message).toBe( - 'The first argument of useFetch is required unless you have a global url setup like: ' - ) - }) - - it('should error if 1st arg is object and no URL field is set in it', (): void => { - const { result } = renderHook((): any => useFetchArgs({})) - expect(result.error.name).toBe('Invariant Violation') - expect(result.error.message).toBe( - 'The first argument of useFetch is required unless you have a global url setup like: ' - ) - }) - - it('should error if no URL is specified', (): void => { - const { result } = renderHook((): any => useFetchArgs('')) - expect(result.error.name).toBe('Invariant Violation') - expect(result.error.message).toBe( - 'The first argument of useFetch is required unless you have a global url setup like: ' - ) - }) - it('should error if 1st and 2nd arg are both objects', (): void => { const { result } = renderHook((): any => useFetchArgs({}, {})) expect(result.error.name).toBe('Invariant Violation') expect(result.error.message).toBe( - 'You cannot have a 2nd parameter of useFetch when your first argument is an object config.' + 'You cannot have a 2nd parameter of useFetch as object when your first argument is an object.' ) }) diff --git a/src/defaults.ts b/src/defaults.ts index 9edb00cb..634d377b 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -3,6 +3,8 @@ import { isObject } from './utils' export const useFetchArgsDefaults: UseFetchArgsReturn = { + host: '', + path: undefined, customOptions: { cacheLife: 0, cachePolicy: CachePolicies.CACHE_FIRST, @@ -11,7 +13,6 @@ export const useFetchArgsDefaults: UseFetchArgsReturn = { onError: () => { /* do nothing */ }, onNewData: (currData: any, newData: any) => newData, onTimeout: () => { /* do nothing */ }, - path: '', perPage: 0, persist: false, responseType: ['json', 'text', 'blob', 'arrayBuffer'], @@ -20,17 +21,15 @@ export const useFetchArgsDefaults: UseFetchArgsReturn = { retryOn: [], suspense: false, timeout: 0, - url: '', + // defaults + data: undefined, + loading: false }, requestInit: { headers: { Accept: 'application/json, text/plain, */*' } }, - defaults: { - data: undefined, - loading: false - }, dependencies: undefined } diff --git a/src/doFetchArgs.ts b/src/doFetchArgs.ts index 487d803e..02c08bf1 100644 --- a/src/doFetchArgs.ts +++ b/src/doFetchArgs.ts @@ -1,16 +1,16 @@ import { HTTPMethod, Interceptors, ValueOf, DoFetchArgs, Cache } from './types' -import { invariant, isServer, isString, isBodyObject } from './utils' +import { invariant, isServer, isString, isBodyObject, addSlash } from './utils' const { GET } = HTTPMethod export default async function doFetchArgs( initialOptions: RequestInit, - initialURL: string, - path: string, method: HTTPMethod, controller: AbortController, cacheLife: number, cache: Cache, + host?: string, + path?: string, routeOrBody?: string | BodyInit | object, bodyAs2ndParam?: BodyInit | object, requestInterceptor?: ValueOf> @@ -34,7 +34,7 @@ export default async function doFetchArgs( return '' })() - const url = `${initialURL}${path}${route}` + const url = `${host}${addSlash(path, host)}${addSlash(route)}` const body = ((): BodyInit | null => { // FormData instanceof check should go first, because React Native's FormData implementation @@ -68,7 +68,7 @@ export default async function doFetchArgs( })() const options = await (async (): Promise => { - const opts = { + const opts: RequestInit = { ...initialOptions, method, signal: controller.signal @@ -83,7 +83,7 @@ export default async function doFetchArgs( if (body !== null) opts.body = body if (requestInterceptor) { - const interceptor = await requestInterceptor({ options: opts, url: initialURL, path, route }) + const interceptor = await requestInterceptor({ options: opts, url: host, path, route }) return interceptor as any } return opts diff --git a/src/types.ts b/src/types.ts index db3ff2f0..12248662 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,13 +75,13 @@ export interface DoFetchArgs { export interface FetchContextTypes { url: string - options: Options + options: IncomingOptions graphql?: boolean } export interface FetchProviderProps { url?: string - options?: Options + options?: IncomingOptions graphql?: boolean children: ReactNode } @@ -141,7 +141,7 @@ export interface Res extends Response { export type Req = ReqMethods & ReqBase -export type UseFetchArgs = [(string | OptionsMaybeURL | OverwriteGlobalOptions)?, (NoUrlOptions | OverwriteGlobalOptions | any[])?, any[]?] +export type UseFetchArgs = [(string | IncomingOptions | OverwriteGlobalOptions)?, (IncomingOptions | OverwriteGlobalOptions | any[])?, any[]?] export type UseFetchArrayReturn = [ Req, @@ -160,7 +160,7 @@ export type UseFetch = UseFetchArrayReturn & UseFetchObjectReturn export type Interceptors = { - request?: ({ options, url, path, route }: { options: Options, url: string, path: string, route: string }) => Promise | Options + request?: ({ options, url, path, route }: { options: RequestInit, url?: string, path?: string, route?: string }) => Promise | RequestInit response?: ({ response }: { response: Res }) => Promise> } @@ -174,36 +174,33 @@ export type Cache = { } export interface CustomOptions { - cacheLife?: number - cachePolicy?: CachePolicies - data?: any - interceptors?: Interceptors - loading?: boolean - onAbort?: () => void - onError?: OnError - onNewData?: (currData: any, newData: any) => any - onTimeout?: () => void - path?: string - persist?: boolean - perPage?: number - responseType?: ResponseType - retries?: number - retryOn?: RetryOn - retryDelay?: RetryDelay - suspense?: boolean - timeout?: number - url?: string + cacheLife: number + cachePolicy: CachePolicies + data: any + interceptors: Interceptors + loading: boolean + onAbort: () => void + onError: OnError + onNewData: (currData: any, newData: any) => any + onTimeout: () => void + persist: boolean + perPage: number + responseType: ResponseType + retries: number + retryOn: RetryOn + retryDelay: RetryDelay + suspense: boolean + timeout: number } +// these are the possible options that can be passed +export type IncomingOptions = Partial & + Omit & { body?: BodyInit | object | null } +// these options have `context` and `defaults` applied so +// the values should all be filled export type Options = CustomOptions & Omit & { body?: BodyInit | object | null } -export type NoUrlOptions = Omit - -export type OptionsMaybeURL = NoUrlOptions & - Partial> & { url?: string } - -// TODO: this is still yet to be implemented export type OverwriteGlobalOptions = (options: Options) => Options export type RetryOn = (({ attempt, error, response }: RetryOpts) => Promise) | number[] @@ -215,6 +212,8 @@ export type ResponseType = BodyInterfaceMethods | BodyInterfaceMethods[] export type OnError = ({ error }: { error: Error }) => void export type UseFetchArgsReturn = { + host: string + path?: string customOptions: { cacheLife: number cachePolicy: CachePolicies @@ -223,7 +222,6 @@ export type UseFetchArgsReturn = { onError: OnError onNewData: (currData: any, newData: any) => any onTimeout: () => void - path: string perPage: number persist: boolean responseType: ResponseType @@ -232,13 +230,11 @@ export type UseFetchArgsReturn = { retryOn: RetryOn | undefined suspense: boolean timeout: number - url: string - } - requestInit: RequestInit - defaults: { + // defaults loading: boolean data?: any } + requestInit: RequestInit dependencies?: any[] } diff --git a/src/useFetch.ts b/src/useFetch.ts index ff85361f..10157d45 100644 --- a/src/useFetch.ts +++ b/src/useFetch.ts @@ -26,7 +26,7 @@ const { CACHE_FIRST } = CachePolicies function useFetch(...args: UseFetchArgs): UseFetch { - const { customOptions, requestInit, defaults, dependencies } = useFetchArgs(...args) + const { host, path, customOptions, requestInit, dependencies } = useFetchArgs(...args) const { cacheLife, cachePolicy, // 'cache-first' by default @@ -35,7 +35,6 @@ function useFetch(...args: UseFetchArgs): UseFetch { onError, onNewData, onTimeout, - path, perPage, persist, responseType, @@ -44,7 +43,7 @@ function useFetch(...args: UseFetchArgs): UseFetch { retryOn, suspense, timeout, - url: initialURL, + ...defaults } = customOptions const cache = useCache({ persist, cacheLife, cachePolicy }) @@ -75,12 +74,12 @@ function useFetch(...args: UseFetchArgs): UseFetch { const { url, options, response } = await doFetchArgs( requestInit, - initialURL, - path, method, theController, cacheLife, cache, + host, + path, routeOrBody, body, interceptors.request @@ -88,27 +87,11 @@ function useFetch(...args: UseFetchArgs): UseFetch { error.current = undefined - if (response.isCached && cachePolicy === CACHE_FIRST) { - try { - res.current = response.cached as Res - const theData = await tryGetData(response.cached, defaults.data, responseType) - res.current.data = theData - res.current = interceptors.response ? await interceptors.response({ response: res.current }) : res.current - invariant('data' in res.current, 'You must have `data` field on the Response returned from your `interceptors.response`') - data.current = res.current.data as TData - if (!suspense && mounted.current) forceUpdate() - return data.current - } catch (err) { - error.current = err - if (mounted.current) forceUpdate() - } - } - - 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 + if (!suspense) setLoading(true) + const timer = timeout && setTimeout(() => { timedout.current = true theController.abort() @@ -119,7 +102,11 @@ function useFetch(...args: UseFetchArgs): UseFetch { let newRes try { - newRes = await fetch(url, options) + if (response.isCached && cachePolicy === CACHE_FIRST) { + newRes = response.cached as Response + } else { + newRes = await fetch(url, options) + } res.current = newRes.clone() newData = await tryGetData(newRes, defaults.data, responseType) @@ -209,7 +196,7 @@ function useFetch(...args: UseFetchArgs): UseFetch { } return doFetch - }, [isServer, onAbort, requestInit, initialURL, path, interceptors, cachePolicy, perPage, timeout, persist, cacheLife, onTimeout, defaults.data, onNewData, forceUpdate, suspense]) + }, [isServer, onAbort, requestInit, host, path, interceptors, cachePolicy, perPage, timeout, persist, cacheLife, onTimeout, defaults.data, onNewData, forceUpdate, suspense]) const post = useCallback(makeFetch(HTTPMethod.POST), [makeFetch]) const del = useCallback(makeFetch(HTTPMethod.DELETE), [makeFetch]) diff --git a/src/useFetchArgs.ts b/src/useFetchArgs.ts index 6200ff51..23c9fa70 100644 --- a/src/useFetchArgs.ts +++ b/src/useFetchArgs.ts @@ -1,155 +1,97 @@ -import { OptionsMaybeURL, NoUrlOptions, CachePolicies, Interceptors, OverwriteGlobalOptions, Options, RetryOn, RetryDelay, UseFetchArgsReturn, ResponseType, OnError } from './types' +import { Interceptors, OverwriteGlobalOptions, Options, IncomingOptions, UseFetchArgsReturn, CustomOptions } from './types' import { isString, isObject, invariant, pullOutRequestInit, isFunction, isPositiveNumber } from './utils' import { useContext, useMemo } from 'react' import FetchContext from './FetchContext' -import defaults from './defaults' +import defaults, { useFetchArgsDefaults } from './defaults' -const useField = ( - field: keyof OptionsMaybeURL | keyof NoUrlOptions, - urlOrOptions?: string | OptionsMaybeURL, - optionsNoURLs?: NoUrlOptions | any[] -) => { - const context = useContext(FetchContext) - const contextOptions = context.options || {} - return useMemo((): DV => { - if (isObject(urlOrOptions) && field in urlOrOptions) return urlOrOptions[field] - if (isObject(optionsNoURLs) && field in optionsNoURLs) { - return (optionsNoURLs as NoUrlOptions)[field as keyof NoUrlOptions] - } - if (field in contextOptions) return contextOptions[field] - return defaults[field] - }, [urlOrOptions, field, optionsNoURLs, contextOptions]) -} - export default function useFetchArgs( - urlOrOptionsOrOverwriteGlobal?: string | OptionsMaybeURL | OverwriteGlobalOptions, - optionsNoURLsOrOverwriteGlobalOrDeps?: NoUrlOptions | OverwriteGlobalOptions | any[], + urlOrPathOrOptionsOrOverwriteGlobalOptions?: string | IncomingOptions | OverwriteGlobalOptions, + optionsOrOverwriteGlobalOrDeps?: IncomingOptions | OverwriteGlobalOptions | any[], deps?: any[] ): UseFetchArgsReturn { - const context = useContext(FetchContext) - context.options = useMemo(() => { - const overwriteGlobalOptions = (isFunction(urlOrOptionsOrOverwriteGlobal) ? urlOrOptionsOrOverwriteGlobal : isFunction(optionsNoURLsOrOverwriteGlobalOrDeps) && optionsNoURLsOrOverwriteGlobalOrDeps) as OverwriteGlobalOptions - if (!overwriteGlobalOptions) return context.options - // make a copy so we make sure not to modify the original context - return overwriteGlobalOptions({ ...context.options } as Options) - }, [context.options, optionsNoURLsOrOverwriteGlobalOrDeps, urlOrOptionsOrOverwriteGlobal]) - - const urlOrOptions = urlOrOptionsOrOverwriteGlobal as string | OptionsMaybeURL - const optionsNoURLs = optionsNoURLsOrOverwriteGlobalOrDeps as NoUrlOptions - invariant( - !(isObject(urlOrOptions) && isObject(optionsNoURLs)), - 'You cannot have a 2nd parameter of useFetch when your first argument is an object config.' + !(isObject(urlOrPathOrOptionsOrOverwriteGlobalOptions) && isObject(optionsOrOverwriteGlobalOrDeps)), + 'You cannot have a 2nd parameter of useFetch as object when your first argument is an object.' ) + const context = useContext(FetchContext) - const url = useMemo((): string => { - if (isString(urlOrOptions) && urlOrOptions) return urlOrOptions as string - if (isObject(urlOrOptions) && !!urlOrOptions.url) return urlOrOptions.url + const host = useMemo((): string => { + const maybeHost = urlOrPathOrOptionsOrOverwriteGlobalOptions as string + if (isString(maybeHost) && maybeHost.includes('://')) return maybeHost if (context.url) return context.url - return defaults.url - }, [context.url, urlOrOptions]) + return defaults.host + }, [context.url, urlOrPathOrOptionsOrOverwriteGlobalOptions]) + + const path = useMemo((): string | undefined => { + const maybePath = urlOrPathOrOptionsOrOverwriteGlobalOptions as string + if (isString(maybePath) && !maybePath.includes('://')) return maybePath + }, [urlOrPathOrOptionsOrOverwriteGlobalOptions]) + + const overwriteGlobalOptions = useMemo((): OverwriteGlobalOptions | undefined => { + if (isFunction(urlOrPathOrOptionsOrOverwriteGlobalOptions)) return urlOrPathOrOptionsOrOverwriteGlobalOptions as OverwriteGlobalOptions + if (isFunction(optionsOrOverwriteGlobalOrDeps)) return optionsOrOverwriteGlobalOrDeps as OverwriteGlobalOptions + }, []) + + const options = useMemo(() => { + let localOptions = { headers: {} } as IncomingOptions + if (isObject(urlOrPathOrOptionsOrOverwriteGlobalOptions)) { + localOptions = urlOrPathOrOptionsOrOverwriteGlobalOptions as IncomingOptions + } else if (isObject(optionsOrOverwriteGlobalOrDeps)) { + localOptions = optionsOrOverwriteGlobalOrDeps as IncomingOptions + } + let globalOptions = context.options + const finalOptions = { + ...defaults, + ...globalOptions, + ...localOptions, + headers: { + ...defaults.headers, + ...globalOptions.headers, + ...localOptions.headers + } as Headers + } as Options + if (overwriteGlobalOptions) return overwriteGlobalOptions(finalOptions) + return finalOptions + }, [urlOrPathOrOptionsOrOverwriteGlobalOptions, overwriteGlobalOptions, context.options]) - invariant( - !!url, - 'The first argument of useFetch is required unless you have a global url setup like: ' - ) + const requestInit = useMemo(() => pullOutRequestInit(options), [options]) const dependencies = useMemo((): any[] | undefined => { - if (Array.isArray(optionsNoURLsOrOverwriteGlobalOrDeps)) return optionsNoURLsOrOverwriteGlobalOrDeps + if (Array.isArray(optionsOrOverwriteGlobalOrDeps)) return optionsOrOverwriteGlobalOrDeps if (Array.isArray(deps)) return deps return defaults.dependencies - }, [optionsNoURLsOrOverwriteGlobalOrDeps, deps]) + }, [optionsOrOverwriteGlobalOrDeps, deps]) - const data = useField('data', urlOrOptions, optionsNoURLs) - const cacheLife = useField('cacheLife', urlOrOptions, optionsNoURLs) + const { cacheLife, retries, retryDelay, retryOn } = options invariant(Number.isInteger(cacheLife) && cacheLife >= 0, '`cacheLife` must be a number >= 0') - const cachePolicy = useField('cachePolicy', urlOrOptions, optionsNoURLs) - const onAbort = useField<() => void>('onAbort', urlOrOptions, optionsNoURLs) - const onError = useField('onError', urlOrOptions, optionsNoURLs) - const onNewData = useField<() => void>('onNewData', urlOrOptions, optionsNoURLs) - const onTimeout = useField<() => void>('onTimeout', urlOrOptions, optionsNoURLs) - const path = useField('path', urlOrOptions, optionsNoURLs) - const perPage = useField('perPage', urlOrOptions, optionsNoURLs) - const persist = useField('persist', urlOrOptions, optionsNoURLs) - const responseType = useField('responseType', urlOrOptions, optionsNoURLs) - const retries = useField('retries', urlOrOptions, optionsNoURLs) invariant(Number.isInteger(retries) && retries >= 0, '`retries` must be a number >= 0') - const retryDelay = useField('retryDelay', urlOrOptions, optionsNoURLs) invariant(isFunction(retryDelay) || Number.isInteger(retryDelay as number) && retryDelay >= 0, '`retryDelay` must be a positive number or a function returning a positive number.') - const retryOn = useField('retryOn', urlOrOptions, optionsNoURLs) const isValidRetryOn = isFunction(retryOn) || (Array.isArray(retryOn) && retryOn.every(isPositiveNumber)) invariant(isValidRetryOn, '`retryOn` must be an array of positive numbers or a function returning a boolean.') - const suspense = useField('suspense', urlOrOptions, optionsNoURLs) - const timeout = useField('timeout', urlOrOptions, optionsNoURLs) - - const loading = useMemo((): boolean => { - if (isObject(urlOrOptions)) return !!urlOrOptions.loading || Array.isArray(dependencies) - if (isObject(optionsNoURLs)) return !!optionsNoURLs.loading || Array.isArray(dependencies) - return defaults.loading || Array.isArray(dependencies) - }, [urlOrOptions, dependencies, optionsNoURLs]) + const loading = options.loading || Array.isArray(dependencies) const interceptors = useMemo((): Interceptors => { - const contextInterceptors = context.options && (context.options.interceptors || {}) - const final: Interceptors = { ...contextInterceptors } - if (isObject(urlOrOptions) && isObject(urlOrOptions.interceptors)) { - if (urlOrOptions.interceptors.request) final.request = urlOrOptions.interceptors.request - if (urlOrOptions.interceptors.response) final.response = urlOrOptions.interceptors.response - } - if (isObject(optionsNoURLs) && isObject(optionsNoURLs.interceptors)) { - if (optionsNoURLs.interceptors.request) final.request = optionsNoURLs.interceptors.request - if (optionsNoURLs.interceptors.response) final.response = optionsNoURLs.interceptors.response - } + const final: Interceptors = {} + if ('request' in options.interceptors) final.request = options.interceptors.request + if ('response' in options.interceptors) final.response = options.interceptors.response return final - }, [context.options, urlOrOptions, optionsNoURLs]) - - const requestInit = useMemo((): RequestInit => { - const contextRequestInit = pullOutRequestInit(context.options as OptionsMaybeURL) + }, [options]) - const requestInitOptions = isObject(urlOrOptions) - ? urlOrOptions - : isObject(optionsNoURLs) - ? optionsNoURLs - : {} - - const requestInit = pullOutRequestInit(requestInitOptions) - - return { - ...contextRequestInit, - ...requestInit, - headers: { - ...defaults.headers, - ...contextRequestInit.headers, - ...requestInit.headers - } - } - }, [context.options, urlOrOptions, optionsNoURLs]) + const customOptions = useMemo((): CustomOptions => { + const customOptionKeys = Object.keys(useFetchArgsDefaults.customOptions) as (keyof CustomOptions)[] // Array + const customOptions = customOptionKeys.reduce((opts, key) => { + (opts as any)[key] = options[key] + return opts + }, {} as CustomOptions) + return { ...customOptions, interceptors, loading } + }, [interceptors, loading]) return { - customOptions: { - cacheLife, - cachePolicy, - interceptors, - onAbort, - onError, - onNewData, - onTimeout, - path, - persist, - perPage, - responseType, - retries, - retryDelay, - retryOn, - suspense, - timeout, - url, - }, + host, + path, + customOptions, requestInit, - defaults: { - data, - loading - }, dependencies } } diff --git a/src/utils.ts b/src/utils.ts index 6e1c456e..b9c45511 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { useMemo, useEffect, MutableRefObject, useRef, useCallback, DependencyList } from 'react' import useSSR from 'use-ssr' -import { RequestInitJSON, OptionsMaybeURL, Res, HTTPMethod, ResponseType } from './types' +import { RequestInitJSON, Options, Res, HTTPMethod, ResponseType } from './types' import { FunctionKeys, NonFunctionKeys } from 'utility-types' /** @@ -94,7 +94,7 @@ export const isNumber = (v: any): boolean => Object.prototype.toString.call(v) = * Makes an object that will match the standards of a normal fetch's options * aka: pulls out all useFetch's special options like "onMount" */ -export const pullOutRequestInit = (options?: OptionsMaybeURL): RequestInit => { +export const pullOutRequestInit = (options?: Options): RequestInit => { if (!options) return {} const requestInitFields = [ 'body', @@ -153,12 +153,12 @@ export const tryGetData = async (res: Response | undefined, defaultData: any, re return !isEmpty(defaultData) && isEmpty(data) ? defaultData : data } -const tryRetry = async (res: Response, types: ResponseType): Promise => { +const tryRetry = async (res: Response, types: ResponseType, i: number = 0): Promise => { try { - return (res.clone() as any)[types[0]]() + return await (res.clone() as any)[types[i]]() } catch (error) { - if (types.length === 1) throw error - return tryRetry(res.clone(), (types as any).slice(1)) + if (types.length - 1 === i) throw error + return tryRetry(res.clone(), types, ++i) } } @@ -247,3 +247,29 @@ export const makeError = (name: string | number, message: string) => { error.name = name + '' return error } + +/** + * Determines if we need to add a slash to front + * of a path, and adds it if we do. + * Cases: + * (path = '', url = '' || null | undefined) => '' + * (path = '?foo=bar', url = 'a.com') => '?foo=bar' + * (path = '?foo=bar', url = 'a.com/') => '?foo=bar' + * (path = 'foo', url = 'a.com') => '/foo' + * (path = 'foo', url = 'a.com/') => 'foo' + * (path = '/foo', url = 'a.com') => '/foo' + * (path = '/foo', url = 'a.com/') => 'foo' + * (path = '?foo=bar') => '?foo=bar' + * (path = 'foo') => '/foo' + * (path = '/foo') => '/foo' + */ +export const addSlash = (input?: string, url?: string) => { + if (!input) return '' + if (!url) { + if (input.startsWith('?') || input.startsWith('/')) return input + return `/${input}` + } + if (url.endsWith('/') && input.startsWith('/')) return input.substr(1) + if (!url.endsWith('/') && !input.startsWith('/') && !input.startsWith('?')) return `/${input}` + return input +}