├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── comparison.md ├── package-lock.json ├── package.json ├── src ├── case.ts ├── index.ts └── proxy.ts ├── test └── api.test.tsx └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .rts2_cache_cjs 5 | .rts2_cache_esm 6 | .rts2_cache_umd 7 | dist 8 | coverage 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | script: 5 | - npm ci 6 | - npm t 7 | - npx codecov 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ryan Allred 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@synvox/api` 2 | 3 | ![Travis (.org)](https://img.shields.io/travis/synvox/api) 4 | ![Codecov](https://img.shields.io/codecov/c/github/synvox/api) 5 | ![Bundle Size](https://badgen.net/bundlephobia/minzip/@synvox/api) 6 | ![License](https://badgen.net/npm/license/@synvox/api) 7 | [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/Synvox/api.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/Synvox/api/context:javascript) 8 | 9 | Simple HTTP calls in React using Suspense. 10 | 11 | ``` 12 | npm i @synvox/api axios 13 | ``` 14 | 15 | ## CodeSandbox 16 | 17 | [![Edit on CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/goofy-albattani-oq838?fontsize=14) 18 | 19 | ## Features 20 | 21 | - Wrapper around `axios`. Pass in an `axios` instance of your choosing 22 | - Small interface 23 | - `useApi` a suspense compatible hook for loading data 24 | - `api` a wrapper around axios 25 | - `touch(...keys: string[])` to refetch queries 26 | - `defer(() => T, defaultValue: T): {data: T, loading:boolean}` to defer an HTTP call 27 | - `preload(() => any): Promise` to preload an HTTP call 28 | - Run any `GET` request through Suspense 29 | - Refresh requests without flickering 30 | - De-duplicates `GET` requests to the same url 31 | - Caches urls while they're in use and garbage collects them when they are not. 32 | - Can be used in conditions and loops 33 | - Easy integration with websockets and SSE for real-time apps 34 | - Well tested and and written in Typescript 35 | - Tiny 36 | 37 | ## Basic Example 38 | 39 | ```js 40 | import { createApi } from '@synvox/api'; 41 | import axios from 'axios'; 42 | 43 | const { useApi } = createApi( 44 | axios.create({ 45 | baseURL: 'https://your-api.com', 46 | headers: { 47 | 'Authorization': 'Bearer your-token-here' 48 | } 49 | }) 50 | ); 51 | 52 | export useApi; 53 | 54 | // then elsewhere: 55 | 56 | import { useApi } from './api' 57 | 58 | function Post({postId}) { 59 | const api = useApi(); 60 | 61 | const user = api.users.me.get(); // GET https://your-api.com/users/me 62 | const post = api.posts[postId].get(); // GET https://your-api.com/posts/{postId} 63 | const comments = api.comments.get({postId: post.id}); // GET https://your-api.com/comments?post_id={postId} 64 | 65 | const authorName = post.authorId === user.id 66 | ? 'You' 67 | : api.users[post.authorId].get().name// GET https://your-api.com/users/{post.authorId} 68 | 69 | return <> 70 |

{post.title} by {authorName}

71 |

{post.body}

72 | 75 | ; 76 | } 77 | 78 | ``` 79 | 80 | ## The `useApi` hook 81 | 82 | `useApi` returns a `Proxy` that builds an axios request when you call it. For example: 83 | 84 | ```js 85 | import { createApi } from '@synvox/api'; 86 | import axios from 'axios'; 87 | 88 | const { useApi } = createApi(axios); 89 | 90 | // in a component: 91 | const api = useApi(); 92 | 93 | const users = api.users(); // calls GET /users 94 | const notifications = api.notifications.get(); // calls GET /notifications, defaults to `get` when no method is specified. 95 | 96 | const userId = 1; 97 | const comments = api.users({ userId: 1 }); // calls GET /users?user_id=1 98 | 99 | const projectId = 2; 100 | const project = api.projects[projectId](); // calls GET /projects/2 101 | 102 | const userProject = api.users[userId].projects[projectId]({ active: true }); // calls GET /users/1/projects/2?active=true 103 | ``` 104 | 105 | ### Calling `api` 106 | 107 | ```ts 108 | api.path[urlParam](params: object, config?: AxiosConfig) as Type 109 | // | | | |__ axios options like `data` and `headers` 110 | // | | |__ query params (uses query-string under the hood so arrays work) 111 | // | |__ url params 112 | // \__ the url path 113 | ``` 114 | 115 | ### `useApi` and the laws of hooks 116 | 117 | You cannot wrap a hook in a condition or use it in a loop, but the `api` object is not a hook, so feel free to use it wherever data is needed. 118 | 119 | ```js 120 | const api = useApi(); 121 | 122 | const users = shouldLoadUsers ? api.users() : []; 123 | 124 | return ( 125 | <> 126 | {users.map(user => ( 127 |
128 | {user.name}: {api.stars.count({ userId: user.id })} 129 |
130 | ))} 131 | 132 | ); 133 | ``` 134 | 135 | ### Refetching 136 | 137 | Call `touch` to refetch queries by url fragment(s). 138 | 139 | ```js 140 | import { createApi } from '@synvox/api'; 141 | import axios from 'axios'; 142 | 143 | const { useApi, touch } = createApi(axios); 144 | 145 | // in a component 146 | const api = useApi(); 147 | const [commentBody, setCommentBody] = useState(''); 148 | 149 | async function submit(e) { 150 | e.preventDefault(); 151 | 152 | // notice you can specify a method when making a call 153 | await api.comments.post( 154 | {}, 155 | { 156 | data: { 157 | body: commentBody, 158 | }, 159 | } 160 | ); 161 | // when used outside a render phase, api returns an AxiosPromise 162 | 163 | await touch('comments', 'users'); 164 | 165 | setCommentBody(''); 166 | } 167 | 168 | return
// Component stuff
; 169 | ``` 170 | 171 | The `touch` function will find all the used requests that contain the word(s) given to touch and run those requests again in the background, only updating the components when all the requests are completed. This helps a ton with flickering and race conditions. 172 | 173 | Because `touch` is not a hook, it can be used outside a component in a websocket handler or a SSE listener to create real-time experiences. 174 | 175 | ```js 176 | import { touch } from './api'; 177 | 178 | const sse = new EventSource('/events'); 179 | 180 | sse.addEventListener('update', e => { 181 | // assume e.data is {touches: ['messages', 'notifications']} 182 | touch(...e.data.touches); 183 | }); 184 | ``` 185 | 186 | ## Using `api` outside a component 187 | 188 | When the api object is used outside a component as its rendering, it will return an `axios` call to that url. 189 | 190 | ```js 191 | import { api } from './api'; 192 | 193 | export async function logout() { 194 | // notice you can specify a method like `post` when making a call 195 | await api.logout.post(); 196 | } 197 | ``` 198 | 199 | ## Preloading (and avoiding waterfall requests) 200 | 201 | Suspense will wait for promises to fulfill before resuming a render which means requests are _not_ loaded parallel. While this is fine for many components, you may want to start the loading of many requests at once. To do this call `preload`: 202 | 203 | ```js 204 | import { preload, useApi } from './api'; 205 | 206 | function Component() { 207 | const api = useApi(); 208 | 209 | // use the same way you would in a render phase 210 | preload(() => api.users()); 211 | preload(() => api.posts()); 212 | 213 | // suspend for /users 214 | const users = api.users(); 215 | 216 | // suspend for /posts, but the promise for posts will have 217 | // already been created in the preload call above. 218 | const posts = api.posts(); 219 | 220 | return ( 221 | 236 | ); 237 | } 238 | ``` 239 | 240 | ## Deferring Requests (make request, but don't suspend) 241 | 242 | If you need to make a request but need to defer until after the first render, then use `defer`: 243 | 244 | ```js 245 | import { defer } from '@synvox/api'; 246 | 247 | function Component() { 248 | const api = useApi(); 249 | 250 | const { data: users, loading } = defer(() => api.users(), []); 251 | 252 | if (loading) return ; 253 | return ; 254 | } 255 | ``` 256 | 257 | This still subscribes the component to updates from `touch`, request de-duplication, and garbage collection. 258 | 259 | ## Binding Links 260 | 261 | You can build graph-like structures with `useApi` by adding a modifier. Pass in a `modifier` to `createApi` to build custom link bindings: 262 | 263 | ```js 264 | // Transforms responses like {'@links': {comments: '/comments?post_id=123' }} into 265 | // an object where data.comments will load /comments?post_id=123 266 | 267 | function bindLinks(object: any, loadUrl: (url: string) => unknown) { 268 | if (!object || typeof object !== 'object') return object; 269 | const { '@links': links } = object; 270 | if (!links) return object; 271 | 272 | const returned: any = Array.isArray(object) ? [] : {}; 273 | 274 | for (let [key, value] of Object.entries(object)) { 275 | if (value && typeof value === 'object') { 276 | returned[key] = bindLinks(value, loadUrl); 277 | } else returned[key] = value; 278 | } 279 | 280 | if (!links) return returned; 281 | 282 | for (let [key, url] of Object.entries(links)) { 283 | if (!object[key]) { 284 | Object.defineProperty(returned, key, { 285 | get() { 286 | return loadUrl(url as string); 287 | }, 288 | enumerable: false, 289 | configurable: false, 290 | }); 291 | } 292 | } 293 | 294 | return returned; 295 | } 296 | 297 | const { useApi } = createApi(axios, { 298 | modifier: bindLinks, 299 | }); 300 | ``` 301 | 302 | ## Defining nested dependencies 303 | 304 | Say you call `/comments` which returns `Comment[]` and want each `Comment` to be loaded into the cache individually so calling `/comments/:id` doesn't make another request. You can do this by setting a deduplication strategy. 305 | 306 | ```js 307 | // will update the cache for all all `{"@url": ...} objects 308 | function deduplicationStrategy(item: any): { [key: string]: any } { 309 | if (!item || typeof item !== 'object') return {}; 310 | if (Array.isArray(item)) 311 | return item 312 | .map(deduplicationStrategy) 313 | .reduce((a, b) => ({ ...a, ...b }), {}); 314 | 315 | const result: { [key: string]: any } = {}; 316 | 317 | for (let value of Object.values(item)) { 318 | Object.assign(result, deduplicationStrategy(value)); 319 | } 320 | 321 | if (item['@url']) { 322 | result[item['@url']] = item; 323 | } 324 | 325 | return result; 326 | } 327 | 328 | const { useApi, api, touch, reset, preload } = createApi(axios, { 329 | modifier: bindLinks, 330 | deduplicationStrategy: (item: any) => { 331 | const others = deduplicationStrategy(item); 332 | return others; 333 | }, 334 | }); 335 | ``` 336 | 337 | ## Case Transformations 338 | 339 | You can optionally specify a case transformation for request bodies, response bodies, and urls. 340 | 341 | ```js 342 | createApi(axios, { 343 | requestCase: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none', 344 | responseCase: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none', 345 | urlCase: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none', 346 | }); 347 | ``` 348 | 349 | ## Saving and Restoring 350 | 351 | To save the cache call `save`: 352 | 353 | ```js 354 | const { save, restore } = createApi(axios); 355 | localStorage.__cache = JSON.stringify(save()); 356 | ``` 357 | 358 | To restore the cache call `restore`: 359 | 360 | ```js 361 | const { save, restore } = createApi(axios); 362 | restore(window.data__from__SSR); 363 | ``` 364 | 365 | ## Retries 366 | 367 | Set `retryCount` to specify how many times failing `GET` requests should be retried. Requests are delayed by `1s` and double for each retry but will not delay longer than `30s`. E.g. `1s, 2s, 4s, 8s, ..., 30s` 368 | Retrying only applies to `GET` requests called in a render. 369 | 370 | ```js 371 | createApi(axios, { retryCount: 10 }); 372 | ``` 373 | 374 | ## Why not just a `useEffect` hook or Redux? 375 | 376 | [See Comparison](comparison.md) 377 | 378 | ### Obligatory Notice about Suspense for data loading 379 | 380 | The React team has asked that we do not build on `react-cache` until it is stable, but that doesn't mean we can't experiment with an implementation of our own Suspense compatible cache until `react-cache` is stable. 381 | -------------------------------------------------------------------------------- /comparison.md: -------------------------------------------------------------------------------- 1 | # Why not just a useEffect hook or Redux? 2 | 3 | Because less code is much easier to maintain and test. Compare `useApi` to the a Redux implementation and a hook based implementation: 4 | 5 | _Redux:_ 6 | 7 | ```js 8 | // redux & redux-thunk style data loading: 9 | 10 | const users = { 11 | getById(id) { 12 | return axios.get(`/users/${id}`); 13 | }, 14 | // query, update, delete, etc. 15 | }; 16 | 17 | // then write actions 18 | const getUserById = id => async dispatch => { 19 | dispatch({ type: 'LOADING_USER', id }); 20 | try { 21 | dispatch({ 22 | type: 'LOAD_USER_SUCCESS', 23 | id, 24 | user: await users.getById(id), 25 | }); 26 | } catch (e) { 27 | dispatch({ 28 | type: 'LOAD_USER_FAILURE', 29 | id, 30 | error: e, 31 | }); 32 | } 33 | }; 34 | 35 | // then write a reducer 36 | const initialState = {}; 37 | 38 | function userReducer(state = initialState, { type, ...payload }) { 39 | switch (type) { 40 | case 'LOADING_USER': 41 | if (state[payload.id] && state[payload.id].loading) return state; 42 | return { 43 | ...state, 44 | [payload.id]: { loading: true, error: undefined, value: undefined }, 45 | }; 46 | case 'LOAD_USER_SUCCESS': 47 | return { 48 | ...state, 49 | [payload.id]: { loading: false, error: undefined, value: payload.user }, 50 | }; 51 | case 'LOAD_USER_FAILURE': 52 | return { 53 | ...state, 54 | [payload.id]: { loading: false, error: payload.error }, 55 | }; 56 | default: 57 | return state; 58 | } 59 | } 60 | 61 | // then write a Hook 62 | function useUser(id) { 63 | const user = useSelector(state => state.users[id]); 64 | const dispatch = useDispatch(); 65 | 66 | useEffect(() => { 67 | if (!user) dispatch(getUserById(id)); 68 | }); 69 | 70 | if (user === undefined) 71 | return { loading: true, error: undefined, value: undefined }; 72 | 73 | return user; 74 | } 75 | ``` 76 | 77 | Some things to think about: 78 | 79 | - Would need to write actions and reducers for every collection or data type 80 | - How do you re-fetch? 81 | - How do you garbage collect old requests? 82 | - How do you run this conditionally? 83 | - Does not suspend 84 | 85 | Similar boilerplate exists for a hook based solution, but is still much smaller than the Redux version: 86 | 87 | ```js 88 | // a simple reducer to handle our three actions 89 | function reducer(state, { type, ...payload }) { 90 | switch (type) { 91 | case 'LOADING': 92 | return { loading: true, error: undefined, data: undefined }; 93 | case 'SUCCESS': 94 | return { loading: false, error: undefined, data: payload.data }; 95 | case 'FAILURE': 96 | return { loading: false, error: payload.error }; 97 | default: 98 | return state; 99 | } 100 | } 101 | 102 | // a custom hook to make data loading easier 103 | function usePromise(asyncFunction, deps) { 104 | const [state, dispatch] = useReducer(reducer, { 105 | loading: { loading: true, error: undefined, data: undefined }, 106 | }); 107 | 108 | useEffect(() => { 109 | let isCurrent = true; 110 | 111 | dispatch({ 112 | type: 'LOADING', 113 | }); 114 | 115 | asyncFunction() 116 | .then(data => { 117 | if (isCurrent) 118 | dispatch({ 119 | type: 'SUCCESS', 120 | data, 121 | }); 122 | }) 123 | .catch(error => { 124 | if (isCurrent) 125 | dispatch({ 126 | type: 'FAILURE', 127 | error, 128 | }); 129 | }); 130 | 131 | return () => { 132 | isCurrent = false; 133 | }; 134 | }, deps); 135 | 136 | return state; 137 | } 138 | 139 | // usage is pretty simple 140 | function Component({ id }) { 141 | const { loading, data: user } = usePromise(async () => { 142 | return (await axios.get(`/users/${id}`)).data; 143 | }, [id]); 144 | 145 | if (loading) return ; 146 | 147 | // more component code 148 | } 149 | ``` 150 | 151 | Some things to think about: 152 | 153 | - How do you re-fetch? 154 | - How do you de-duplicate requests? 155 | - How do you run this conditionally? 156 | - Still lots of code 157 | - Does not suspend 158 | 159 | Compare this to the `useApi` version: 160 | 161 | ```js 162 | import { createApi } from '@synvox/api'; 163 | import axios from 'axios'; 164 | 165 | const { useApi, touch } = createApi(axios); 166 | 167 | // in a component 168 | function Component({ id }) { 169 | const api = useApi(); 170 | const user = api.users[id].get(); 171 | 172 | // do something with user 173 | } 174 | ``` 175 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@synvox/api", 3 | "version": "1.4.1", 4 | "main": "dist/index.js", 5 | "module": "dist/useapi.esm.js", 6 | "typings": "dist/index.d.ts", 7 | "license": "MIT", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "start": "tsdx watch", 13 | "build": "tsdx build", 14 | "test": "tsdx test --coverage", 15 | "lint": "tsdx lint src", 16 | "prepublish": "npm run build" 17 | }, 18 | "husky": { 19 | "hooks": { 20 | "pre-commit": "tsdx lint src" 21 | } 22 | }, 23 | "prettier": { 24 | "printWidth": 80, 25 | "semi": true, 26 | "singleQuote": true, 27 | "trailingComma": "es5" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/jest-dom": "^4.1.0", 31 | "@testing-library/react": "^9.1.4", 32 | "@types/jest": "^24.0.18", 33 | "@types/qs": "^6.9.3", 34 | "@types/react": "^16.9.2", 35 | "axios": "*", 36 | "axios-mock-adapter": "^1.17.0", 37 | "husky": "^3.0.5", 38 | "react": "^16.9.0", 39 | "react-dom": "^16.9.0", 40 | "tsdx": "^0.9.1", 41 | "tslib": "^1.10.0", 42 | "typescript": "^3.6.2" 43 | }, 44 | "peerDependencies": { 45 | "axios": "*", 46 | "react": "^16.9.0" 47 | }, 48 | "dependencies": { 49 | "debug": "^4.1.1", 50 | "qs": "^6.9.4" 51 | }, 52 | "jest": { 53 | "coverageDirectory": "./coverage/", 54 | "collectCoverage": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/case.ts: -------------------------------------------------------------------------------- 1 | // @TODO move this to a module 2 | 3 | export type CaseMethod = null | ((word: string, index: number) => string); 4 | 5 | export const caseMethods: { 6 | [id: string]: CaseMethod; 7 | } = { 8 | none: null, 9 | camel(word, index) { 10 | return index === 0 ? word : word[0].toUpperCase() + word.slice(1); 11 | }, 12 | snake(word, index) { 13 | return index === 0 ? word : '_' + word; 14 | }, 15 | kebab(word, index) { 16 | return index === 0 ? word : '-' + word; 17 | }, 18 | constant(word, index) { 19 | return index === 0 ? word.toUpperCase() : '_' + word.toUpperCase(); 20 | }, 21 | pascal(word, _index) { 22 | return word[0].toUpperCase() + word.slice(1); 23 | }, 24 | }; 25 | 26 | export function transformKey(key: string, method: CaseMethod) { 27 | if (method === null) return key; 28 | let prefix = ''; 29 | 30 | key = key.replace(/^_+/, p => { 31 | prefix = p; 32 | return ''; 33 | }); 34 | 35 | return key 36 | .replace(/_/g, ' ') 37 | .replace(/(\b|^|[a-z])([A-Z])/g, '$1 $2') 38 | .replace(/ +/g, ' ') 39 | .trim() 40 | .toLowerCase() 41 | .split(' ') 42 | .reduce((str, word, index) => str + method(word, index), prefix); 43 | } 44 | 45 | export function transformKeys(obj: any, method: CaseMethod): any { 46 | if (method === null) return obj; 47 | if (typeof obj !== 'object') return obj; 48 | if (!obj) return obj; 49 | if (Array.isArray(obj)) return obj.map(item => transformKeys(item, method)); 50 | 51 | return Object.keys(obj) 52 | .map(key => ({ key, value: transformKeys(obj[key], method) })) 53 | .map(({ key, value }) => ({ 54 | value, 55 | key: transformKey(key, method), 56 | })) 57 | .reduce( 58 | (returned, { key, value }) => Object.assign(returned, { [key]: value }), 59 | {} 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | useEffect, 5 | useLayoutEffect, 6 | createContext, 7 | useContext, 8 | } from 'react'; 9 | import queryString, { IStringifyOptions } from 'qs'; 10 | import realAxios, { 11 | AxiosError, 12 | AxiosInstance, 13 | Method, 14 | AxiosRequestConfig, 15 | AxiosPromise, 16 | } from 'axios'; 17 | import createProxy from './proxy'; 18 | import { transformKeys, caseMethods, transformKey } from './case'; 19 | 20 | export type UrlBuilder = { 21 | [segment: string]: UrlBuilder; 22 | (params?: object, config?: AxiosRequestConfig): 23 | | Promise 24 | | ReturnType; 25 | }; 26 | 27 | type Subscription = (key: string) => void; 28 | type Subscribers = Set>; 29 | 30 | type Cache = Map< 31 | string, 32 | { 33 | subscribersCount: number; 34 | dependentKeys: string[]; 35 | value: unknown; 36 | promise: Promise | null; 37 | deletionTimeout: ReturnType | null; 38 | } 39 | >; 40 | 41 | const axiosOptionsContext = createContext({}); 42 | 43 | function useForceUpdate() { 44 | // @TODO this should be a low priority update when concurrent mode is stable 45 | const update = useState({})[1]; 46 | 47 | const forceUpdate = () => { 48 | update({}); 49 | }; 50 | 51 | return forceUpdate; 52 | } 53 | 54 | function isPromise(value: any) { 55 | return value && typeof value.then === 'function'; 56 | } 57 | 58 | function getMaybeError(fn: () => T): T { 59 | try { 60 | return fn(); 61 | } catch (e) { 62 | return e; 63 | } 64 | } 65 | 66 | export function createApi( 67 | axios: AxiosInstance = realAxios, 68 | { 69 | requestCase = 'none', 70 | responseCase = 'none', 71 | urlCase = requestCase, 72 | modifier = x => x, 73 | deduplicationStrategy = () => ({}), 74 | cache = new Map(), 75 | onUpdateCache = () => {}, 76 | loadUrlFromCache = async () => {}, 77 | touchCache = async () => {}, 78 | retryCount = 0, 79 | qsOptions = { 80 | encodeValuesOnly: true, 81 | arrayFormat: 'brackets', 82 | }, 83 | }: { 84 | requestCase?: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none'; 85 | responseCase?: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none'; 86 | urlCase?: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none'; 87 | modifier?: (data: unknown, loadUrl: (url: string) => unknown) => any; 88 | deduplicationStrategy?: (data: any) => { [url: string]: any }; 89 | cache?: Cache; 90 | onUpdateCache?: () => void; 91 | loadUrlFromCache?: (url: string) => Promise; 92 | touchCache?: (edges: string[]) => Promise; 93 | retryCount?: number; 94 | qsOptions?: IStringifyOptions; 95 | } = {} 96 | ) { 97 | const caseToServer = caseMethods[requestCase]; 98 | const caseFromServer = caseMethods[responseCase]; 99 | const caseForUrls = caseMethods[urlCase]; 100 | 101 | /** Set of refs of callbacks for components subscribing to any api call */ 102 | const subscribers: Subscribers = new Set(); 103 | /** Whether or not calling an api will subscribe this component, used in preload */ 104 | let doSubscription = true; 105 | 106 | let realOnUpdateCache = onUpdateCache; 107 | let onUpdateCacheTimeout: ReturnType | null = null; 108 | onUpdateCache = function() { 109 | if (onUpdateCacheTimeout) clearTimeout(onUpdateCacheTimeout); 110 | onUpdateCacheTimeout = setTimeout(() => { 111 | realOnUpdateCache(); 112 | clearTimeout(onUpdateCacheTimeout!); 113 | }, 0); 114 | }; 115 | 116 | function setKey(key: string, value: unknown) { 117 | const item = cache.get(key); 118 | const subscribersCount = item ? item.subscribersCount : 0; 119 | const existingValue = item ? getMaybeError(() => item.value) : undefined; 120 | const dependentKeys = item ? item.dependentKeys : []; 121 | const deletionTimeout = item ? item.deletionTimeout : null; 122 | 123 | if (deletionTimeout) { 124 | clearTimeout(deletionTimeout); 125 | } 126 | 127 | // registering promises in the cache does not require 128 | // subscribing components to update. 129 | if (isPromise(value)) { 130 | cache.set(key, { 131 | value: existingValue, 132 | promise: value as Promise, 133 | subscribersCount, 134 | dependentKeys, 135 | deletionTimeout: null, 136 | }); 137 | 138 | if (subscribersCount <= 0) { 139 | deferRemoveKey(key); 140 | } 141 | 142 | return; 143 | } 144 | 145 | if (value instanceof Error) { 146 | // Give an error if this should throw an error 147 | cache.set(key, { 148 | get value() { 149 | throw value; 150 | }, 151 | promise: null, 152 | subscribersCount, 153 | dependentKeys, 154 | deletionTimeout: null, 155 | }); 156 | } else { 157 | const otherKeys = deduplicationStrategy(value); 158 | const dependentKeys = Object.keys(otherKeys); 159 | 160 | cache.set(key, { 161 | value, 162 | promise: null, 163 | subscribersCount, 164 | dependentKeys, 165 | deletionTimeout: null, 166 | }); 167 | 168 | if (subscribersCount <= 0) { 169 | deferRemoveKey(key); 170 | } 171 | 172 | for (let key in otherKeys) { 173 | if (!cache.get(key)) { 174 | cache.set(key, { 175 | value: otherKeys[key], 176 | promise: null, 177 | subscribersCount: 0, 178 | dependentKeys: [], 179 | deletionTimeout: null, 180 | }); 181 | 182 | deferRemoveKey(key); 183 | } 184 | } 185 | } 186 | 187 | subscribers.forEach(ref => ref.current && ref.current(key)); 188 | onUpdateCache(); 189 | } 190 | 191 | function loadUrl( 192 | key: string, 193 | { 194 | keys, 195 | valueCache, 196 | }: { 197 | keys?: Set; 198 | valueCache?: WeakMap; 199 | } = {}, 200 | axiosOptions: Partial = {} 201 | ): BaseType { 202 | if (keys && !keys.has(key)) keys.add(key); 203 | 204 | const { 205 | value = undefined, 206 | promise: existingPromise = undefined, 207 | deletionTimeout, 208 | } = cache.get(key) || {}; 209 | 210 | if (deletionTimeout) { 211 | clearTimeout(deletionTimeout); 212 | } 213 | 214 | // return early if the value is already loaded 215 | if (value !== undefined) { 216 | if (!valueCache || typeof value !== 'object' || value === null) 217 | return modifier(value, key => loadUrl(key, { keys, valueCache })); 218 | 219 | if (valueCache.has(value)) return valueCache.get(value); 220 | 221 | const modifiedValue = modifier(value, key => 222 | loadUrl(key, { keys, valueCache }) 223 | ); 224 | 225 | valueCache.set(value, modifiedValue); 226 | 227 | return modifiedValue; 228 | } 229 | 230 | // piggy back promises to the same key 231 | if (isPromise(existingPromise)) { 232 | throw existingPromise; 233 | } 234 | 235 | // if the value is not yet loaded, create a promise that will load the value 236 | const promise = loadUrlFromCache(key).then(async data => { 237 | if (data !== undefined) { 238 | data = transformKeys(data, caseFromServer); 239 | 240 | setKey(key, data); 241 | return; 242 | } 243 | 244 | let triesRemaining = retryCount; 245 | let retryDelay = 1000; 246 | const tryRequest = async () => { 247 | await axios({ 248 | url: key, 249 | method: 'get', 250 | ...axiosOptions, 251 | }) 252 | .then(async ({ data }: { data: any }) => { 253 | data = transformKeys(data, caseFromServer); 254 | 255 | setKey(key, data); 256 | }) 257 | .catch(async (err: AxiosError) => { 258 | // not found errors can just set null 259 | if (err.response && err.response.status === 404) 260 | return setKey(key, null); 261 | 262 | if (triesRemaining === 0) { 263 | // every other error should throw 264 | setKey(key, err); 265 | } else { 266 | await new Promise(r => 267 | setTimeout(r, Math.min(30000, retryDelay)) 268 | ); 269 | triesRemaining--; 270 | retryDelay *= 2; 271 | return await tryRequest(); 272 | } 273 | }); 274 | }; 275 | 276 | await tryRequest(); 277 | }); 278 | 279 | setKey(key, promise); 280 | 281 | throw promise; 282 | } 283 | 284 | function createAxiosProxy( 285 | getSuspendedValue: (url: string) => undefined | T, 286 | { params: optionsParams, ...axiosOptions }: AxiosRequestConfig = {} 287 | ) { 288 | const api = createProxy( 289 | caseForUrls, 290 | ( 291 | method: Method, 292 | path: string, 293 | params: object, 294 | options: Partial 295 | ) => { 296 | const qs = queryString.stringify( 297 | transformKeys({ ...optionsParams, ...params }, caseToServer), 298 | qsOptions 299 | ); 300 | 301 | const url = path + (qs ? `?${qs}` : ''); 302 | 303 | let suspended = getSuspendedValue(url); 304 | if (suspended !== undefined) return suspended; 305 | 306 | return axios({ 307 | method, 308 | url, 309 | ...axiosOptions, 310 | ...options, 311 | data: 312 | 'data' in options 313 | ? transformKeys(options.data, caseToServer) 314 | : undefined, 315 | }) 316 | .then(res => { 317 | res.data = transformKeys(res.data, caseFromServer); 318 | return res; 319 | }) 320 | .catch(err => { 321 | if (err.response) { 322 | err.response.data = transformKeys( 323 | err.response.data, 324 | caseFromServer 325 | ); 326 | } 327 | throw err; 328 | }) as AxiosPromise; 329 | } 330 | ); 331 | 332 | return api; 333 | } 334 | 335 | async function touchWithMatcher( 336 | matcher: (key: string, value: BaseType) => boolean 337 | ) { 338 | let keysToReset = []; 339 | 340 | // find the keys that these edges touch. E.g. `users` should touch `/users/1` 341 | const cacheKeys = Array.from(cache.keys()); 342 | for (let cacheKey of cacheKeys) { 343 | const item = cache.get(cacheKey)!; 344 | const value = getMaybeError(() => item.value); 345 | 346 | if (!matcher(cacheKey, value as BaseType)) continue; 347 | if (item.subscribersCount <= 0) { 348 | if (item.deletionTimeout) clearTimeout(item.deletionTimeout); 349 | cache.delete(cacheKey); 350 | continue; 351 | } 352 | 353 | keysToReset.push(cacheKey); 354 | } 355 | 356 | const keyValues = await Promise.all( 357 | keysToReset.map(cacheKey => { 358 | let retriesRemaining = retryCount; 359 | let retryDelay = 1000; 360 | const refresh = async () => { 361 | const tryRequest = async () => { 362 | // re-run the axios call. This should be an a similar call to the call in `loadUrl` 363 | const data: any = await axios({ 364 | url: cacheKey, 365 | method: 'get', 366 | }) 367 | .then(({ data }) => { 368 | // run the transform here and not in setKey in case there is an error 369 | return transformKeys(data, caseFromServer); 370 | }) 371 | .catch(async err => { 372 | if (retriesRemaining > 0) { 373 | await new Promise(r => 374 | setTimeout(r, Math.min(30000, retryDelay)) 375 | ); 376 | retriesRemaining--; 377 | retryDelay *= 2; 378 | return await tryRequest(); 379 | } 380 | 381 | if (err.response) { 382 | if (err.response.status === 404) return null; 383 | else { 384 | err.response.data = transformKeys( 385 | err.response.data, 386 | caseFromServer 387 | ); 388 | } 389 | } 390 | 391 | return err; 392 | }); 393 | return data; 394 | }; 395 | 396 | return [cacheKey, await tryRequest()]; 397 | }; 398 | 399 | const promise = refresh(); 400 | setKey(cacheKey, promise); 401 | 402 | return promise; 403 | }) 404 | ); 405 | 406 | for (let [key, value] of keyValues) { 407 | setKey(key, value); 408 | } 409 | 410 | onUpdateCache(); 411 | } 412 | 413 | async function touch(...edges: string[]) { 414 | const casedEdges = edges.map(edge => transformKey(edge, caseForUrls)); 415 | await touchCache(casedEdges); 416 | return touchWithMatcher(str => casedEdges.some(edge => str.includes(edge))); 417 | } 418 | 419 | /** 420 | * @example 421 | * const api = useApi() 422 | * const users = api.users() as User[] 423 | * // do something with users 424 | */ 425 | function useUrl() { 426 | const axiosOptions = useContext(axiosOptionsContext) || {}; 427 | const keysRef = useRef(new Set()); 428 | const previousKeysRef = useRef(new Set()); 429 | const valueWeakMap = useRef(new WeakMap()); 430 | 431 | // If the previous render suspended, the pending keys will be in keysRef. 432 | // This is to make sure those are included as potential "previous keys". 433 | keysRef.current.forEach(key => previousKeysRef.current.add(key)); 434 | keysRef.current = new Set(); 435 | 436 | const forceUpdate = useForceUpdate(); 437 | const subscription = useRef(changedKey => { 438 | if (!keysRef.current.has(changedKey)) return; 439 | forceUpdate(); 440 | }); 441 | 442 | // Add/remove this component from subscriber list on mount/unmount 443 | useEffect(() => { 444 | subscribers.add(subscription); 445 | return () => { 446 | subscribers.delete(subscription); 447 | 448 | const keys = Array.from(keysRef.current); 449 | 450 | for (let key of keys) { 451 | const item = cache.get(key); 452 | // this shouldn't happen but if 453 | // it does we don't want to crash 454 | /* istanbul ignore next */ 455 | if (!item) continue; 456 | item.subscribersCount -= 1; 457 | if (item.subscribersCount <= 0) { 458 | deferRemoveKey(key); 459 | } 460 | } 461 | }; 462 | }, []); 463 | 464 | useLayoutEffect(() => { 465 | // @TODO this could be optimized or use set methods when those are released 466 | const newKeys = Array.from(keysRef.current).filter( 467 | key => !previousKeysRef.current.has(key) 468 | ); 469 | 470 | const removedKeys = Array.from(previousKeysRef.current).filter( 471 | key => !keysRef.current.has(key) 472 | ); 473 | 474 | // count up new keys 475 | for (let key of newKeys) { 476 | const item = cache.get(key); 477 | // this shouldn't happen but if 478 | // it does we don't want to crash 479 | /* istanbul ignore next */ 480 | if (!item) continue; 481 | item.subscribersCount += 1; 482 | } 483 | 484 | // and count down old keys until they are removed 485 | for (let key of removedKeys) { 486 | const item = cache.get(key); 487 | // this shouldn't happen but if 488 | // it does we don't want to crash 489 | /* istanbul ignore next */ 490 | if (!item) continue; 491 | item.subscribersCount -= 1; 492 | if (item.subscribersCount <= 0) { 493 | deferRemoveKey(key); 494 | } 495 | } 496 | 497 | previousKeysRef.current = keysRef.current; 498 | }); 499 | 500 | function getUrl(url: string) { 501 | const { params: _, ...axiosOptionsWithoutParams } = axiosOptions; 502 | if (!doSubscription) return loadUrl(url); 503 | return loadUrl( 504 | url, 505 | { 506 | keys: keysRef.current, 507 | valueCache: valueWeakMap.current, 508 | }, 509 | axiosOptionsWithoutParams 510 | ); 511 | } 512 | 513 | return getUrl; 514 | } 515 | 516 | /** 517 | * @example 518 | * const api = useApi() 519 | * const users = api.users() as User[] 520 | * // do something with users 521 | */ 522 | function useApi() { 523 | const getUrl = useUrl(); 524 | const axiosOptions = useContext(axiosOptionsContext) || {}; 525 | 526 | let isSuspending = true; 527 | useLayoutEffect(() => { 528 | isSuspending = false; 529 | }); 530 | 531 | const api = createAxiosProxy(url => { 532 | // We want this component to throw on all requests while it is in the render phase 533 | // but not after. This effect switches it to non-suspending after the commit. 534 | if (!isSuspending && doSubscription) return undefined; 535 | else { 536 | return getUrl(url); 537 | } 538 | }, axiosOptions); 539 | 540 | return api; 541 | } 542 | 543 | function deferRemoveKey(key: string) { 544 | const item = cache.get(key); 545 | if (!item) return; 546 | 547 | if (item.deletionTimeout) clearTimeout(item.deletionTimeout); 548 | 549 | const timeout = setTimeout(() => { 550 | const item = cache.get(key); 551 | if (!item) return; 552 | cache.delete(key); 553 | 554 | // Find all records dependant on this record and 555 | // remove those that have no subscribers. 556 | const { dependentKeys } = item; 557 | for (let dependentKey of dependentKeys) { 558 | const dependentItem = cache.get(dependentKey); 559 | // this shouldn't happen but if 560 | // it does we don't want to crash 561 | /* istanbul ignore next */ 562 | if (!dependentItem) continue; 563 | if (dependentItem.subscribersCount <= 0) cache.delete(dependentKey); 564 | } 565 | }, 1000 * 60 * 3); 566 | 567 | item.deletionTimeout = timeout; 568 | } 569 | 570 | /** 571 | * @example 572 | * const axiosResponse = await api.comments.post({}, { data: { body: '123' }}) 573 | */ 574 | 575 | const api = createAxiosProxy(url => { 576 | if (doSubscription) return undefined; 577 | return loadUrl(url); 578 | }); 579 | 580 | function reset() { 581 | cache = new Map(); 582 | } 583 | 584 | /** 585 | * Preload api calls without suspending or subscribing this component 586 | * Returns a promise that is fulfilled when the request(s) are cached. 587 | * @param fns functions that may suspend 588 | * @example 589 | * 590 | * const api = useApi() // using the hook is necessary 591 | * 592 | * preload(() => { 593 | * api.users(); // preload users without suspending or subscribing this component 594 | * }); 595 | * 596 | * preload(() => { 597 | * api.posts(); // preload posts 598 | * }); 599 | * 600 | * preload(() => { 601 | * // also works with multiple calls 602 | * const user = api.users.me(); 603 | * const tasks = api.tasks({ userId: user.id }); 604 | * }); 605 | * 606 | */ 607 | async function preload(fn: () => void) { 608 | // continue until success 609 | while (true) { 610 | try { 611 | doSubscription = false; 612 | fn(); 613 | doSubscription = true; 614 | break; 615 | } catch (e) { 616 | doSubscription = true; 617 | if (!isPromise(e)) throw e; 618 | // make sure promise fires 619 | await e; 620 | } 621 | } 622 | } 623 | 624 | function save() { 625 | const returned: { [key: string]: unknown } = {}; 626 | cache.forEach(({ value }, key) => { 627 | if (value !== undefined) returned[key] = value; 628 | }); 629 | return returned; 630 | } 631 | 632 | function restore(saveFile: { [key: string]: unknown }) { 633 | for (let [key, value] of Object.entries(saveFile)) { 634 | if (!cache.has(key)) { 635 | cache.set(key, { 636 | value, 637 | promise: null, 638 | subscribersCount: 0, 639 | dependentKeys: [], 640 | deletionTimeout: null, 641 | }); 642 | } 643 | } 644 | } 645 | 646 | return { 647 | touch, 648 | touchWithMatcher, 649 | useApi, 650 | useUrl, 651 | api, 652 | reset, 653 | preload, 654 | save, 655 | restore, 656 | }; 657 | } 658 | 659 | export function defer(call: () => T, defaultValue?: T) { 660 | try { 661 | return { data: call(), loading: false }; 662 | } catch (e) { 663 | if (!isPromise(e)) throw e; 664 | 665 | return { data: defaultValue, loading: true }; 666 | } 667 | } 668 | 669 | export function AxiosOptionsProvider({ 670 | children, 671 | options = {}, 672 | }: { 673 | children: React.ReactNode; 674 | options: AxiosRequestConfig; 675 | }) { 676 | return React.createElement(axiosOptionsContext.Provider, { 677 | value: options, 678 | children, 679 | }); 680 | } 681 | -------------------------------------------------------------------------------- /src/proxy.ts: -------------------------------------------------------------------------------- 1 | import { Method, AxiosRequestConfig, AxiosPromise } from 'axios'; 2 | import { transformKey, CaseMethod } from './case'; 3 | import { UrlBuilder } from '.'; 4 | 5 | type Callback = ( 6 | method: Method, 7 | path: string, 8 | params: object, 9 | options: Partial 10 | ) => ReturnType | AxiosPromise; 11 | 12 | function createProxy( 13 | caseForUrls: CaseMethod, 14 | callback: Callback, 15 | path: string[] = [''] 16 | ): UrlBuilder { 17 | const callable = ( 18 | params: object = {}, 19 | options: Partial = {} 20 | ) => { 21 | let method = path[path.length - 1]; 22 | const hasMethod = ['post', 'get', 'put', 'patch', 'delete'].includes( 23 | method 24 | ); 25 | 26 | let urlBase = ''; 27 | if (!hasMethod) { 28 | method = 'get'; 29 | urlBase = path.join('/'); 30 | } else urlBase = path.slice(0, -1).join('/'); 31 | 32 | return callback(method as Method, urlBase, params, options); 33 | }; 34 | 35 | const proxy = new Proxy(callable, { 36 | get: (_, prop) => { 37 | return createProxy(caseForUrls, callback, [ 38 | ...path, 39 | transformKey(String(prop), caseForUrls), 40 | ]); 41 | }, 42 | }); 43 | 44 | return proxy as UrlBuilder; 45 | } 46 | 47 | export default createProxy; 48 | -------------------------------------------------------------------------------- /test/api.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FunctionComponent, 3 | useState, 4 | Dispatch, 5 | SetStateAction, 6 | } from 'react'; 7 | import { render, cleanup, waitForElement } from '@testing-library/react'; 8 | import '@testing-library/jest-dom/extend-expect'; 9 | import axios, { AxiosRequestConfig } from 'axios'; 10 | import { act } from 'react-dom/test-utils'; 11 | import MockAdapter from 'axios-mock-adapter'; 12 | 13 | import { createApi, UrlBuilder, defer, AxiosOptionsProvider } from '../src'; 14 | 15 | const timers: { [id: number]: any } = {}; 16 | let timerKey = 0; 17 | const realSetTimeout = global.setTimeout; 18 | const realClearTimeout = global.clearTimeout; 19 | function mockSetTimeout(innerFn: any, time: number) { 20 | function caller() { 21 | realClearTimeout(timeout); 22 | innerFn(); 23 | delete timers[key]; 24 | } 25 | 26 | const key = timerKey++; 27 | const timeout = realSetTimeout(caller, time); 28 | 29 | timers[key] = caller; 30 | 31 | return key; 32 | } 33 | 34 | function mockClearTimeout(id: number) { 35 | if (timers[id]) delete timers[id]; 36 | } 37 | 38 | function advanceTimers() { 39 | for (let id in timers) { 40 | timers[id](); 41 | } 42 | } 43 | 44 | global.setTimeout = mockSetTimeout as typeof setTimeout; 45 | global.clearTimeout = mockClearTimeout as typeof clearTimeout; 46 | 47 | let mock = new MockAdapter(axios, { delayResponse: 1 }); 48 | 49 | // Example hateoas link binder 50 | function bindLinks(object: any, loadUrl: (url: string) => unknown) { 51 | if (!object || typeof object !== 'object') return object; 52 | const { '@links': links } = object; 53 | if (!links) return object; 54 | 55 | const returned: any = Array.isArray(object) ? [] : {}; 56 | 57 | for (let [key, value] of Object.entries(object)) { 58 | if (value && typeof value === 'object') { 59 | returned[key] = bindLinks(value, loadUrl); 60 | } else returned[key] = value; 61 | } 62 | 63 | if (!links) return returned; 64 | 65 | for (let [key, url] of Object.entries(links)) { 66 | if (!object[key]) { 67 | Object.defineProperty(returned, key, { 68 | get() { 69 | return loadUrl(url as string); 70 | }, 71 | enumerable: false, 72 | configurable: false, 73 | }); 74 | } 75 | } 76 | 77 | return returned; 78 | } 79 | 80 | function dedup(item: any): { [key: string]: any } { 81 | if (!item || typeof item !== 'object') return {}; 82 | if (Array.isArray(item)) 83 | return item.map(dedup).reduce((a, b) => ({ ...a, ...b }), {}); 84 | 85 | const result: { [key: string]: any } = {}; 86 | 87 | for (let value of Object.values(item)) { 88 | Object.assign(result, dedup(value)); 89 | } 90 | 91 | if (item['@url']) { 92 | result[item['@url']] = item; 93 | } 94 | 95 | return result; 96 | } 97 | 98 | const { 99 | useApi, 100 | api, 101 | touch, 102 | touchWithMatcher, 103 | reset, 104 | preload, 105 | save, 106 | restore, 107 | } = createApi(axios, { 108 | requestCase: 'snake', 109 | responseCase: 'camel', 110 | modifier: bindLinks, 111 | deduplicationStrategy: (item: any) => { 112 | const others = dedup(item); 113 | return others; 114 | }, 115 | loadUrlFromCache: async url => { 116 | if (url === '/from_cache') { 117 | return { derp: 'derp' }; 118 | } 119 | 120 | return undefined; 121 | }, 122 | }); 123 | 124 | function renderSuspending(fn: FunctionComponent) { 125 | const Component = fn; 126 | 127 | return render( 128 | 129 | 130 | 131 | ); 132 | } 133 | 134 | beforeEach(() => { 135 | reset(); 136 | mock.reset(); 137 | }); 138 | 139 | afterEach(cleanup); 140 | 141 | it('works at all', async () => { 142 | mock.onGet('/users').reply(200, [{ id: 1, name: 'John Smith' }]); 143 | 144 | const { queryByTestId } = renderSuspending(() => { 145 | const api = useApi(); 146 | const users = api.users() as { id: number; name: string }[]; 147 | 148 | return
{users.length}
; 149 | }); 150 | 151 | const element = await waitForElement(() => queryByTestId('element')); 152 | 153 | expect(element!.textContent).toEqual('1'); 154 | }); 155 | 156 | it('works with explicit get', async () => { 157 | mock.onGet('/users').reply(200, [{ id: 1, name: 'John Smith' }]); 158 | 159 | const { queryByTestId } = renderSuspending(() => { 160 | const api = useApi(); 161 | const users = api.users.get() as { id: number; name: string }[]; 162 | 163 | return
{users.length}
; 164 | }); 165 | 166 | const element = await waitForElement(() => queryByTestId('element')); 167 | 168 | expect(element!.textContent).toEqual('1'); 169 | }); 170 | 171 | it('works with query params', async () => { 172 | mock 173 | .onGet('/values?some_prop=2') 174 | .reply(200, [{ some_value: 2 }, { some_value: 3 }]); 175 | 176 | const { queryByTestId } = renderSuspending(() => { 177 | const api = useApi(); 178 | const values = api.values({ someProp: 2 }) as { someValue: number }[]; 179 | 180 | return
{values[0].someValue}
; 181 | }); 182 | 183 | const element = await waitForElement(() => queryByTestId('element')); 184 | 185 | expect(element!.textContent).toEqual('2'); 186 | }); 187 | 188 | it('works with url params', async () => { 189 | const { useApi } = createApi(axios, { 190 | requestCase: 'snake', 191 | responseCase: 'camel', 192 | urlCase: 'kebab', 193 | modifier: bindLinks, 194 | deduplicationStrategy: (item: any) => { 195 | const others = dedup(item); 196 | return others; 197 | }, 198 | }); 199 | 200 | mock.onGet('/some-case-stuff/123').reply(200, { some_value: 123 }); 201 | 202 | const { queryByTestId } = renderSuspending(() => { 203 | const api = useApi(); 204 | const value = api.someCaseStuff[123]() as { someValue: number }; 205 | 206 | return
{value.someValue}
; 207 | }); 208 | 209 | const element = await waitForElement(() => queryByTestId('element')); 210 | 211 | expect(element!.textContent).toEqual('123'); 212 | }); 213 | 214 | it('works with url params snake case', async () => { 215 | const { useApi } = createApi(axios, { 216 | requestCase: 'snake', 217 | responseCase: 'camel', 218 | modifier: bindLinks, 219 | deduplicationStrategy: (item: any) => { 220 | const others = dedup(item); 221 | return others; 222 | }, 223 | }); 224 | 225 | mock.onGet('/some_case_stuff/123').reply(200, { some_value: 123 }); 226 | 227 | const { queryByTestId } = renderSuspending(() => { 228 | const api = useApi(); 229 | const value = api.someCaseStuff[123]() as { someValue: number }; 230 | 231 | return
{value.someValue}
; 232 | }); 233 | 234 | const element = await waitForElement(() => queryByTestId('element')); 235 | 236 | expect(element!.textContent).toEqual('123'); 237 | }); 238 | 239 | it('deduplicates requests', async () => { 240 | mock.onGet('/values/123').reply(200, { some_value: 123 }); 241 | 242 | const { queryByTestId } = renderSuspending(() => { 243 | const api = useApi(); 244 | const value = api.values[123]() as { someValue: number }; 245 | const value2 = api.values[123]() as { someValue: number }; 246 | 247 | return
{value === value2 ? 'yes' : 'no'}
; 248 | }); 249 | 250 | const element = await waitForElement(() => queryByTestId('element')); 251 | 252 | expect(element!.textContent).toEqual('yes'); 253 | }); 254 | 255 | it('deduplicates requests with defer', async () => { 256 | mock.onGet('/values/123').reply(200, { some_value: 123 }); 257 | 258 | const { queryByTestId } = renderSuspending(() => { 259 | const api = useApi(); 260 | const { data: value } = defer( 261 | () => api.values[123]() as { someValue: number } 262 | ); 263 | 264 | const value2 = api.values[123]() as { someValue: number }; 265 | 266 | return
{value === value2 ? 'yes' : 'no'}
; 267 | }); 268 | 269 | const element = await waitForElement(() => queryByTestId('element')); 270 | 271 | expect(element!.textContent).toEqual('yes'); 272 | }); 273 | 274 | it('works with defer', async () => { 275 | mock.onGet('/values/123').reply(200, { some_value: 123 }); 276 | 277 | let wasLoading = false; 278 | 279 | const { queryByTestId } = renderSuspending(() => { 280 | const api = useApi(); 281 | const { data: value, loading } = defer( 282 | () => api.values[123]() as { someValue: number } 283 | ); 284 | 285 | if (loading) { 286 | wasLoading = true; 287 | return null; 288 | } 289 | 290 | return
{value!.someValue}
; 291 | }); 292 | 293 | const element = await waitForElement(() => queryByTestId('element')); 294 | 295 | expect(wasLoading).toBe(true); 296 | expect(element!.textContent).toEqual('123'); 297 | 298 | expect(() => { 299 | defer(() => { 300 | throw new Error(); 301 | }); 302 | }).toThrow(); 303 | }); 304 | 305 | it('refetches when touch is called', async () => { 306 | let valueRef = { current: 1 }; 307 | let rerenders = 0; 308 | let doError = true; 309 | let didError = null; 310 | mock.onGet('/val').reply(() => [200, { value: valueRef.current }]); 311 | mock.onGet('/null').reply(() => [404]); 312 | mock.onGet('/error').reply(() => { 313 | if (doError) { 314 | doError = false; 315 | return [500]; 316 | } 317 | return [200]; 318 | }); 319 | 320 | const { queryByTestId } = renderSuspending(() => { 321 | const api = useApi(); 322 | const { value } = api.val.get() as { value: number }; 323 | const nothing = api.null.get() as string | null; 324 | try { 325 | api.error.get(); 326 | didError = false; 327 | } catch (e) { 328 | if (!(e instanceof Error)) throw e; 329 | didError = true; 330 | } 331 | 332 | rerenders++; 333 | 334 | return ( 335 |
336 | {value} {nothing === null ? 'null' : ''} 337 |
338 | ); 339 | }); 340 | 341 | let element = await waitForElement(() => 342 | queryByTestId(`element-${valueRef.current}`) 343 | ); 344 | 345 | expect(element!.textContent).toEqual('1 null'); 346 | valueRef.current = 2; 347 | 348 | expect(didError).toBe(true); 349 | 350 | await act(async () => { 351 | await touch('val', 'null', 'error'); 352 | }); 353 | 354 | element = await waitForElement(() => 355 | queryByTestId(`element-${valueRef.current}`) 356 | ); 357 | 358 | expect(element!.textContent).toEqual('2 null'); 359 | expect(didError).toBe(false); 360 | 361 | await act(async () => { 362 | // make sure touching something else does not cause this to rerender 363 | await touch('something'); 364 | }); 365 | 366 | expect(rerenders).toBe(3); 367 | }); 368 | 369 | it('refetches when touchWithMatcher is called', async () => { 370 | let valueRef = { current: 1 }; 371 | let rerenders = 0; 372 | mock.onGet('/val').reply(() => [200, { value: valueRef.current }]); 373 | mock.onGet('/null').reply(() => [404]); 374 | 375 | const { queryByTestId } = renderSuspending(() => { 376 | const api = useApi(); 377 | const { value } = api.val.get() as { value: number }; 378 | const nothing = api.null.get() as string | null; 379 | rerenders++; 380 | 381 | return ( 382 |
383 | {value} {nothing === null ? 'null' : ''} 384 |
385 | ); 386 | }); 387 | 388 | let element = await waitForElement(() => 389 | queryByTestId(`element-${valueRef.current}`) 390 | ); 391 | 392 | expect(element!.textContent).toEqual('1 null'); 393 | valueRef.current = 2; 394 | 395 | await act(async () => { 396 | await touchWithMatcher((url: string, item: any) => { 397 | return url === '/null' || item; // make sure item is passed and is truthy 398 | }); 399 | }); 400 | 401 | element = await waitForElement(() => 402 | queryByTestId(`element-${valueRef.current}`) 403 | ); 404 | 405 | expect(element!.textContent).toEqual('2 null'); 406 | 407 | await act(async () => { 408 | // make sure touching something else does not cause this to rerender 409 | await touch('something'); 410 | }); 411 | 412 | expect(rerenders).toBe(3); 413 | }); 414 | 415 | it('removes unused', async () => { 416 | let suspenseCount = 0; 417 | mock.onGet('/val/1').reply(() => [200, { value: 1 }]); 418 | mock.onGet('/val/2').reply(() => [200, { value: 2 }]); 419 | 420 | let setIndex: Dispatch> = () => {}; 421 | 422 | const { queryByTestId } = renderSuspending(() => { 423 | const [index, s] = useState(1); 424 | setIndex = s; 425 | 426 | const api = useApi(); 427 | try { 428 | const { value } = api.val[index].get() as { value: number }; 429 | 430 | return
{value}
; 431 | } catch (e) { 432 | suspenseCount += 1; 433 | return null; 434 | } 435 | }); 436 | 437 | // another component subscribing to a url 438 | renderSuspending(() => { 439 | const api = useApi(); 440 | api.val[2](); 441 | return null; 442 | }); 443 | 444 | advanceTimers(); 445 | 446 | let element = await waitForElement(() => queryByTestId(`element-1`)); 447 | 448 | expect(element!.textContent).toEqual('1'); 449 | 450 | act(() => { 451 | setIndex(2); 452 | }); 453 | 454 | advanceTimers(); 455 | 456 | element = await waitForElement(() => queryByTestId(`element-2`)); 457 | 458 | expect(element!.textContent).toEqual('2'); 459 | 460 | act(() => { 461 | setIndex(1); 462 | }); 463 | 464 | advanceTimers(); 465 | 466 | element = await waitForElement(() => queryByTestId(`element-1`)); 467 | 468 | expect(element!.textContent).toEqual('1'); 469 | 470 | expect(suspenseCount).toBe(2); 471 | }); 472 | 473 | it('supports wrapping axios in api without hook', async () => { 474 | mock.onPost('/val').reply(() => [200, { some_value: 'post!' }]); 475 | mock.onGet('/val?asdf=0').reply(() => [200, { value: 'get!' }]); 476 | const { 477 | data: { someValue }, 478 | } = (await api.val.post({}, { data: { somethingCool: '123' } })) as { 479 | data: { someValue: string }; 480 | }; 481 | 482 | expect(someValue).toEqual('post!'); 483 | expect(mock.history.post[0].data).toBe( 484 | JSON.stringify({ something_cool: '123' }) 485 | ); 486 | 487 | const { 488 | data: { value: value2 }, 489 | } = (await api.val({ asdf: 0 })) as { 490 | data: { value: string }; 491 | }; 492 | 493 | expect(value2).toEqual('get!'); 494 | }); 495 | 496 | it('supports wrapping axios in api outside render phase', async () => { 497 | mock.onPost('/val').reply(() => [200, { value: 'post!' }]); 498 | mock.onGet('/val').reply(() => [200, { value: 'get!' }]); 499 | let api: null | UrlBuilder = null; 500 | 501 | renderSuspending(() => { 502 | api = useApi(); 503 | 504 | return null; 505 | }); 506 | 507 | const { 508 | data: { value }, 509 | } = (await api!.val.post({}, { data: { somethingCool: '123' } })) as { 510 | data: { value: string }; 511 | }; 512 | 513 | expect(value).toEqual('post!'); 514 | expect(mock.history.post[0].data).toBe( 515 | JSON.stringify({ something_cool: '123' }) 516 | ); 517 | 518 | const { 519 | data: { value: value2 }, 520 | } = (await api!.val()) as { 521 | data: { value: string }; 522 | }; 523 | 524 | expect(value2).toEqual('get!'); 525 | }); 526 | 527 | it('supports modifiers', async () => { 528 | mock 529 | .onGet('/val') 530 | .reply(() => [200, { name: 'count', '@links': { count: '/count' } }]); 531 | mock.onGet('/count').reply(() => [200, 1]); 532 | 533 | const { queryByTestId } = renderSuspending(() => { 534 | const api = useApi(); 535 | const val = api.val.get() as { name: string; count: number }; 536 | const val2 = api.val.get() as { name: string; count: number }; 537 | 538 | return ( 539 |
540 | {val.name}:{val.count}:{String(val === val2)} 541 |
542 | ); 543 | }); 544 | 545 | let element = await waitForElement(() => queryByTestId(`element`)); 546 | 547 | expect(element!.textContent).toEqual('count:1:true'); 548 | }); 549 | 550 | it('supports dependents', async () => { 551 | mock.onGet('/list').reply(() => [200, [{ val: 1, '@url': '/list/1' }]]); 552 | 553 | let setState: any; 554 | const { queryByTestId } = renderSuspending(() => { 555 | const api = useApi(); 556 | const [doRequest, s] = useState(true); 557 | setState = s; 558 | 559 | if (doRequest) api.list() as { val: number }[]; 560 | 561 | try { 562 | const { val } = doRequest 563 | ? (api.list[1]() as { val: number }) 564 | : { val: 0 }; 565 | 566 | return
{val}
; 567 | } catch (e) { 568 | // this should not suspend 569 | throw new Error(); 570 | } 571 | }); 572 | 573 | let element = await waitForElement(() => queryByTestId(`element-1`)); 574 | expect(element!.textContent).toEqual('1'); 575 | 576 | act(() => { 577 | setState(false); 578 | }); 579 | 580 | element = await waitForElement(() => queryByTestId(`element-0`)); 581 | expect(element!.textContent).toEqual('0'); 582 | }); 583 | 584 | it('supports preloading', async () => { 585 | mock.onGet('/users').reply(() => [200, [{ id: 1, name: 'Billy' }]]); 586 | mock.onGet('/posts').reply(() => [200, [{ id: 2, body: 'post body' }]]); 587 | 588 | let users: any = null; 589 | let posts: any = null; 590 | 591 | let renders = 0; 592 | let api: UrlBuilder | null = null; 593 | let promise: Promise | null = null; 594 | renderSuspending(() => { 595 | renders++; 596 | api = useApi(); 597 | 598 | promise = Promise.all([ 599 | preload(() => { 600 | users = api!.users(); 601 | }), 602 | preload(() => { 603 | posts = api!.posts(); 604 | }), 605 | ]); 606 | 607 | return null; 608 | }); 609 | 610 | expect(renders).toEqual(1); 611 | 612 | await promise; 613 | expect(users).toEqual([{ id: 1, name: 'Billy' }]); 614 | expect(posts).toEqual([{ id: 2, body: 'post body' }]); 615 | 616 | await act(async () => { 617 | await touch('users'); 618 | }); 619 | 620 | // make sure the component is not subscribed 621 | expect(renders).toEqual(1); 622 | 623 | let threw = false; 624 | try { 625 | await preload(() => { 626 | throw new Error(); 627 | }); 628 | } catch (_) { 629 | threw = true; 630 | } 631 | expect(threw).toBeTruthy(); 632 | }); 633 | 634 | it('supports preloading outside components', async () => { 635 | mock.onGet('/users').reply(() => [200, [{ id: 1, name: 'Billy' }]]); 636 | mock.onGet('/posts').reply(() => [200, [{ id: 2, body: 'post body' }]]); 637 | 638 | const promise = Promise.all([ 639 | preload(() => { 640 | api!.users(); 641 | }), 642 | preload(() => { 643 | api!.posts(); 644 | }), 645 | ]); 646 | 647 | await promise; 648 | 649 | let renders = 0; 650 | let finished = false; 651 | renderSuspending(() => { 652 | renders++; 653 | const api = useApi(); 654 | 655 | // these should not suspend because they are preloaded 656 | api!.users(); 657 | api!.posts(); 658 | 659 | finished = true; 660 | return null; 661 | }); 662 | 663 | expect(renders).toEqual(1); 664 | expect(finished).toEqual(true); 665 | }); 666 | 667 | it('works with 404 returning null', async () => { 668 | mock.onGet('/values/4').reply(404); 669 | 670 | const { queryByTestId } = renderSuspending(() => { 671 | const api = useApi(); 672 | const value = api.values[4]() as { someValue: number } | null; 673 | 674 | return ( 675 |
{value === null ? 'null' : 'not null'}
676 | ); 677 | }); 678 | 679 | let element = await waitForElement(() => queryByTestId('element')); 680 | expect(element!.textContent).toEqual('null'); 681 | 682 | // is it null after a touch as well? 683 | touch('values'); 684 | element = await waitForElement(() => queryByTestId('element')); 685 | 686 | expect(element!.textContent).toEqual('null'); 687 | }); 688 | 689 | it('works with server error throwing error', async () => { 690 | mock.onGet('/error').networkErrorOnce(); 691 | 692 | const { queryByTestId } = renderSuspending(() => { 693 | const api = useApi(); 694 | let errored = false; 695 | 696 | try { 697 | api.error.get(); 698 | } catch (e) { 699 | if (typeof e.then === 'function') { 700 | throw e; 701 | } 702 | 703 | errored = true; 704 | } 705 | 706 | return ( 707 |
{errored ? 'crash' : 'no crash Smee'}
708 | ); 709 | }); 710 | 711 | const element = await waitForElement(() => queryByTestId('element')); 712 | 713 | expect(element!.textContent).toEqual('crash'); 714 | }); 715 | 716 | it('works with server error throwing error (non hook)', async () => { 717 | mock.onPost('/error').reply(500, { error_value: 0 }); 718 | 719 | let err = null; 720 | try { 721 | await api.error.post(); 722 | } catch (e) { 723 | err = e; 724 | } 725 | 726 | expect(err instanceof Error).toBe(true); 727 | expect(err.response.data).toEqual({ errorValue: 0 }); 728 | }); 729 | 730 | it('works with server error throwing error (outside hook)', async () => { 731 | mock.onPost('/error').reply(500, { error_value: 0 }); 732 | 733 | let api: UrlBuilder | null = null; 734 | renderSuspending(() => { 735 | api = useApi(); 736 | 737 | return null; 738 | }); 739 | 740 | let err = null; 741 | try { 742 | await api!.error.post(); 743 | } catch (e) { 744 | err = e; 745 | } 746 | 747 | expect(err instanceof Error).toBe(true); 748 | expect(err.response.data).toEqual({ errorValue: 0 }); 749 | }); 750 | 751 | it('can save to a POJO', async () => { 752 | mock.onGet('/thing').reply(200, { value: 0 }); 753 | 754 | const { queryByTestId } = renderSuspending(() => { 755 | const api = useApi(); 756 | api.thing(); 757 | return
; 758 | }); 759 | 760 | await waitForElement(() => queryByTestId('element')); 761 | 762 | expect(save()).toEqual({ '/thing': { value: 0 } }); 763 | }); 764 | 765 | it('can restore from a POJO', async () => { 766 | mock.onGet('/thing').reply(200, { value: 0 }); 767 | restore({ '/thing': { value: 0 } }); 768 | 769 | let renders = 0; 770 | const { queryByTestId } = renderSuspending(() => { 771 | renders++; 772 | const api = useApi(); 773 | api.thing(); 774 | return
; 775 | }); 776 | 777 | await waitForElement(() => queryByTestId('element')); 778 | 779 | // make sure the call did not suspend 780 | expect(renders).toBe(1); 781 | }); 782 | 783 | it('can load from an external cache', async () => { 784 | let val = null; 785 | const { queryByTestId } = renderSuspending(() => { 786 | const api = useApi(); 787 | val = api.fromCache(); 788 | return
; 789 | }); 790 | 791 | await waitForElement(() => queryByTestId('element')); 792 | 793 | expect(val).toEqual({ derp: 'derp' }); 794 | }); 795 | 796 | it('reads axios options from provider (query params)', async () => { 797 | function renderSuspending(fn: FunctionComponent) { 798 | const Component = fn; 799 | 800 | return render( 801 | 802 | 803 | 804 | 805 | 806 | ); 807 | } 808 | 809 | mock.onGet('/users?a=1&b=1').reply(200, [{ id: 1, name: 'John Smith' }]); 810 | mock.onGet('/users?a=1').reply(200, [{ id: 2, name: 'Jane Doe' }]); 811 | let api: ReturnType | null = null; 812 | const { queryByTestId } = renderSuspending(() => { 813 | const a = useApi(); 814 | api = a; 815 | const users = api.users({ b: 1 }) as { id: number; name: string }[]; 816 | 817 | return
{users.map(u => u.name).join(',')}
; 818 | }); 819 | 820 | const element = await waitForElement(() => queryByTestId('element')); 821 | 822 | expect(element!.textContent).toEqual('John Smith'); 823 | 824 | mock.onPost('/val?a=1').reply(() => [200, { value: 'post!' }]); 825 | mock.onGet('/val?a=1').reply(() => [200, { value: 'get!' }]); 826 | 827 | const { 828 | data: { value }, 829 | } = (await api!.val.post({}, { data: { somethingCool: '123' } })) as { 830 | data: { value: string }; 831 | }; 832 | 833 | expect(value).toEqual('post!'); 834 | expect(mock.history.post[0].data).toBe( 835 | JSON.stringify({ something_cool: '123' }) 836 | ); 837 | 838 | const { 839 | data: { value: value2 }, 840 | } = (await api!.val()) as { 841 | data: { value: string }; 842 | }; 843 | 844 | expect(value2).toEqual('get!'); 845 | }); 846 | 847 | it('reads axios options from provider (headers)', async () => { 848 | const token = 'abc'; 849 | function renderSuspending(fn: FunctionComponent) { 850 | const Component = fn; 851 | 852 | return render( 853 | 854 | 855 | 856 | 857 | 858 | ); 859 | } 860 | 861 | const withToken = (reply: any) => (config: AxiosRequestConfig) => 862 | config.headers.token === token ? reply : [404]; 863 | 864 | mock 865 | .onGet('/users?b=1') 866 | .reply(withToken([200, [{ id: 1, name: 'John Smith' }]])); 867 | let api: ReturnType | null = null; 868 | const { queryByTestId } = renderSuspending(() => { 869 | const a = useApi(); 870 | api = a; 871 | const users = api.users({ b: 1 }) as { id: number; name: string }[]; 872 | 873 | return
{users.map(u => u.name).join(',')}
; 874 | }); 875 | 876 | const element = await waitForElement(() => queryByTestId('element')); 877 | 878 | expect(element!.textContent).toEqual('John Smith'); 879 | 880 | mock.onPost('/val').reply(withToken([200, { value: 'post!' }])); 881 | mock.onGet('/val').reply(withToken([200, { value: 'get!' }])); 882 | 883 | const { 884 | data: { value }, 885 | } = (await api!.val.post({}, { data: { somethingCool: '123' } })) as { 886 | data: { value: string }; 887 | }; 888 | 889 | expect(value).toEqual('post!'); 890 | expect(mock.history.post[0].data).toBe( 891 | JSON.stringify({ something_cool: '123' }) 892 | ); 893 | 894 | const { 895 | data: { value: value2 }, 896 | } = (await api!.val()) as { 897 | data: { value: string }; 898 | }; 899 | 900 | expect(value2).toEqual('get!'); 901 | }); 902 | 903 | it('works with query params that start with _', async () => { 904 | mock.onGet('/users?__a=1').reply(200, [{ id: 1, name: 'John Smith' }]); 905 | 906 | const { queryByTestId } = renderSuspending(() => { 907 | const api = useApi(); 908 | const users = api.users({ __a: 1 }) as { id: number; name: string }[]; 909 | 910 | return
{users.length}
; 911 | }); 912 | 913 | const element = await waitForElement(() => queryByTestId('element')); 914 | 915 | expect(element!.textContent).toEqual('1'); 916 | }); 917 | 918 | it('works with retries (pass)', async () => { 919 | const { useApi, touch } = createApi(axios, { 920 | retryCount: 10, 921 | }); 922 | 923 | let didFail = false; 924 | let count = 10; 925 | mock.onGet('/thing').reply(() => { 926 | realSetTimeout(() => { 927 | advanceTimers(); 928 | }, 10); 929 | if (count > 0) { 930 | didFail = true; 931 | count--; 932 | return [500, { error: true }]; 933 | } 934 | 935 | count = 10; 936 | return [200, { someValue: 123 }]; 937 | }); 938 | 939 | const { queryByTestId } = renderSuspending(() => { 940 | const api = useApi(); 941 | const value = api.thing() as { someValue: number }; 942 | 943 | return
{value.someValue}
; 944 | }); 945 | 946 | const element = await waitForElement(() => queryByTestId('element')); 947 | 948 | expect(element!.textContent).toEqual('123'); 949 | 950 | expect(didFail).toBe(true); 951 | didFail = false; 952 | 953 | await act(async () => { 954 | await touch('thing'); 955 | }); 956 | 957 | await waitForElement(() => queryByTestId('element')); 958 | 959 | expect(didFail).toBe(true); 960 | }); 961 | 962 | it('works with retries (fail)', async () => { 963 | const { useApi, touch } = createApi(axios, { 964 | retryCount: 10, 965 | }); 966 | 967 | let requests = 0; 968 | mock.onGet('/thing').reply(() => { 969 | realSetTimeout(() => { 970 | advanceTimers(); 971 | }, 10); 972 | 973 | requests++; 974 | 975 | return [500, { error: true }]; 976 | }); 977 | 978 | const { queryByTestId } = renderSuspending(() => { 979 | const api = useApi(); 980 | try { 981 | const value = api.thing() as { someValue: number }; 982 | 983 | return
{value.someValue}
; 984 | } catch (e) { 985 | if (e instanceof Error) { 986 | return
Error
; 987 | } else throw e; 988 | } 989 | }); 990 | 991 | const element = await waitForElement(() => queryByTestId('element')); 992 | 993 | expect(element!.textContent).toEqual('Error'); 994 | expect(requests).toBe(11); 995 | 996 | await act(async () => { 997 | await touch('thing'); 998 | }); 999 | 1000 | const element2 = await waitForElement(() => queryByTestId('element')); 1001 | expect(element2!.textContent).toEqual('Error'); 1002 | expect(requests).toBe(22); 1003 | }); 1004 | 1005 | it('serializes arrays to brackets', async () => { 1006 | const { useApi } = createApi(axios, {}); 1007 | 1008 | mock.onGet('/thing?include[]=derp&include[]=doo').reply(() => { 1009 | return [200, { someValue: 123 }]; 1010 | }); 1011 | 1012 | const { queryByTestId } = renderSuspending(() => { 1013 | const api = useApi(); 1014 | const value = api.thing({ include: ['derp', 'doo'] }) as { 1015 | someValue: number; 1016 | }; 1017 | 1018 | return
{value.someValue}
; 1019 | }); 1020 | 1021 | const element = await waitForElement(() => queryByTestId('element')); 1022 | 1023 | expect(element!.textContent).toEqual('123'); 1024 | }); 1025 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true, 29 | "downlevelIteration": true 30 | } 31 | } 32 | --------------------------------------------------------------------------------