├── .eslintignore ├── .flowconfig ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── LICENSE ├── README.md ├── docs ├── api │ ├── cache.md │ ├── create-fetcher.md │ ├── fetch.md │ └── resource.md ├── getting-started │ ├── introduction.md │ └── tutorial.md └── index.md ├── examples ├── .eslintrc ├── .gitignore ├── README.md └── simple │ ├── README.md │ ├── package.json │ ├── public │ └── index.html │ └── src │ ├── index.css │ └── index.js ├── flow-typed └── npm │ └── jest_v24.x.x.js ├── package.json ├── rollup.config.js ├── src ├── Cache.js ├── Fetch.js ├── Resource.js ├── checkStatus.js ├── createFetcher.js ├── index.js ├── parseBody.js ├── parseError.js └── types.js ├── test ├── .eslintrc ├── Cache.test.js ├── Fetch.test.js ├── Resource.test.js ├── checkStatus.test.js ├── createFetcher.test.js ├── parseBody.test.js ├── parseError.test.js └── setupJest.js ├── website ├── .babelrc.js ├── .eslintrc ├── components │ ├── ColorModeToggle.js │ ├── Container.js │ ├── Footer.js │ ├── Header.js │ ├── Layout.js │ ├── Link.js │ ├── Main.js │ ├── Pager.js │ ├── Palette.js │ ├── RsrcLogo.js │ ├── RsrcType.js │ ├── Sidebar.js │ └── SigSciLogo.js ├── next.config.js ├── package.json ├── pages │ ├── 404.js │ ├── _app.js │ ├── _document.js │ ├── docs │ └── index.js ├── public │ ├── robots.txt │ └── static │ │ ├── favicon.png │ │ ├── rsrc-logo.svg │ │ ├── rsrc-og.png │ │ └── rsrc-type.svg ├── theme │ ├── colors.js │ ├── index.js │ └── styles.js └── yarn.lock └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | examples 4 | flow-typed 5 | node_modules 6 | .next 7 | .tmp 8 | out 9 | website/rsrc 10 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | /coverage 3 | /dist 4 | /docs 5 | /examples 6 | /website 7 | /.tmp 8 | /node_modules/resolve/test 9 | 10 | [include] 11 | 12 | [libs] 13 | 14 | [lints] 15 | 16 | [options] 17 | module.file_ext=.js 18 | module.file_ext=.md 19 | 20 | [strict] 21 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Thanks for contributing! 2 | 3 | Please refer to issue & pull request templates. 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expected Behavior 2 | 3 | ## Actual Behavior 4 | 5 | ## Environment (where applicable) 6 | 7 | - OS: 8 | - Node: 9 | - React: 10 | - Browser: 11 | - Build Tools: 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What problem are we solving? 2 | 3 | ## What does this do? 4 | 5 | ## What does this not do? 6 | 7 | ## References and related issues: 8 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [12.x] 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - name: Install yarnpkg 18 | run: npm i -g yarn 19 | - name: Install deps and build package 20 | run: yarn install --frozen-lockfile 21 | - name: Install docs deps 22 | run: cd website && yarn install --frozen-lockfile 23 | - name: Run lint 24 | run: yarn lint 25 | env: 26 | CI: true 27 | - name: Run tests 28 | run: yarn test 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | package-lock.json 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # temp 59 | .tmp 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # next.js build output 65 | .next 66 | out 67 | website/rsrc 68 | 69 | # dist 70 | dist 71 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .storybook/ 3 | packages/ 4 | .babelrc 5 | .DS_Store 6 | .gitignore 7 | lerna.json 8 | postcss.config.json 9 | styleguide.config.json 10 | yarn.lock 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | flow-typed 4 | node_modules 5 | .next 6 | out 7 | website/rsrc 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Signal Sciences, Inc. 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 | ![Logo](https://signalsciences.github.io/rsrc/static/rsrc-logo.svg) 2 | 3 | ![Rsrc](https://signalsciences.github.io/rsrc/static/rsrc-type.svg) 4 | 5 | [![ci][ci-badge]][ci] 6 | [![version][version-badge]][npm] 7 | [![license][license-badge]](LICENSE.md) 8 | [![gzip size][gzip-badge]][gzip] 9 | 10 | [ci-badge]: https://github.com/signalsciences/rsrc/workflows/CI/badge.svg 11 | [ci]: https://github.com/signalsciences/rsrc/actions 12 | [version-badge]: https://badgen.net/npm/v/rsrc 13 | [npm]: https://npmjs.com/package/rsrc 14 | [gzip-badge]: http://badgen.net/bundlephobia/minzip/rsrc 15 | [gzip]: https://bundlephobia.com/result?p=rsrc 16 | [license-badge]: https://badgen.net/badge/license/MIT/blue 17 | 18 | A collection of components designed to simplify fetch state in React. 19 | 20 | **[Docs](https://signalsciences.github.io/rsrc)** 21 | 22 | ## Getting Started 23 | 24 | ``` 25 | yarn add rsrc 26 | ``` 27 | 28 | ## Usage 29 | 30 | ```jsx 31 | import React from "react"; 32 | import { Resource } from "rsrc"; 33 | 34 | export default (props) => { 35 | const { id } = props; 36 | const url = `/todos/${id}`; 37 | 38 | return ( 39 | ({ 44 | options: { 45 | method: "DELETE", 46 | }, 47 | invalidates: ["/todos"], 48 | }), 49 | }} 50 | > 51 | {({ state, actions }) => { 52 | if (!state.fulfilled) return null; 53 | 54 | const todo = state.value; 55 | 56 | const handleClick = () => { 57 | actions 58 | .remove() 59 | .then((value) => { 60 | /* */ 61 | }) 62 | .catch((error) => { 63 | /* */ 64 | }); 65 | }; 66 | 67 | return ( 68 |
69 |

{todo.name}

70 |

{todo.description}

71 | 72 |
73 | ); 74 | }} 75 |
76 | ); 77 | }; 78 | ``` 79 | 80 | ### Related 81 | 82 | - [Redux](https://github.com/reduxjs/redux) 83 | - [React Refetch](https://github.com/heroku/react-refetch) 84 | -------------------------------------------------------------------------------- /docs/api/cache.md: -------------------------------------------------------------------------------- 1 | # Cache 2 | 3 | Cache is a context provider with a map-like interface. It can store any 4 | arbitrary key-value pairs. The `Fetch` component uses `Cache` to store promises 5 | returned from `GET` requests keyed by the URL. The timestamp information is 6 | also included to assist with time-based invalidation strategies (via the 7 | `maxAge` prop). 8 | 9 | ```jsx 10 | React.Component 11 | ``` 12 | 13 | ## Props 14 | 15 | ```jsx 16 | type CacheProps = { 17 | map: Map<*, *>, 18 | children?: React.Node, 19 | }; 20 | ``` 21 | 22 | > The `map` prop accepts any map-like interface. At a minimum, it should be 23 | > iterable and provide methods for `get()`, `set()`, and `delete()`. 24 | 25 | ## State 26 | 27 | ```jsx 28 | type CacheState = { 29 | get: (key: *) => *, 30 | set: (key: *, value: *) => Map<*, *>, 31 | delete: (key: *) => boolean, 32 | entries: () => Iterator<*>, 33 | values: () => Iterator<*>, 34 | keys: () => Iterator<*>, 35 | }; 36 | ``` 37 | 38 | This state is passed to the context provider which is then used internally by 39 | `Resource`. 40 | 41 | ## Example 42 | 43 | > The cache consumer shown below is for illustration purposes only. It is used 44 | > internally by `Resource` and is not meant to be used directly. 45 | 46 | ```jsx 47 | import React from "react"; 48 | import { Cache } from "rsrc"; 49 | 50 | export default () => ( 51 | 52 | 53 | {(cache) => { 54 | const addRandomEntry = () => { 55 | cache.set(+new Date(), Math.random()); 56 | }; 57 | const removeEntry = (key) => { 58 | cache.delete(key); 59 | }; 60 | return ( 61 | <> 62 | 63 |
    64 | {[...cache.entries()].map(([key, value]) => ( 65 |
  • 66 | 67 | {`${key}: ${value}`} 68 |
  • 69 | ))} 70 |
71 | 72 | ); 73 | }} 74 |
75 |
76 | ); 77 | ``` 78 | -------------------------------------------------------------------------------- /docs/api/create-fetcher.md: -------------------------------------------------------------------------------- 1 | # createFetcher() 2 | 3 | [TODO] 4 | 5 | Advanced feature to customize fetch behavior 6 | -------------------------------------------------------------------------------- /docs/api/fetch.md: -------------------------------------------------------------------------------- 1 | # Fetch 2 | 3 | Fetch is a component that exposes a declarative interface for the `fetch` API. 4 | Its main job is to translate the promise state of a fetch operation into 5 | consumable props. It also provides methods to read, invalidate, and refresh 6 | fetch states. 7 | 8 | ```jsx 9 | React.Component 10 | ``` 11 | 12 | > The Fetch component is designed to work with `GET` requests. This is for 2 reasons: 13 | > 14 | > 1. `GET` requests are typically 15 | > [safe](https://developer.mozilla.org/en-US/docs/Glossary/safe), and 16 | > [cacheable](https://developer.mozilla.org/en-US/docs/Glossary/cacheable). 17 | > 2. `POST`, `PUT`, `PATCH`, and `DELETE` methods are typically reserved for 18 | > operations that modify state on the server. In UI terms, these actions 19 | > usually require user interaction, e.g. `onClick`, `onSubmit`. 20 | > 21 | > To make additional operations for a particular endpoint available to 22 | > children, consider using a Resource component. 23 | 24 | ## Props 25 | 26 | ```jsx 27 | type FetchProps = { 28 | url: string, 29 | options: RequestOptions, 30 | maxAge: number, 31 | children?: (FetchState) => React.Node, 32 | 33 | /* advanced options */ 34 | cache: CacheState, 35 | fetcher: Fetcher, 36 | }; 37 | ``` 38 | 39 | > `maxAge` is the maximum **number of seconds** since last resolved that a cached 40 | > result will be considered acceptable. The fetcher will always look in the cache 41 | > first. If the result is not found, or the last resolved timestamp is outside the 42 | > maximum allowed, the fetcher will automatically refresh the result. 43 | 44 | ## FetchState 45 | 46 | ```jsx 47 | type FetchState = { 48 | pending: boolean, 49 | rejected: boolean, 50 | fulfilled: boolean, 51 | value: ?any, 52 | reason: ?Error 53 | invalidate: () => void, 54 | read: () => void, 55 | refresh: () => void 56 | }; 57 | ``` 58 | 59 | ## Example 60 | 61 | ```jsx 62 | import React from "react"; 63 | 64 | export default () => { 65 | const url = `https://api.example.com/users`; 66 | const options = { 67 | method: "GET", 68 | headers: { 69 | "Content-Type": "application/json; charset=UTF-8", 70 | }, 71 | }; 72 | const maxAge = 60 * 60; // 1 hour 73 | 74 | return ( 75 | 76 | {(fetchState) => { 77 | if (fetchState.pending) return "Loading..."; 78 | 79 | if (fetchState.rejected) return "Error"; 80 | 81 | const users = fetchState.value.data; 82 | 83 | return ( 84 | <> 85 | 86 | 87 | 88 |
    89 | {users.map((user) => ( 90 |
  • {user.name}
  • 91 | ))} 92 |
93 | 94 | ); 95 | }} 96 |
97 | ); 98 | }; 99 | ``` 100 | -------------------------------------------------------------------------------- /docs/api/resource.md: -------------------------------------------------------------------------------- 1 | # Resource 2 | 3 | Resource glues together the functionality of Cache and Fetch with a more 4 | expressive interface. Since Fetch is limited to handle only the `GET` method 5 | for a particular endpoint, Resource expands on this to allow additional or 6 | related operations to be declared and exposed to children via `actions`. 7 | 8 | ```jsx 9 | React.Component 10 | ``` 11 | 12 | ## Props 13 | 14 | ```jsx 15 | type Action = (any) => { 16 | url?: string, 17 | options?: RequestOptions, 18 | maxAge?: number, 19 | invalidates?: string | Array, 20 | }; 21 | ``` 22 | 23 | > Note: invalidation from actions is a convenience mechanism to automatically 24 | > invalidate related cache keys any time a given action is successful. The 25 | > `invalidates` prop is used to create matchers that get compared to cached 26 | > keys. For RESTful endpoints, the actions defined in a `` 27 | > typically map to modification requests (POST, PUT, PATCH, DELETE). These 28 | > operations result in changes to the underlying entities and collections. 29 | > Query strings used to filter collections are usually not relevant in deciding 30 | > whether it should be invalidated. For simplicity, we invalidate all keys 31 | > related to the endpoint and ignore search strings. For example: 32 | > `invalidates={ ["/foos?bar=1", "/foos/1234"] }` is converted to matchers with 33 | > the search omitted: `["/foos", "/foos/1234"]`. 34 | 35 | ```jsx 36 | type ResourceProps = { 37 | url: string, 38 | options: RequestOptions, 39 | maxAge: number, 40 | actions: { [key: string]: Action }, 41 | children?: (ResourceState) => React.Node, 42 | 43 | /* advanced options */ 44 | fetcher: Fetcher, 45 | }; 46 | ``` 47 | 48 | ## State 49 | 50 | ```jsx 51 | type FetchState = { 52 | pending: boolean, 53 | rejected: boolean, 54 | fulfilled: boolean, 55 | value: ?any, 56 | reason: ?Error 57 | invalidate: () => void, 58 | read: () => void, 59 | refresh: () => void 60 | }; 61 | 62 | type ResourceState = { 63 | state: FetchState, 64 | actions: { 65 | [key: string]: (*) => Promise<*> 66 | }, 67 | meta: ResourceProps 68 | }; 69 | ``` 70 | 71 | ## Example 72 | 73 | ```jsx 74 | // resources/users/User.js 75 | 76 | import React from "react"; 77 | import { Resource } from "rsrc"; 78 | import Form from "./Form"; 79 | 80 | export default ({ id }) => { 81 | const url = `https://api.example.com/users/${id}`; 82 | const options = { 83 | method: "GET", 84 | headers: { 85 | "Content-Type": "application/json; charset=UTF-8", 86 | }, 87 | }; 88 | const maxAge = 60 * 60; // 1 hour 89 | 90 | const update = (data) => ({ 91 | url, 92 | options: { 93 | ...options, 94 | method: "PATCH", 95 | body: JSON.stringify(data), 96 | }, 97 | invalidates: [url], 98 | }); 99 | 100 | return ( 101 | 102 | {(resource) => { 103 | const { state, actions } = resource; 104 | 105 | if (state.pending) return "Loading..."; 106 | 107 | if (state.rejected) return "Error"; 108 | 109 | const handleSubmit = (formValues) => { 110 | actions 111 | .update(formValues) 112 | .then((value) => console.log("Success", value)) 113 | .catch((error) => console.log("Fail", error.message)); 114 | }; 115 | 116 | const { name, email } = state.value; 117 | 118 | return
; 119 | }} 120 | 121 | ); 122 | }; 123 | ``` 124 | -------------------------------------------------------------------------------- /docs/getting-started/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | There are a number of great solutions for managing async fetch state in the 4 | React ecosystem but many of these require a non-trivial amount of boilerplate 5 | or framework knowledge to implement and maintain. 6 | 7 | This project attempts to identify and abstract generic fetch state utilities 8 | into composable components that are as flexible and expressive as the underlying 9 | technologies they expose. 10 | 11 | For more on the backstory, please check out the 12 | [announcement](https://building.signalsciences.com/rsrc) on the 13 | [Signal Sciences blog](https://building.signalsciences.com/). 14 | 15 | ## Motivation 16 | 17 | - reduce dependence on common boilerplate for managing fetch state 18 | - facilitate the collocation of data fetching alongside the components that 19 | rely on it 20 | - simplify cache management and invalidation 21 | 22 | ## What does this do? 23 | 24 | | | | 25 | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 26 | | **Cache** | Cache is a context provider that exposes a [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map)-like interface. This should be mounted near the top of your application tree similar to other context providers. | 27 | | **Fetch** | Fetch is a component that exposes a declarative interface for the [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) api. Its main job is to translate [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) state into consumable props and provide methods to read, invalidate, and refresh fetch states. | 28 | | **Resource** | Resource glues together the above functionality with a more expressive interface. If Fetch represents an individual request state, a Resource can be thought of as a more generic entity description that defines and exposes additional or related actions for a given endpoint. | 29 | 30 | ## What doesn't this do? 31 | 32 | Server-side rendering (SSR), and cache initialization from serialized state are 33 | not supported out of the box. This is because rsrc leverages raw promises rather 34 | than serialized objects to persist state internally. 35 | 36 | That said, the cache component accepts any map-like interface, so it should be 37 | possible to serialize resolved states, and then rewrap them in promises before 38 | passing along to the cache provider. 39 | 40 | ## References & Credits 41 | 42 | Related projects, inspiration, and references: 43 | 44 | - [heroku/react-refetch](https://github.com/heroku/react-refetch) 45 | -------------------------------------------------------------------------------- /docs/getting-started/tutorial.md: -------------------------------------------------------------------------------- 1 | # Tutorial 2 | 3 | ``` 4 | yarn add rsrc 5 | ``` 6 | 7 | > Requires `react@>=16.8`. 8 | > The [fetch 9 | > API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) 10 | > will also need to be natively supported or polyfilled for your target 11 | > environments. 12 | 13 | ## Configure a cache provider 14 | 15 | The cache provider accepts a single, optional prop called `map`, which defaults 16 | to `new Map()`. The value is expected to expose a 17 | [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) 18 | or map-like interface — meaning it is iterable and provides methods for 19 | `get()`, `set()`, and `delete()`. 20 | 21 | For larger applications, you might want to consider a [least recently used 22 | (LRU)]() 23 | implementation like [quick-lru](https://www.npmjs.com/package/quick-lru) which 24 | can help limit memory usage. 25 | 26 | ```jsx 27 | // index.js 28 | 29 | import React from "react"; 30 | import { render } from "react-dom"; 31 | import { Router } from "@reach/router"; 32 | import { Cache } from "rsrc"; 33 | import LRU from "quick-lru"; 34 | import Todos from "./pages/Todos"; 35 | 36 | const lru = new LRU({ maxSize: 100 }); 37 | 38 | const App = () => ( 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | 46 | render(, document.getElementById("root")); 47 | ``` 48 | 49 | ## Define a resource 50 | 51 | A resource can be thought of as a component-level description of an API endpoint 52 | and its available methods. In most cases this will mirror the interface provided 53 | by the endpoint itself along with any related operations that might be useful when 54 | dealing with the resource. 55 | 56 | In the example below, we have an endpoint at `/todos`. While a `GET` request to 57 | this endpoint could return a collection of "todos", it's likely designed to 58 | accommodate additional operations (`POST`, `PATCH`, etc). We can define these 59 | methods as seen in the `actions` prop below. 60 | 61 | For actions that modify a resource, you can provide an additional argument to 62 | automatically invalidate related resources when the action resolves 63 | successfully. 64 | 65 | ```jsx 66 | // resources/todos/Resource.js 67 | 68 | import React from "react"; 69 | import { Resource } from "rsrc"; 70 | 71 | export default ({ children }) => { 72 | const url = "https://api.example.com/todos"; 73 | const options = { 74 | headers: { 75 | "Content-Type": "application/json", 76 | }, 77 | }; 78 | const actions = { 79 | create: (data) => ({ 80 | options: { 81 | ...options, 82 | method: "POST", 83 | body: JSON.stringify(data), 84 | }, 85 | invalidates: [url], 86 | }), 87 | markComplete: (id) => ({ 88 | url: `${url}/{id}`, 89 | options: { 90 | ...options, 91 | method: "PATCH", 92 | body: JSON.stringify({ completed: true }), 93 | }, 94 | invalidates: [url, `${url}/${id}`], 95 | }), 96 | }; 97 | 98 | return ( 99 | 100 | {children} 101 | 102 | ); 103 | }; 104 | ``` 105 | 106 | ## Build out your views 107 | 108 | A resource view is a component that has been tailored to consume a particular 109 | resource value. 110 | 111 | ```jsx 112 | // resources/todos/List.js 113 | 114 | import React from "react"; 115 | 116 | export default (props) => { 117 | const { onSuccess, onFail, resource } = props; 118 | const { state, actions } = resource; 119 | 120 | if (state.pending) return "Loading..."; 121 | 122 | if (state.rejected) return `Error: ${state.reason.message}`; 123 | 124 | const todos = state.value.filter((todo) => !todo.completed); 125 | 126 | const handleClick = (id) => { 127 | actions 128 | .markcomplete(id) 129 | .then((value) => { 130 | onSuccess(value); 131 | }) 132 | .catch((error) => { 133 | onFail(error); 134 | }); 135 | }; 136 | 137 | return ( 138 |
    139 | {todos.map((todo) => ( 140 |
  • 141 | 142 | {todo.title} 143 |
  • 144 | ))} 145 |
146 | ); 147 | }; 148 | ``` 149 | 150 | ```jsx 151 | // resources/todos/Create.js 152 | 153 | import React from "react"; 154 | import TodosForm from "./Form"; 155 | 156 | export default (props) => { 157 | const { onSuccess, onFail, resource } = props; 158 | const { state, actions } = resource; 159 | 160 | const handleSubmit = (formValues) => { 161 | actions 162 | .create(formValues) 163 | .then((value) => { 164 | onSuccess(value); 165 | }) 166 | .catch((error) => { 167 | onFail(error); 168 | }); 169 | }; 170 | 171 | return ; 172 | }; 173 | ``` 174 | 175 | ## Use it on a page 176 | 177 | ```jsx 178 | // pages/Todos.js 179 | 180 | import React from "react"; 181 | import { notify } from "../utils"; 182 | import { Todos, TodosList, TodosCreate } from "../resources/todos"; 183 | 184 | export default (props) => { 185 | const handleSuccess = (value) => { 186 | notify({ purpose: "success", message: `Success: ${value.id}` }); 187 | }; 188 | 189 | const handleFail = (error) => { 190 | notify({ purpose: "error", message: `Fail: ${error.message}` }); 191 | }; 192 | 193 | return ( 194 | 195 | {(resource) => ( 196 | <> 197 | 202 | 207 | 208 | )} 209 | 210 | ); 211 | }; 212 | ``` 213 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | #### Getting Started 2 | 3 | - [Introduction](/docs/getting-started/introduction) 4 | - [Tutorial](/docs/getting-started/tutorial) 5 | 6 | #### API Reference 7 | 8 | - [Cache](/docs/api/cache) 9 | - [Fetch](/docs/api/fetch) 10 | - [Resource](/docs/api/resource) 11 | -------------------------------------------------------------------------------- /examples/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "react/prop-types": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | See individual example directories for details. 4 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | # Simple Example 2 | 3 | Open in [Code 4 | Sandbox](https://codesandbox.io/s/github/signalsciences/rsrc/tree/main/examples/simple) 5 | or run locally: 6 | 7 | ``` 8 | git clone https://github.com/signalsciences/rsrc.git 9 | 10 | cd rsrc/examples/simple 11 | yarn 12 | yarn start 13 | ``` 14 | -------------------------------------------------------------------------------- /examples/simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rsrc-examples-simple", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.10.2", 7 | "react-dom": "^16.10.2", 8 | "react-scripts": "^3.2.0", 9 | "rsrc": "^1.0.0" 10 | }, 11 | "scripts": { 12 | "start": "react-scripts start" 13 | }, 14 | "browserslist": { 15 | "production": [ 16 | ">0.2%", 17 | "not dead", 18 | "not op_mini all" 19 | ], 20 | "development": [ 21 | "last 1 chrome version", 22 | "last 1 firefox version", 23 | "last 1 safari version" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/simple/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React App 7 | 8 | 9 |
10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/simple/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: rgb(77, 46, 229); 3 | color: white; 4 | font-family: system-ui, sans-serif; 5 | font-size: 18px; 6 | line-height: 2rem; 7 | } 8 | input { 9 | border: 1px solid white; 10 | padding: 8px 16px; 11 | margin: 2px; 12 | font-size: 18px; 13 | } 14 | button { 15 | background: transparent; 16 | border: 1px solid white; 17 | color: white; 18 | padding: 8px 16px; 19 | margin: 2px; 20 | cursor: pointer; 21 | font-size: 18px; 22 | text-align: left; 23 | } 24 | button.link { 25 | padding: 0; 26 | margin: 0; 27 | } 28 | button:disabled { 29 | opacity: 0.6; 30 | } 31 | ul { 32 | margin: 0; 33 | padding: 0; 34 | list-style-type: none; 35 | } 36 | li { 37 | margin: 4px; 38 | } 39 | li button { 40 | border: 0; 41 | } 42 | -------------------------------------------------------------------------------- /examples/simple/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Cache, Resource } from "rsrc"; 4 | import "./index.css"; 5 | 6 | const User = ({ id, children }) => { 7 | const url = `https://jsonplaceholder.typicode.com/users/${id}`; 8 | return {children}; 9 | }; 10 | 11 | const PostsList = ({ activePostId, onItemClick }) => { 12 | const url = "https://jsonplaceholder.typicode.com/posts"; 13 | return ( 14 | 15 | {({ state }) => { 16 | const { value } = state; 17 | const maxPosts = 5; 18 | const posts = value ? value.slice(0, maxPosts) : []; 19 | return ( 20 | <> 21 | 28 | 35 |
    36 | {posts.map((post) => ( 37 |
  • 38 | 46 |
  • 47 | ))} 48 |
49 | 50 | ); 51 | }} 52 |
53 | ); 54 | }; 55 | 56 | const Post = ({ id, children }) => { 57 | const url = `https://jsonplaceholder.typicode.com/posts/${id}`; 58 | const options = { 59 | method: "GET", 60 | headers: { 61 | "Content-Type": "application/json; charset=UTF-8", 62 | }, 63 | }; 64 | const maxAge = 60 * 60; // 1 hour 65 | const update = (data) => ({ 66 | // url: "/foo", 67 | options: { 68 | ...options, 69 | method: "PATCH", 70 | body: JSON.stringify(data), 71 | }, 72 | invalidates: [url, "https://jsonplaceholder.typicode.com/posts"], 73 | }); 74 | return ( 75 | 76 | {children} 77 | 78 | ); 79 | }; 80 | 81 | const PostForm = ({ initialState, onSubmit }) => { 82 | const [title, setTitle] = useState(initialState.title); 83 | const handleSubmit = (event) => { 84 | event.preventDefault(); 85 | onSubmit({ title }); 86 | }; 87 | return ( 88 | 89 | setTitle(event.currentTarget.value)} 93 | /> 94 | 95 | 96 | ); 97 | }; 98 | 99 | const PostDetail = ({ resource }) => { 100 | const [message, setMessage] = useState(""); 101 | const { state, actions } = resource; 102 | if (state.pending) return "Loading..."; 103 | if (state.rejected) return "Error"; 104 | const handleSubmit = (data) => { 105 | actions 106 | .update(data) 107 | .then((value) => { 108 | setMessage(`Success!\n\n${value.title}`); 109 | }) 110 | .catch((error) => { 111 | setMessage(`Failed to update.\n\n${error.message}`); 112 | }); 113 | }; 114 | const post = state.value; 115 | return ( 116 | <> 117 |

{post.title}

118 | 119 | {(userResource) => ( 120 | 121 | By:{" "} 122 | {userResource.state.value ? userResource.state.value.name : "..."} 123 | 124 | )} 125 | 126 |

{post.body}

127 |
128 | 129 |
{message}
130 | 131 | ); 132 | }; 133 | 134 | const Root = () => { 135 | const [postId, setPostId] = useState(1); 136 | return ( 137 | 138 |
139 |
140 | setPostId(id)} 143 | /> 144 |
145 |
146 | 147 | {(resource) => } 148 | 149 |
150 |
151 |
152 | ); 153 | }; 154 | 155 | ReactDOM.render(, document.getElementById("root")); 156 | -------------------------------------------------------------------------------- /flow-typed/npm/jest_v24.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 27f8467378a99b6130bd20f54f31a644 2 | // flow-typed version: 6cb9e99836/jest_v24.x.x/flow_>=v0.104.x 3 | 4 | type JestMockFn, TReturn> = { 5 | (...args: TArguments): TReturn, 6 | /** 7 | * An object for introspecting mock calls 8 | */ 9 | mock: { 10 | /** 11 | * An array that represents all calls that have been made into this mock 12 | * function. Each call is represented by an array of arguments that were 13 | * passed during the call. 14 | */ 15 | calls: Array, 16 | /** 17 | * An array that contains all the object instances that have been 18 | * instantiated from this mock function. 19 | */ 20 | instances: Array, 21 | /** 22 | * An array that contains all the object results that have been 23 | * returned by this mock function call 24 | */ 25 | results: Array<{ 26 | isThrow: boolean, 27 | value: TReturn, 28 | ... 29 | }>, 30 | ... 31 | }, 32 | /** 33 | * Resets all information stored in the mockFn.mock.calls and 34 | * mockFn.mock.instances arrays. Often this is useful when you want to clean 35 | * up a mock's usage data between two assertions. 36 | */ 37 | mockClear(): void, 38 | /** 39 | * Resets all information stored in the mock. This is useful when you want to 40 | * completely restore a mock back to its initial state. 41 | */ 42 | mockReset(): void, 43 | /** 44 | * Removes the mock and restores the initial implementation. This is useful 45 | * when you want to mock functions in certain test cases and restore the 46 | * original implementation in others. Beware that mockFn.mockRestore only 47 | * works when mock was created with jest.spyOn. Thus you have to take care of 48 | * restoration yourself when manually assigning jest.fn(). 49 | */ 50 | mockRestore(): void, 51 | /** 52 | * Accepts a function that should be used as the implementation of the mock. 53 | * The mock itself will still record all calls that go into and instances 54 | * that come from itself -- the only difference is that the implementation 55 | * will also be executed when the mock is called. 56 | */ 57 | mockImplementation( 58 | fn: (...args: TArguments) => TReturn 59 | ): JestMockFn, 60 | /** 61 | * Accepts a function that will be used as an implementation of the mock for 62 | * one call to the mocked function. Can be chained so that multiple function 63 | * calls produce different results. 64 | */ 65 | mockImplementationOnce( 66 | fn: (...args: TArguments) => TReturn 67 | ): JestMockFn, 68 | /** 69 | * Accepts a string to use in test result output in place of "jest.fn()" to 70 | * indicate which mock function is being referenced. 71 | */ 72 | mockName(name: string): JestMockFn, 73 | /** 74 | * Just a simple sugar function for returning `this` 75 | */ 76 | mockReturnThis(): void, 77 | /** 78 | * Accepts a value that will be returned whenever the mock function is called. 79 | */ 80 | mockReturnValue(value: TReturn): JestMockFn, 81 | /** 82 | * Sugar for only returning a value once inside your mock 83 | */ 84 | mockReturnValueOnce(value: TReturn): JestMockFn, 85 | /** 86 | * Sugar for jest.fn().mockImplementation(() => Promise.resolve(value)) 87 | */ 88 | mockResolvedValue(value: TReturn): JestMockFn>, 89 | /** 90 | * Sugar for jest.fn().mockImplementationOnce(() => Promise.resolve(value)) 91 | */ 92 | mockResolvedValueOnce( 93 | value: TReturn 94 | ): JestMockFn>, 95 | /** 96 | * Sugar for jest.fn().mockImplementation(() => Promise.reject(value)) 97 | */ 98 | mockRejectedValue(value: TReturn): JestMockFn>, 99 | /** 100 | * Sugar for jest.fn().mockImplementationOnce(() => Promise.reject(value)) 101 | */ 102 | mockRejectedValueOnce(value: TReturn): JestMockFn>, 103 | ... 104 | }; 105 | 106 | type JestAsymmetricEqualityType = { /** 107 | * A custom Jasmine equality tester 108 | */ 109 | asymmetricMatch(value: mixed): boolean, ... }; 110 | 111 | type JestCallsType = { 112 | allArgs(): mixed, 113 | all(): mixed, 114 | any(): boolean, 115 | count(): number, 116 | first(): mixed, 117 | mostRecent(): mixed, 118 | reset(): void, 119 | ... 120 | }; 121 | 122 | type JestClockType = { 123 | install(): void, 124 | mockDate(date: Date): void, 125 | tick(milliseconds?: number): void, 126 | uninstall(): void, 127 | ... 128 | }; 129 | 130 | type JestMatcherResult = { 131 | message?: string | (() => string), 132 | pass: boolean, 133 | ... 134 | }; 135 | 136 | type JestMatcher = ( 137 | received: any, 138 | ...actual: Array 139 | ) => JestMatcherResult | Promise; 140 | 141 | type JestPromiseType = { 142 | /** 143 | * Use rejects to unwrap the reason of a rejected promise so any other 144 | * matcher can be chained. If the promise is fulfilled the assertion fails. 145 | */ 146 | rejects: JestExpectType, 147 | /** 148 | * Use resolves to unwrap the value of a fulfilled promise so any other 149 | * matcher can be chained. If the promise is rejected the assertion fails. 150 | */ 151 | resolves: JestExpectType, 152 | ... 153 | }; 154 | 155 | /** 156 | * Jest allows functions and classes to be used as test names in test() and 157 | * describe() 158 | */ 159 | type JestTestName = string | Function; 160 | 161 | /** 162 | * Plugin: jest-styled-components 163 | */ 164 | 165 | type JestStyledComponentsMatcherValue = 166 | | string 167 | | JestAsymmetricEqualityType 168 | | RegExp 169 | | typeof undefined; 170 | 171 | type JestStyledComponentsMatcherOptions = { 172 | media?: string, 173 | modifier?: string, 174 | supports?: string, 175 | ... 176 | }; 177 | 178 | type JestStyledComponentsMatchersType = { toHaveStyleRule( 179 | property: string, 180 | value: JestStyledComponentsMatcherValue, 181 | options?: JestStyledComponentsMatcherOptions 182 | ): void, ... }; 183 | 184 | /** 185 | * Plugin: jest-enzyme 186 | */ 187 | type EnzymeMatchersType = { 188 | // 5.x 189 | toBeEmpty(): void, 190 | toBePresent(): void, 191 | // 6.x 192 | toBeChecked(): void, 193 | toBeDisabled(): void, 194 | toBeEmptyRender(): void, 195 | toContainMatchingElement(selector: string): void, 196 | toContainMatchingElements(n: number, selector: string): void, 197 | toContainExactlyOneMatchingElement(selector: string): void, 198 | toContainReact(element: React$Element): void, 199 | toExist(): void, 200 | toHaveClassName(className: string): void, 201 | toHaveHTML(html: string): void, 202 | toHaveProp: ((propKey: string, propValue?: any) => void) & 203 | ((props: {...}) => void), 204 | toHaveRef(refName: string): void, 205 | toHaveState: ((stateKey: string, stateValue?: any) => void) & 206 | ((state: {...}) => void), 207 | toHaveStyle: ((styleKey: string, styleValue?: any) => void) & 208 | ((style: {...}) => void), 209 | toHaveTagName(tagName: string): void, 210 | toHaveText(text: string): void, 211 | toHaveValue(value: any): void, 212 | toIncludeText(text: string): void, 213 | toMatchElement( 214 | element: React$Element, 215 | options?: {| ignoreProps?: boolean, verbose?: boolean |} 216 | ): void, 217 | toMatchSelector(selector: string): void, 218 | // 7.x 219 | toHaveDisplayName(name: string): void, 220 | ... 221 | }; 222 | 223 | // DOM testing library extensions (jest-dom) 224 | // https://github.com/testing-library/jest-dom 225 | type DomTestingLibraryType = { 226 | /** 227 | * @deprecated 228 | */ 229 | toBeInTheDOM(container?: HTMLElement): void, 230 | toBeInTheDocument(): void, 231 | toBeVisible(): void, 232 | toBeEmpty(): void, 233 | toBeDisabled(): void, 234 | toBeEnabled(): void, 235 | toBeInvalid(): void, 236 | toBeRequired(): void, 237 | toBeValid(): void, 238 | toContainElement(element: HTMLElement | null): void, 239 | toContainHTML(htmlText: string): void, 240 | toHaveAttribute(attr: string, value?: any): void, 241 | toHaveClass(...classNames: string[]): void, 242 | toHaveFocus(): void, 243 | toHaveFormValues(expectedValues: { [name: string]: any, ... }): void, 244 | toHaveStyle(css: string): void, 245 | toHaveTextContent( 246 | text: string | RegExp, 247 | options?: { normalizeWhitespace: boolean, ... } 248 | ): void, 249 | toHaveValue(value?: string | string[] | number): void, 250 | ... 251 | }; 252 | 253 | // Jest JQuery Matchers: https://github.com/unindented/custom-jquery-matchers 254 | type JestJQueryMatchersType = { 255 | toExist(): void, 256 | toHaveLength(len: number): void, 257 | toHaveId(id: string): void, 258 | toHaveClass(className: string): void, 259 | toHaveTag(tag: string): void, 260 | toHaveAttr(key: string, val?: any): void, 261 | toHaveProp(key: string, val?: any): void, 262 | toHaveText(text: string | RegExp): void, 263 | toHaveData(key: string, val?: any): void, 264 | toHaveValue(val: any): void, 265 | toHaveCss(css: { [key: string]: any, ... }): void, 266 | toBeChecked(): void, 267 | toBeDisabled(): void, 268 | toBeEmpty(): void, 269 | toBeHidden(): void, 270 | toBeSelected(): void, 271 | toBeVisible(): void, 272 | toBeFocused(): void, 273 | toBeInDom(): void, 274 | toBeMatchedBy(sel: string): void, 275 | toHaveDescendant(sel: string): void, 276 | toHaveDescendantWithText(sel: string, text: string | RegExp): void, 277 | ... 278 | }; 279 | 280 | // Jest Extended Matchers: https://github.com/jest-community/jest-extended 281 | type JestExtendedMatchersType = { 282 | /** 283 | * Note: Currently unimplemented 284 | * Passing assertion 285 | * 286 | * @param {String} message 287 | */ 288 | // pass(message: string): void; 289 | 290 | /** 291 | * Note: Currently unimplemented 292 | * Failing assertion 293 | * 294 | * @param {String} message 295 | */ 296 | // fail(message: string): void; 297 | 298 | /** 299 | * Use .toBeEmpty when checking if a String '', Array [] or Object {} is empty. 300 | */ 301 | toBeEmpty(): void, 302 | /** 303 | * Use .toBeOneOf when checking if a value is a member of a given Array. 304 | * @param {Array.<*>} members 305 | */ 306 | toBeOneOf(members: any[]): void, 307 | /** 308 | * Use `.toBeNil` when checking a value is `null` or `undefined`. 309 | */ 310 | toBeNil(): void, 311 | /** 312 | * Use `.toSatisfy` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean`. 313 | * @param {Function} predicate 314 | */ 315 | toSatisfy(predicate: (n: any) => boolean): void, 316 | /** 317 | * Use `.toBeArray` when checking if a value is an `Array`. 318 | */ 319 | toBeArray(): void, 320 | /** 321 | * Use `.toBeArrayOfSize` when checking if a value is an `Array` of size x. 322 | * @param {Number} x 323 | */ 324 | toBeArrayOfSize(x: number): void, 325 | /** 326 | * Use `.toIncludeAllMembers` when checking if an `Array` contains all of the same members of a given set. 327 | * @param {Array.<*>} members 328 | */ 329 | toIncludeAllMembers(members: any[]): void, 330 | /** 331 | * Use `.toIncludeAnyMembers` when checking if an `Array` contains any of the members of a given set. 332 | * @param {Array.<*>} members 333 | */ 334 | toIncludeAnyMembers(members: any[]): void, 335 | /** 336 | * Use `.toSatisfyAll` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean` for all values in an array. 337 | * @param {Function} predicate 338 | */ 339 | toSatisfyAll(predicate: (n: any) => boolean): void, 340 | /** 341 | * Use `.toBeBoolean` when checking if a value is a `Boolean`. 342 | */ 343 | toBeBoolean(): void, 344 | /** 345 | * Use `.toBeTrue` when checking a value is equal (===) to `true`. 346 | */ 347 | toBeTrue(): void, 348 | /** 349 | * Use `.toBeFalse` when checking a value is equal (===) to `false`. 350 | */ 351 | toBeFalse(): void, 352 | /** 353 | * Use .toBeDate when checking if a value is a Date. 354 | */ 355 | toBeDate(): void, 356 | /** 357 | * Use `.toBeFunction` when checking if a value is a `Function`. 358 | */ 359 | toBeFunction(): void, 360 | /** 361 | * Use `.toHaveBeenCalledBefore` when checking if a `Mock` was called before another `Mock`. 362 | * 363 | * Note: Required Jest version >22 364 | * Note: Your mock functions will have to be asynchronous to cause the timestamps inside of Jest to occur in a differentJS event loop, otherwise the mock timestamps will all be the same 365 | * 366 | * @param {Mock} mock 367 | */ 368 | toHaveBeenCalledBefore(mock: JestMockFn): void, 369 | /** 370 | * Use `.toBeNumber` when checking if a value is a `Number`. 371 | */ 372 | toBeNumber(): void, 373 | /** 374 | * Use `.toBeNaN` when checking a value is `NaN`. 375 | */ 376 | toBeNaN(): void, 377 | /** 378 | * Use `.toBeFinite` when checking if a value is a `Number`, not `NaN` or `Infinity`. 379 | */ 380 | toBeFinite(): void, 381 | /** 382 | * Use `.toBePositive` when checking if a value is a positive `Number`. 383 | */ 384 | toBePositive(): void, 385 | /** 386 | * Use `.toBeNegative` when checking if a value is a negative `Number`. 387 | */ 388 | toBeNegative(): void, 389 | /** 390 | * Use `.toBeEven` when checking if a value is an even `Number`. 391 | */ 392 | toBeEven(): void, 393 | /** 394 | * Use `.toBeOdd` when checking if a value is an odd `Number`. 395 | */ 396 | toBeOdd(): void, 397 | /** 398 | * Use `.toBeWithin` when checking if a number is in between the given bounds of: start (inclusive) and end (exclusive). 399 | * 400 | * @param {Number} start 401 | * @param {Number} end 402 | */ 403 | toBeWithin(start: number, end: number): void, 404 | /** 405 | * Use `.toBeObject` when checking if a value is an `Object`. 406 | */ 407 | toBeObject(): void, 408 | /** 409 | * Use `.toContainKey` when checking if an object contains the provided key. 410 | * 411 | * @param {String} key 412 | */ 413 | toContainKey(key: string): void, 414 | /** 415 | * Use `.toContainKeys` when checking if an object has all of the provided keys. 416 | * 417 | * @param {Array.} keys 418 | */ 419 | toContainKeys(keys: string[]): void, 420 | /** 421 | * Use `.toContainAllKeys` when checking if an object only contains all of the provided keys. 422 | * 423 | * @param {Array.} keys 424 | */ 425 | toContainAllKeys(keys: string[]): void, 426 | /** 427 | * Use `.toContainAnyKeys` when checking if an object contains at least one of the provided keys. 428 | * 429 | * @param {Array.} keys 430 | */ 431 | toContainAnyKeys(keys: string[]): void, 432 | /** 433 | * Use `.toContainValue` when checking if an object contains the provided value. 434 | * 435 | * @param {*} value 436 | */ 437 | toContainValue(value: any): void, 438 | /** 439 | * Use `.toContainValues` when checking if an object contains all of the provided values. 440 | * 441 | * @param {Array.<*>} values 442 | */ 443 | toContainValues(values: any[]): void, 444 | /** 445 | * Use `.toContainAllValues` when checking if an object only contains all of the provided values. 446 | * 447 | * @param {Array.<*>} values 448 | */ 449 | toContainAllValues(values: any[]): void, 450 | /** 451 | * Use `.toContainAnyValues` when checking if an object contains at least one of the provided values. 452 | * 453 | * @param {Array.<*>} values 454 | */ 455 | toContainAnyValues(values: any[]): void, 456 | /** 457 | * Use `.toContainEntry` when checking if an object contains the provided entry. 458 | * 459 | * @param {Array.} entry 460 | */ 461 | toContainEntry(entry: [string, string]): void, 462 | /** 463 | * Use `.toContainEntries` when checking if an object contains all of the provided entries. 464 | * 465 | * @param {Array.>} entries 466 | */ 467 | toContainEntries(entries: [string, string][]): void, 468 | /** 469 | * Use `.toContainAllEntries` when checking if an object only contains all of the provided entries. 470 | * 471 | * @param {Array.>} entries 472 | */ 473 | toContainAllEntries(entries: [string, string][]): void, 474 | /** 475 | * Use `.toContainAnyEntries` when checking if an object contains at least one of the provided entries. 476 | * 477 | * @param {Array.>} entries 478 | */ 479 | toContainAnyEntries(entries: [string, string][]): void, 480 | /** 481 | * Use `.toBeExtensible` when checking if an object is extensible. 482 | */ 483 | toBeExtensible(): void, 484 | /** 485 | * Use `.toBeFrozen` when checking if an object is frozen. 486 | */ 487 | toBeFrozen(): void, 488 | /** 489 | * Use `.toBeSealed` when checking if an object is sealed. 490 | */ 491 | toBeSealed(): void, 492 | /** 493 | * Use `.toBeString` when checking if a value is a `String`. 494 | */ 495 | toBeString(): void, 496 | /** 497 | * Use `.toEqualCaseInsensitive` when checking if a string is equal (===) to another ignoring the casing of both strings. 498 | * 499 | * @param {String} string 500 | */ 501 | toEqualCaseInsensitive(string: string): void, 502 | /** 503 | * Use `.toStartWith` when checking if a `String` starts with a given `String` prefix. 504 | * 505 | * @param {String} prefix 506 | */ 507 | toStartWith(prefix: string): void, 508 | /** 509 | * Use `.toEndWith` when checking if a `String` ends with a given `String` suffix. 510 | * 511 | * @param {String} suffix 512 | */ 513 | toEndWith(suffix: string): void, 514 | /** 515 | * Use `.toInclude` when checking if a `String` includes the given `String` substring. 516 | * 517 | * @param {String} substring 518 | */ 519 | toInclude(substring: string): void, 520 | /** 521 | * Use `.toIncludeRepeated` when checking if a `String` includes the given `String` substring the correct number of times. 522 | * 523 | * @param {String} substring 524 | * @param {Number} times 525 | */ 526 | toIncludeRepeated(substring: string, times: number): void, 527 | /** 528 | * Use `.toIncludeMultiple` when checking if a `String` includes all of the given substrings. 529 | * 530 | * @param {Array.} substring 531 | */ 532 | toIncludeMultiple(substring: string[]): void, 533 | ... 534 | }; 535 | 536 | interface JestExpectType { 537 | not: JestExpectType & 538 | EnzymeMatchersType & 539 | DomTestingLibraryType & 540 | JestJQueryMatchersType & 541 | JestStyledComponentsMatchersType & 542 | JestExtendedMatchersType; 543 | /** 544 | * If you have a mock function, you can use .lastCalledWith to test what 545 | * arguments it was last called with. 546 | */ 547 | lastCalledWith(...args: Array): void; 548 | /** 549 | * toBe just checks that a value is what you expect. It uses === to check 550 | * strict equality. 551 | */ 552 | toBe(value: any): void; 553 | /** 554 | * Use .toBeCalledWith to ensure that a mock function was called with 555 | * specific arguments. 556 | */ 557 | toBeCalledWith(...args: Array): void; 558 | /** 559 | * Using exact equality with floating point numbers is a bad idea. Rounding 560 | * means that intuitive things fail. 561 | */ 562 | toBeCloseTo(num: number, delta: any): void; 563 | /** 564 | * Use .toBeDefined to check that a variable is not undefined. 565 | */ 566 | toBeDefined(): void; 567 | /** 568 | * Use .toBeFalsy when you don't care what a value is, you just want to 569 | * ensure a value is false in a boolean context. 570 | */ 571 | toBeFalsy(): void; 572 | /** 573 | * To compare floating point numbers, you can use toBeGreaterThan. 574 | */ 575 | toBeGreaterThan(number: number): void; 576 | /** 577 | * To compare floating point numbers, you can use toBeGreaterThanOrEqual. 578 | */ 579 | toBeGreaterThanOrEqual(number: number): void; 580 | /** 581 | * To compare floating point numbers, you can use toBeLessThan. 582 | */ 583 | toBeLessThan(number: number): void; 584 | /** 585 | * To compare floating point numbers, you can use toBeLessThanOrEqual. 586 | */ 587 | toBeLessThanOrEqual(number: number): void; 588 | /** 589 | * Use .toBeInstanceOf(Class) to check that an object is an instance of a 590 | * class. 591 | */ 592 | toBeInstanceOf(cls: Class<*>): void; 593 | /** 594 | * .toBeNull() is the same as .toBe(null) but the error messages are a bit 595 | * nicer. 596 | */ 597 | toBeNull(): void; 598 | /** 599 | * Use .toBeTruthy when you don't care what a value is, you just want to 600 | * ensure a value is true in a boolean context. 601 | */ 602 | toBeTruthy(): void; 603 | /** 604 | * Use .toBeUndefined to check that a variable is undefined. 605 | */ 606 | toBeUndefined(): void; 607 | /** 608 | * Use .toContain when you want to check that an item is in a list. For 609 | * testing the items in the list, this uses ===, a strict equality check. 610 | */ 611 | toContain(item: any): void; 612 | /** 613 | * Use .toContainEqual when you want to check that an item is in a list. For 614 | * testing the items in the list, this matcher recursively checks the 615 | * equality of all fields, rather than checking for object identity. 616 | */ 617 | toContainEqual(item: any): void; 618 | /** 619 | * Use .toEqual when you want to check that two objects have the same value. 620 | * This matcher recursively checks the equality of all fields, rather than 621 | * checking for object identity. 622 | */ 623 | toEqual(value: any): void; 624 | /** 625 | * Use .toHaveBeenCalled to ensure that a mock function got called. 626 | */ 627 | toHaveBeenCalled(): void; 628 | toBeCalled(): void; 629 | /** 630 | * Use .toHaveBeenCalledTimes to ensure that a mock function got called exact 631 | * number of times. 632 | */ 633 | toHaveBeenCalledTimes(number: number): void; 634 | toBeCalledTimes(number: number): void; 635 | /** 636 | * 637 | */ 638 | toHaveBeenNthCalledWith(nthCall: number, ...args: Array): void; 639 | nthCalledWith(nthCall: number, ...args: Array): void; 640 | /** 641 | * 642 | */ 643 | toHaveReturned(): void; 644 | toReturn(): void; 645 | /** 646 | * 647 | */ 648 | toHaveReturnedTimes(number: number): void; 649 | toReturnTimes(number: number): void; 650 | /** 651 | * 652 | */ 653 | toHaveReturnedWith(value: any): void; 654 | toReturnWith(value: any): void; 655 | /** 656 | * 657 | */ 658 | toHaveLastReturnedWith(value: any): void; 659 | lastReturnedWith(value: any): void; 660 | /** 661 | * 662 | */ 663 | toHaveNthReturnedWith(nthCall: number, value: any): void; 664 | nthReturnedWith(nthCall: number, value: any): void; 665 | /** 666 | * Use .toHaveBeenCalledWith to ensure that a mock function was called with 667 | * specific arguments. 668 | */ 669 | toHaveBeenCalledWith(...args: Array): void; 670 | toBeCalledWith(...args: Array): void; 671 | /** 672 | * Use .toHaveBeenLastCalledWith to ensure that a mock function was last called 673 | * with specific arguments. 674 | */ 675 | toHaveBeenLastCalledWith(...args: Array): void; 676 | lastCalledWith(...args: Array): void; 677 | /** 678 | * Check that an object has a .length property and it is set to a certain 679 | * numeric value. 680 | */ 681 | toHaveLength(number: number): void; 682 | /** 683 | * 684 | */ 685 | toHaveProperty(propPath: string | $ReadOnlyArray, value?: any): void; 686 | /** 687 | * Use .toMatch to check that a string matches a regular expression or string. 688 | */ 689 | toMatch(regexpOrString: RegExp | string): void; 690 | /** 691 | * Use .toMatchObject to check that a javascript object matches a subset of the properties of an object. 692 | */ 693 | toMatchObject(object: Object | Array): void; 694 | /** 695 | * Use .toStrictEqual to check that a javascript object matches a subset of the properties of an object. 696 | */ 697 | toStrictEqual(value: any): void; 698 | /** 699 | * This ensures that an Object matches the most recent snapshot. 700 | */ 701 | toMatchSnapshot(propertyMatchers?: any, name?: string): void; 702 | /** 703 | * This ensures that an Object matches the most recent snapshot. 704 | */ 705 | toMatchSnapshot(name: string): void; 706 | 707 | toMatchInlineSnapshot(snapshot?: string): void; 708 | toMatchInlineSnapshot(propertyMatchers?: any, snapshot?: string): void; 709 | /** 710 | * Use .toThrow to test that a function throws when it is called. 711 | * If you want to test that a specific error gets thrown, you can provide an 712 | * argument to toThrow. The argument can be a string for the error message, 713 | * a class for the error, or a regex that should match the error. 714 | * 715 | * Alias: .toThrowError 716 | */ 717 | toThrow(message?: string | Error | Class | RegExp): void; 718 | toThrowError(message?: string | Error | Class | RegExp): void; 719 | /** 720 | * Use .toThrowErrorMatchingSnapshot to test that a function throws a error 721 | * matching the most recent snapshot when it is called. 722 | */ 723 | toThrowErrorMatchingSnapshot(): void; 724 | toThrowErrorMatchingInlineSnapshot(snapshot?: string): void; 725 | } 726 | 727 | type JestObjectType = { 728 | /** 729 | * Disables automatic mocking in the module loader. 730 | * 731 | * After this method is called, all `require()`s will return the real 732 | * versions of each module (rather than a mocked version). 733 | */ 734 | disableAutomock(): JestObjectType, 735 | /** 736 | * An un-hoisted version of disableAutomock 737 | */ 738 | autoMockOff(): JestObjectType, 739 | /** 740 | * Enables automatic mocking in the module loader. 741 | */ 742 | enableAutomock(): JestObjectType, 743 | /** 744 | * An un-hoisted version of enableAutomock 745 | */ 746 | autoMockOn(): JestObjectType, 747 | /** 748 | * Clears the mock.calls and mock.instances properties of all mocks. 749 | * Equivalent to calling .mockClear() on every mocked function. 750 | */ 751 | clearAllMocks(): JestObjectType, 752 | /** 753 | * Resets the state of all mocks. Equivalent to calling .mockReset() on every 754 | * mocked function. 755 | */ 756 | resetAllMocks(): JestObjectType, 757 | /** 758 | * Restores all mocks back to their original value. 759 | */ 760 | restoreAllMocks(): JestObjectType, 761 | /** 762 | * Removes any pending timers from the timer system. 763 | */ 764 | clearAllTimers(): void, 765 | /** 766 | * Returns the number of fake timers still left to run. 767 | */ 768 | getTimerCount(): number, 769 | /** 770 | * The same as `mock` but not moved to the top of the expectation by 771 | * babel-jest. 772 | */ 773 | doMock(moduleName: string, moduleFactory?: any): JestObjectType, 774 | /** 775 | * The same as `unmock` but not moved to the top of the expectation by 776 | * babel-jest. 777 | */ 778 | dontMock(moduleName: string): JestObjectType, 779 | /** 780 | * Returns a new, unused mock function. Optionally takes a mock 781 | * implementation. 782 | */ 783 | fn, TReturn>( 784 | implementation?: (...args: TArguments) => TReturn 785 | ): JestMockFn, 786 | /** 787 | * Determines if the given function is a mocked function. 788 | */ 789 | isMockFunction(fn: Function): boolean, 790 | /** 791 | * Given the name of a module, use the automatic mocking system to generate a 792 | * mocked version of the module for you. 793 | */ 794 | genMockFromModule(moduleName: string): any, 795 | /** 796 | * Mocks a module with an auto-mocked version when it is being required. 797 | * 798 | * The second argument can be used to specify an explicit module factory that 799 | * is being run instead of using Jest's automocking feature. 800 | * 801 | * The third argument can be used to create virtual mocks -- mocks of modules 802 | * that don't exist anywhere in the system. 803 | */ 804 | mock( 805 | moduleName: string, 806 | moduleFactory?: any, 807 | options?: Object 808 | ): JestObjectType, 809 | /** 810 | * Returns the actual module instead of a mock, bypassing all checks on 811 | * whether the module should receive a mock implementation or not. 812 | */ 813 | requireActual(moduleName: string): any, 814 | /** 815 | * Returns a mock module instead of the actual module, bypassing all checks 816 | * on whether the module should be required normally or not. 817 | */ 818 | requireMock(moduleName: string): any, 819 | /** 820 | * Resets the module registry - the cache of all required modules. This is 821 | * useful to isolate modules where local state might conflict between tests. 822 | */ 823 | resetModules(): JestObjectType, 824 | /** 825 | * Creates a sandbox registry for the modules that are loaded inside the 826 | * callback function. This is useful to isolate specific modules for every 827 | * test so that local module state doesn't conflict between tests. 828 | */ 829 | isolateModules(fn: () => void): JestObjectType, 830 | /** 831 | * Exhausts the micro-task queue (usually interfaced in node via 832 | * process.nextTick). 833 | */ 834 | runAllTicks(): void, 835 | /** 836 | * Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(), 837 | * setInterval(), and setImmediate()). 838 | */ 839 | runAllTimers(): void, 840 | /** 841 | * Exhausts all tasks queued by setImmediate(). 842 | */ 843 | runAllImmediates(): void, 844 | /** 845 | * Executes only the macro task queue (i.e. all tasks queued by setTimeout() 846 | * or setInterval() and setImmediate()). 847 | */ 848 | advanceTimersByTime(msToRun: number): void, 849 | /** 850 | * Executes only the macro task queue (i.e. all tasks queued by setTimeout() 851 | * or setInterval() and setImmediate()). 852 | * 853 | * Renamed to `advanceTimersByTime`. 854 | */ 855 | runTimersToTime(msToRun: number): void, 856 | /** 857 | * Executes only the macro-tasks that are currently pending (i.e., only the 858 | * tasks that have been queued by setTimeout() or setInterval() up to this 859 | * point) 860 | */ 861 | runOnlyPendingTimers(): void, 862 | /** 863 | * Explicitly supplies the mock object that the module system should return 864 | * for the specified module. Note: It is recommended to use jest.mock() 865 | * instead. 866 | */ 867 | setMock(moduleName: string, moduleExports: any): JestObjectType, 868 | /** 869 | * Indicates that the module system should never return a mocked version of 870 | * the specified module from require() (e.g. that it should always return the 871 | * real module). 872 | */ 873 | unmock(moduleName: string): JestObjectType, 874 | /** 875 | * Instructs Jest to use fake versions of the standard timer functions 876 | * (setTimeout, setInterval, clearTimeout, clearInterval, nextTick, 877 | * setImmediate and clearImmediate). 878 | */ 879 | useFakeTimers(): JestObjectType, 880 | /** 881 | * Instructs Jest to use the real versions of the standard timer functions. 882 | */ 883 | useRealTimers(): JestObjectType, 884 | /** 885 | * Creates a mock function similar to jest.fn but also tracks calls to 886 | * object[methodName]. 887 | */ 888 | spyOn( 889 | object: Object, 890 | methodName: string, 891 | accessType?: 'get' | 'set' 892 | ): JestMockFn, 893 | /** 894 | * Set the default timeout interval for tests and before/after hooks in milliseconds. 895 | * Note: The default timeout interval is 5 seconds if this method is not called. 896 | */ 897 | setTimeout(timeout: number): JestObjectType, 898 | ... 899 | }; 900 | 901 | type JestSpyType = { calls: JestCallsType, ... }; 902 | 903 | type JestDoneFn = {| 904 | (): void, 905 | fail: (error: Error) => void, 906 | |}; 907 | 908 | /** Runs this function after every test inside this context */ 909 | declare function afterEach( 910 | fn: (done: JestDoneFn) => ?Promise, 911 | timeout?: number 912 | ): void; 913 | /** Runs this function before every test inside this context */ 914 | declare function beforeEach( 915 | fn: (done: JestDoneFn) => ?Promise, 916 | timeout?: number 917 | ): void; 918 | /** Runs this function after all tests have finished inside this context */ 919 | declare function afterAll( 920 | fn: (done: JestDoneFn) => ?Promise, 921 | timeout?: number 922 | ): void; 923 | /** Runs this function before any tests have started inside this context */ 924 | declare function beforeAll( 925 | fn: (done: JestDoneFn) => ?Promise, 926 | timeout?: number 927 | ): void; 928 | 929 | /** A context for grouping tests together */ 930 | declare var describe: { 931 | /** 932 | * Creates a block that groups together several related tests in one "test suite" 933 | */ 934 | (name: JestTestName, fn: () => void): void, 935 | /** 936 | * Only run this describe block 937 | */ 938 | only(name: JestTestName, fn: () => void): void, 939 | /** 940 | * Skip running this describe block 941 | */ 942 | skip(name: JestTestName, fn: () => void): void, 943 | /** 944 | * each runs this test against array of argument arrays per each run 945 | * 946 | * @param {table} table of Test 947 | */ 948 | each( 949 | ...table: Array | mixed> | [Array, string] 950 | ): ( 951 | name: JestTestName, 952 | fn?: (...args: Array) => ?Promise, 953 | timeout?: number 954 | ) => void, 955 | ... 956 | }; 957 | 958 | /** An individual test unit */ 959 | declare var it: { 960 | /** 961 | * An individual test unit 962 | * 963 | * @param {JestTestName} Name of Test 964 | * @param {Function} Test 965 | * @param {number} Timeout for the test, in milliseconds. 966 | */ 967 | ( 968 | name: JestTestName, 969 | fn?: (done: JestDoneFn) => ?Promise, 970 | timeout?: number 971 | ): void, 972 | /** 973 | * Only run this test 974 | * 975 | * @param {JestTestName} Name of Test 976 | * @param {Function} Test 977 | * @param {number} Timeout for the test, in milliseconds. 978 | */ 979 | only: {| 980 | ( 981 | name: JestTestName, 982 | fn?: (done: JestDoneFn) => ?Promise, 983 | timeout?: number 984 | ): void, 985 | each( 986 | ...table: Array | mixed> | [Array, string] 987 | ): ( 988 | name: JestTestName, 989 | fn?: (...args: Array) => ?Promise, 990 | timeout?: number 991 | ) => void 992 | |}, 993 | /** 994 | * Skip running this test 995 | * 996 | * @param {JestTestName} Name of Test 997 | * @param {Function} Test 998 | * @param {number} Timeout for the test, in milliseconds. 999 | */ 1000 | skip( 1001 | name: JestTestName, 1002 | fn?: (done: JestDoneFn) => ?Promise, 1003 | timeout?: number 1004 | ): void, 1005 | /** 1006 | * Highlight planned tests in the summary output 1007 | * 1008 | * @param {String} Name of Test to do 1009 | */ 1010 | todo(name: string): void, 1011 | /** 1012 | * Run the test concurrently 1013 | * 1014 | * @param {JestTestName} Name of Test 1015 | * @param {Function} Test 1016 | * @param {number} Timeout for the test, in milliseconds. 1017 | */ 1018 | concurrent( 1019 | name: JestTestName, 1020 | fn?: (done: JestDoneFn) => ?Promise, 1021 | timeout?: number 1022 | ): void, 1023 | /** 1024 | * each runs this test against array of argument arrays per each run 1025 | * 1026 | * @param {table} table of Test 1027 | */ 1028 | each( 1029 | ...table: Array | mixed> | [Array, string] 1030 | ): ( 1031 | name: JestTestName, 1032 | fn?: (...args: Array) => ?Promise, 1033 | timeout?: number 1034 | ) => void, 1035 | ... 1036 | }; 1037 | 1038 | declare function fit( 1039 | name: JestTestName, 1040 | fn: (done: JestDoneFn) => ?Promise, 1041 | timeout?: number 1042 | ): void; 1043 | /** An individual test unit */ 1044 | declare var test: typeof it; 1045 | /** A disabled group of tests */ 1046 | declare var xdescribe: typeof describe; 1047 | /** A focused group of tests */ 1048 | declare var fdescribe: typeof describe; 1049 | /** A disabled individual test */ 1050 | declare var xit: typeof it; 1051 | /** A disabled individual test */ 1052 | declare var xtest: typeof it; 1053 | 1054 | type JestPrettyFormatColors = { 1055 | comment: { 1056 | close: string, 1057 | open: string, 1058 | ... 1059 | }, 1060 | content: { 1061 | close: string, 1062 | open: string, 1063 | ... 1064 | }, 1065 | prop: { 1066 | close: string, 1067 | open: string, 1068 | ... 1069 | }, 1070 | tag: { 1071 | close: string, 1072 | open: string, 1073 | ... 1074 | }, 1075 | value: { 1076 | close: string, 1077 | open: string, 1078 | ... 1079 | }, 1080 | ... 1081 | }; 1082 | 1083 | type JestPrettyFormatIndent = string => string; 1084 | type JestPrettyFormatRefs = Array; 1085 | type JestPrettyFormatPrint = any => string; 1086 | type JestPrettyFormatStringOrNull = string | null; 1087 | 1088 | type JestPrettyFormatOptions = {| 1089 | callToJSON: boolean, 1090 | edgeSpacing: string, 1091 | escapeRegex: boolean, 1092 | highlight: boolean, 1093 | indent: number, 1094 | maxDepth: number, 1095 | min: boolean, 1096 | plugins: JestPrettyFormatPlugins, 1097 | printFunctionName: boolean, 1098 | spacing: string, 1099 | theme: {| 1100 | comment: string, 1101 | content: string, 1102 | prop: string, 1103 | tag: string, 1104 | value: string, 1105 | |}, 1106 | |}; 1107 | 1108 | type JestPrettyFormatPlugin = { 1109 | print: ( 1110 | val: any, 1111 | serialize: JestPrettyFormatPrint, 1112 | indent: JestPrettyFormatIndent, 1113 | opts: JestPrettyFormatOptions, 1114 | colors: JestPrettyFormatColors 1115 | ) => string, 1116 | test: any => boolean, 1117 | ... 1118 | }; 1119 | 1120 | type JestPrettyFormatPlugins = Array; 1121 | 1122 | /** The expect function is used every time you want to test a value */ 1123 | declare var expect: { 1124 | /** The object that you want to make assertions against */ 1125 | ( 1126 | value: any 1127 | ): JestExpectType & 1128 | JestPromiseType & 1129 | EnzymeMatchersType & 1130 | DomTestingLibraryType & 1131 | JestJQueryMatchersType & 1132 | JestStyledComponentsMatchersType & 1133 | JestExtendedMatchersType, 1134 | /** Add additional Jasmine matchers to Jest's roster */ 1135 | extend(matchers: { [name: string]: JestMatcher, ... }): void, 1136 | /** Add a module that formats application-specific data structures. */ 1137 | addSnapshotSerializer(pluginModule: JestPrettyFormatPlugin): void, 1138 | assertions(expectedAssertions: number): void, 1139 | hasAssertions(): void, 1140 | any(value: mixed): JestAsymmetricEqualityType, 1141 | anything(): any, 1142 | arrayContaining(value: Array): Array, 1143 | objectContaining(value: Object): Object, 1144 | /** Matches any received string that contains the exact expected string. */ 1145 | stringContaining(value: string): string, 1146 | stringMatching(value: string | RegExp): string, 1147 | not: { 1148 | arrayContaining: (value: $ReadOnlyArray) => Array, 1149 | objectContaining: (value: {...}) => Object, 1150 | stringContaining: (value: string) => string, 1151 | stringMatching: (value: string | RegExp) => string, 1152 | ... 1153 | }, 1154 | ... 1155 | }; 1156 | 1157 | // TODO handle return type 1158 | // http://jasmine.github.io/2.4/introduction.html#section-Spies 1159 | declare function spyOn(value: mixed, method: string): Object; 1160 | 1161 | /** Holds all functions related to manipulating test runner */ 1162 | declare var jest: JestObjectType; 1163 | 1164 | /** 1165 | * The global Jasmine object, this is generally not exposed as the public API, 1166 | * using features inside here could break in later versions of Jest. 1167 | */ 1168 | declare var jasmine: { 1169 | DEFAULT_TIMEOUT_INTERVAL: number, 1170 | any(value: mixed): JestAsymmetricEqualityType, 1171 | anything(): any, 1172 | arrayContaining(value: Array): Array, 1173 | clock(): JestClockType, 1174 | createSpy(name: string): JestSpyType, 1175 | createSpyObj( 1176 | baseName: string, 1177 | methodNames: Array 1178 | ): { [methodName: string]: JestSpyType, ... }, 1179 | objectContaining(value: Object): Object, 1180 | stringMatching(value: string): string, 1181 | ... 1182 | }; 1183 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rsrc", 3 | "author": "Signal Sciences", 4 | "description": "React components for managing async fetch state", 5 | "version": "1.3.0", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "browserslist": [ 10 | "extends browserslist-config-signalsciences" 11 | ], 12 | "keywords": [ 13 | "react", 14 | "resource", 15 | "fetch", 16 | "cache" 17 | ], 18 | "homepage": "https://github.com/signalsciences/rsrc", 19 | "bugs": { 20 | "url": "https://github.com/signalsciences/rsrc/issues" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/signalsciences/rsrc.git" 25 | }, 26 | "license": "MIT", 27 | "files": [ 28 | "dist", 29 | "LICENSE", 30 | "README.md" 31 | ], 32 | "main": "dist/index.js", 33 | "module": "dist/rsrc.esm.js", 34 | "umd:main": "dist/rsrc.umd.production.js", 35 | "scripts": { 36 | "dev": "yarn start", 37 | "test": "jest src", 38 | "test:watch": "jest src --watch", 39 | "start": "cross-env NODE_ENV=development rollup -c --watch", 40 | "build": "cross-env NODE_ENV=production rollup -c", 41 | "prebuild": "rimraf dist", 42 | "prepublish": "yarn run build", 43 | "format": "prettier --write '{docs,examples,src,test,website}/**/*.{js,json,md}' './*.{js,json,md}'", 44 | "lint-staged": "lint-staged", 45 | "lint": "flow && eslint ." 46 | }, 47 | "husky": { 48 | "hooks": { 49 | "pre-commit": "lint-staged" 50 | } 51 | }, 52 | "lint-staged": { 53 | "*.js": [ 54 | "prettier --write", 55 | "eslint" 56 | ], 57 | "*.md": [ 58 | "prettier --write" 59 | ] 60 | }, 61 | "babel": { 62 | "presets": [ 63 | "babel-preset-signalsciences" 64 | ] 65 | }, 66 | "eslintConfig": { 67 | "extends": [ 68 | "eslint-config-signalsciences" 69 | ], 70 | "rules": { 71 | "react/static-property-placement": "off" 72 | }, 73 | "settings": { 74 | "polyfills": [ 75 | "fetch", 76 | "Map", 77 | "Promise" 78 | ] 79 | } 80 | }, 81 | "jest": { 82 | "collectCoverage": true, 83 | "collectCoverageFrom": [ 84 | "src/**/*.js" 85 | ], 86 | "preset": "jest-preset-signalsciences", 87 | "setupFiles": [ 88 | "./test/setupJest.js" 89 | ], 90 | "testEnvironment": "jest-environment-jsdom-sixteen" 91 | }, 92 | "peerDependencies": { 93 | "react": "^16.8 || ^17.0.0", 94 | "react-dom": "^16.8 || ^17.0.0" 95 | }, 96 | "devDependencies": { 97 | "@babel/core": "7.12.9", 98 | "@testing-library/react": "11.2.2", 99 | "babel-preset-signalsciences": "4.0.5", 100 | "browserslist-config-signalsciences": "1.0.4", 101 | "core-js": "^3.8.0", 102 | "cross-env": "7.0.2", 103 | "eslint": "7.14.0", 104 | "eslint-config-signalsciences": "4.0.5", 105 | "flow-bin": "0.133.0", 106 | "hashlru": "^2.3.0", 107 | "husky": "4.3.0", 108 | "isomorphic-unfetch": "3.1.0", 109 | "jest": "26.6.3", 110 | "jest-environment-jsdom-sixteen": "^1.0.3", 111 | "jest-fetch-mock": "3.0.3", 112 | "jest-preset-signalsciences": "4.0.5", 113 | "lint-staged": "10.5.2", 114 | "pre-commit": "1.2.2", 115 | "prettier": "2.2.0", 116 | "react": "^16.13.1", 117 | "react-dom": "^16.13.1", 118 | "regenerator-runtime": "^0.13.7", 119 | "rimraf": "^3.0.2", 120 | "rollup": "^2.33.3", 121 | "rollup-config-signalsciences": "4.0.6" 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("rollup-config-signalsciences"); 2 | -------------------------------------------------------------------------------- /src/Cache.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from "react"; 4 | 5 | import type { CacheProps, CacheState } from "./types"; 6 | 7 | const CacheContext = React.createContext<*>(null); 8 | 9 | class Cache extends React.Component { 10 | static displayName = "Cache"; 11 | 12 | static defaultProps = { 13 | map: new Map<*, *>(), 14 | }; 15 | 16 | static Consumer = CacheContext.Consumer; 17 | 18 | mounted: boolean; 19 | 20 | constructor(props: CacheProps) { 21 | super(props); 22 | /* eslint-disable react/no-unused-state */ 23 | this.mounted = false; 24 | this.state = { 25 | has: this.has.bind(this), 26 | entries: this.entries.bind(this), 27 | values: this.values.bind(this), 28 | keys: this.keys.bind(this), 29 | get: this.get.bind(this), 30 | 31 | // NOTE: these methods trigger setState 32 | set: this.set.bind(this), 33 | delete: this.delete.bind(this), 34 | clear: this.clear.bind(this), 35 | }; 36 | } 37 | 38 | componentDidMount() { 39 | this.mounted = true; 40 | } 41 | 42 | componentWillUnmount() { 43 | this.mounted = false; 44 | } 45 | 46 | get(key: *) { 47 | const { map } = this.props; 48 | return map.get(key); 49 | } 50 | 51 | has(key: *) { 52 | const { map } = this.props; 53 | return map.has(key); 54 | } 55 | 56 | entries(): Array<*> { 57 | const { map } = this.props; 58 | return [...map.entries()]; 59 | } 60 | 61 | values(): Array<*> { 62 | const { map } = this.props; 63 | return [...map.values()]; 64 | } 65 | 66 | keys(): Array<*> { 67 | const { map } = this.props; 68 | return [...map.keys()]; 69 | } 70 | 71 | // --------- 72 | // Actions that modify entries also trigger setState 73 | // in order to communicate context updates to consumers. 74 | 75 | set(key: *, value: *): Map<*, *> { 76 | const { map } = this.props; 77 | const result = map.set(key, value); 78 | this.touchState(); 79 | return result; 80 | } 81 | 82 | delete(key: *): boolean { 83 | const { map } = this.props; 84 | // Accomodate map-like interfaces that do not return boolean from delete. 85 | const hasKey = map.has(key); 86 | // Only update state if the key being removed exists 87 | if (hasKey) { 88 | map.delete(key); 89 | this.touchState(); 90 | } 91 | return hasKey; 92 | } 93 | 94 | clear() { 95 | const { map } = this.props; 96 | map.clear(); 97 | this.touchState(); 98 | } 99 | 100 | touchState() { 101 | if (this.mounted) { 102 | this.setState((prevState) => prevState); 103 | } 104 | } 105 | 106 | render() { 107 | const { children } = this.props; 108 | 109 | return ( 110 | 111 | {children} 112 | 113 | ); 114 | } 115 | } 116 | 117 | export default Cache; 118 | -------------------------------------------------------------------------------- /src/Fetch.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from "react"; 4 | import createFetcher from "./createFetcher"; 5 | import type { FetchProps, FetchState } from "./types"; 6 | 7 | const defaultState = { 8 | pending: false, 9 | rejected: false, 10 | fulfilled: false, 11 | value: null, 12 | reason: null, 13 | }; 14 | 15 | class Fetch extends React.Component { 16 | promise: ?Promise<*>; 17 | 18 | static displayName = "Fetch"; 19 | 20 | static defaultProps = { 21 | url: "", 22 | options: {}, 23 | maxAge: 60, // 1 minute 24 | cache: new Map<*, *>(), 25 | fetcher: createFetcher(), 26 | }; 27 | 28 | constructor(props: FetchProps) { 29 | super(props); 30 | this.state = { 31 | // promiseState 32 | ...defaultState, 33 | pending: true, 34 | // actions 35 | read: this.read.bind(this), 36 | refresh: this.refresh.bind(this), 37 | invalidate: this.invalidate.bind(this), 38 | }; 39 | this.promise = undefined; 40 | } 41 | 42 | componentDidMount() { 43 | this.read(); 44 | } 45 | 46 | componentDidUpdate(prevProps: FetchProps) { 47 | const { url, options, maxAge, cache } = this.props; 48 | 49 | const cached = cache.get(url); 50 | 51 | if ( 52 | !cached || 53 | url !== prevProps.url || 54 | options !== prevProps.options || 55 | maxAge !== prevProps.maxAge 56 | ) { 57 | this.read(); 58 | } else if (cached && cached.value && cached.value !== this.promise) { 59 | // handle case where cache needs to be rechecked 60 | this.read(); 61 | } 62 | } 63 | 64 | componentWillUnmount() { 65 | // We clear the promise to prevent setState() from being called by 66 | // lingering promises after the component has unmounted 67 | this.promise = undefined; 68 | } 69 | 70 | onFulfill(value: *, promise: ?Promise<*>) { 71 | // Highlander rule applies: there can be only one promise that sets state 72 | // Only set state if still mounted and this.promise is known. 73 | // The reference is removed in componentWillUnmount so it won't attempt to setState. 74 | if (promise === this.promise) { 75 | this.setState(() => ({ 76 | // promiseState 77 | ...defaultState, 78 | fulfilled: true, 79 | value, 80 | })); 81 | } 82 | } 83 | 84 | onReject(reason: Error, promise: ?Promise<*>) { 85 | if (promise === this.promise) { 86 | this.setState(() => ({ 87 | // promiseState 88 | ...defaultState, 89 | rejected: true, 90 | reason, 91 | })); 92 | } 93 | } 94 | 95 | read() { 96 | this.setState((prevState) => ({ 97 | ...prevState, 98 | pending: true, 99 | })); 100 | 101 | const { url, options, maxAge, fetcher, cache } = this.props; 102 | 103 | const isCacheable = 104 | !options.method || options.method.toLowerCase() === "get"; 105 | 106 | let isStale = false; 107 | let cached; 108 | 109 | if (isCacheable) { 110 | cached = cache.get(url); 111 | } 112 | 113 | /* Discard result if stale */ 114 | if (cached) { 115 | isStale = 116 | cached.lastResolved && 117 | maxAge && 118 | cached.lastResolved + maxAge * 1000 < +new Date(); 119 | if (isStale) { 120 | cached = undefined; 121 | } 122 | } 123 | 124 | let promise; 125 | 126 | if (cached) { 127 | /* CACHE HIT */ 128 | promise = cached.value; 129 | } else { 130 | /* CACHE MISS */ 131 | promise = fetcher(url, options); 132 | 133 | cache.set(url, { 134 | value: promise, 135 | }); 136 | promise.then( 137 | () => { 138 | const r = cache.get(url); 139 | if (r && r.value === promise) { 140 | cache.set(url, { 141 | ...r, 142 | lastResolved: +new Date(), 143 | }); 144 | } 145 | }, 146 | () => { 147 | const r = cache.get(url); 148 | if (r && r.value === promise) { 149 | cache.set(url, { 150 | ...r, 151 | lastResolved: +new Date(), 152 | }); 153 | } 154 | } 155 | ); 156 | } 157 | 158 | this.promise = promise; 159 | 160 | promise 161 | .then((value) => { 162 | this.onFulfill(value, promise); 163 | }) 164 | .catch((error) => { 165 | this.onReject(error, promise); 166 | }); 167 | } 168 | 169 | invalidate() { 170 | const { cache, url } = this.props; 171 | cache.delete(url); 172 | } 173 | 174 | refresh() { 175 | this.invalidate(); 176 | this.read(); 177 | } 178 | 179 | render() { 180 | const { children } = this.props; 181 | 182 | return ( 183 | <>{typeof children === "function" ? children(this.state) : children} 184 | ); 185 | } 186 | } 187 | 188 | export default Fetch; 189 | -------------------------------------------------------------------------------- /src/Resource.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from "react"; 4 | 5 | import Cache from "./Cache"; 6 | import Fetch from "./Fetch"; 7 | 8 | import createFetcher from "./createFetcher"; 9 | 10 | import type { ResourceProps } from "./types"; 11 | 12 | export const getInvalidKeys = ( 13 | cacheKeys: Array, 14 | keysOrFilterFn: string | Array | ((string) => boolean) 15 | ): Array => { 16 | if (typeof keysOrFilterFn === "function") { 17 | return cacheKeys.filter(keysOrFilterFn); 18 | } 19 | 20 | // force string to array 21 | const matchers = [] 22 | .concat(keysOrFilterFn) 23 | // ignore search string in matchers 24 | .map((m) => m.split("?")[0]); 25 | 26 | return cacheKeys.filter((cacheKey) => { 27 | // ignore search string in cacheKey 28 | const key = cacheKey.split("?")[0]; 29 | return matchers.includes(key); 30 | }); 31 | }; 32 | 33 | function mapFetchersToActions(props: ResourceProps, invalidate: Function) { 34 | const mappedActions = {}; 35 | 36 | const { actions, fetcher } = props; 37 | 38 | Object.keys(actions).forEach((key) => { 39 | const action = key ? actions[key] : null; 40 | 41 | if (action && typeof action === "function") { 42 | mappedActions[key] = (...args) => { 43 | const actionProps = action(...args); 44 | 45 | const url = 46 | actionProps.url && typeof actionProps.url === "string" 47 | ? actionProps.url 48 | : props.url; 49 | const options = actionProps.options || props.options; 50 | const invalidates = actionProps.invalidates || []; 51 | 52 | const promise = fetcher(url, options) 53 | .then((value) => { 54 | if (invalidates && invalidate.length > 0) { 55 | invalidate(invalidates); 56 | } 57 | return value; 58 | }) 59 | .catch((error) => { 60 | throw error; 61 | }); 62 | 63 | return promise; 64 | }; 65 | } 66 | }); 67 | 68 | return mappedActions; 69 | } 70 | 71 | const Resource = (props: ResourceProps): React.Node => { 72 | const { 73 | url, 74 | options, 75 | maxAge, 76 | 77 | children, 78 | fetcher, 79 | } = props; 80 | 81 | const defaultCache = new Map<*, *>(); 82 | 83 | return ( 84 | 85 | {(context) => { 86 | // If no Cache provider is found, the default `undefined` will be returned 87 | // Warn user on dev, this may be desired in some cases 88 | if (context === undefined) { 89 | /* eslint-disable no-console */ 90 | console.warn( 91 | "rsrc : - Persistent cache provider not found. Falling back to instance cache." 92 | ); 93 | /* eslint-enable no-console */ 94 | } 95 | 96 | const cache = context || defaultCache; 97 | 98 | const invalidate = (keysOrFilterFn) => { 99 | const keys = getInvalidKeys([...cache.keys()], keysOrFilterFn); 100 | keys.forEach((key) => { 101 | cache.delete(key); 102 | }); 103 | }; 104 | 105 | const actions = mapFetchersToActions(props, invalidate); 106 | 107 | return ( 108 | 115 | {(state) => { 116 | const childProps = { 117 | meta: props, 118 | actions, 119 | state, 120 | }; 121 | 122 | return ( 123 | <> 124 | {typeof children === "function" 125 | ? children(childProps) 126 | : children} 127 | 128 | ); 129 | }} 130 | 131 | ); 132 | }} 133 | 134 | ); 135 | }; 136 | 137 | Resource.defaultProps = { 138 | url: "", 139 | options: {}, 140 | maxAge: 60, // 1 minute 141 | actions: {}, 142 | fetcher: createFetcher(), 143 | }; 144 | 145 | export default Resource; 146 | -------------------------------------------------------------------------------- /src/checkStatus.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export default function checkStatus(response: Response): Promise { 4 | if (!response.ok) { 5 | return Promise.reject(response); 6 | } 7 | return Promise.resolve(response); 8 | } 9 | -------------------------------------------------------------------------------- /src/createFetcher.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import _checkStatus from "./checkStatus"; 4 | import _parseBody from "./parseBody"; 5 | import _parseError from "./parseError"; 6 | 7 | import type { FetcherConfig, Fetcher } from "./types"; 8 | 9 | export default function createFetcher(overrides: FetcherConfig = {}): Fetcher { 10 | const config = { 11 | fetchFunction: overrides.fetchFunction || fetch, 12 | checkStatus: overrides.checkStatus || _checkStatus, 13 | parseBody: overrides.parseBody || _parseBody, 14 | parseError: overrides.parseError || _parseError, 15 | }; 16 | 17 | const { fetchFunction } = config; 18 | 19 | return (url, options) => 20 | fetchFunction(url, options) 21 | // First we check the status of the response and either resolve or reject it 22 | // based on the function's criteria. The default is to only resolve when 23 | // `response.ok` is true (status in the range 200-299). 24 | .then((response) => { 25 | const { checkStatus } = config; 26 | return checkStatus(response); 27 | }) 28 | .then((response) => { 29 | const { parseBody } = config; 30 | return parseBody(response).then((value) => Promise.resolve(value)); 31 | }) 32 | .catch((error) => { 33 | if (error instanceof Error) { 34 | return Promise.reject(error); 35 | } 36 | 37 | const { parseError } = config; 38 | 39 | return parseError(error).then((reason) => Promise.reject(reason)); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import Cache from "./Cache"; 4 | import Fetch from "./Fetch"; 5 | import Resource from "./Resource"; 6 | 7 | import createFetcher from "./createFetcher"; 8 | 9 | export * from "./types"; 10 | 11 | export { Cache, Fetch, Resource, createFetcher }; 12 | -------------------------------------------------------------------------------- /src/parseBody.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export default function parseBody(response: Response): Promise { 4 | const contentType = response.headers.get("Content-Type"); 5 | 6 | // Response methods return promises. To simulate for empty responses, 7 | // we wrap the return in a Promise.resolve() 8 | if (!contentType || response.status === 204) { 9 | return Promise.resolve(null); 10 | } 11 | 12 | const mimeType = contentType.split(";")[0].trim(); 13 | 14 | if ( 15 | mimeType === "application/json" || 16 | mimeType === "text/json" || 17 | /\+json$/.test(mimeType) 18 | ) { 19 | return response.json(); 20 | } 21 | 22 | if ( 23 | mimeType === "text/plain" || 24 | mimeType === "text/html" || 25 | mimeType === "application/xml" || 26 | mimeType === "text/xml" || 27 | /\+xml$/.test(mimeType) 28 | ) { 29 | return response.text(); 30 | } 31 | 32 | return response.arrayBuffer(); 33 | } 34 | -------------------------------------------------------------------------------- /src/parseError.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import parseBody from "./parseBody"; 4 | 5 | export default function parseError(response: Response): Promise { 6 | return parseBody(response).then((body) => 7 | Promise.resolve( 8 | new Error( 9 | `HTTP ${response.status} ${response.statusText}: ${JSON.stringify( 10 | body 11 | )}` 12 | ) 13 | ) 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | /* eslint-disable no-use-before-define */ 4 | 5 | import * as React from "react"; 6 | 7 | /* 8 | * Cache 9 | */ 10 | 11 | export type CacheState = { 12 | get: (key: *) => *, 13 | has: (key: *) => boolean, 14 | entries: () => Array<*>, 15 | values: () => Array<*>, 16 | keys: () => Array<*>, 17 | set: (key: *, value: *) => Map<*, *>, 18 | delete: (key: *) => any, 19 | clear: () => void, 20 | }; 21 | 22 | export type CacheProps = { 23 | map: 24 | | Map<*, *> 25 | | { 26 | get: (key: *) => *, 27 | has: (key: *) => boolean, 28 | entries: () => Iterator<*> | Array<*>, 29 | values: () => Iterator<*> | Array<*>, 30 | keys: () => Iterator<*> | Array<*>, 31 | set: (key: *, value: *) => any, 32 | delete: (key: *) => any, 33 | clear: () => void, 34 | }, 35 | children: React.Node, 36 | }; 37 | 38 | /* 39 | * Fetcher 40 | */ 41 | 42 | export type FetcherState = { 43 | pending: boolean, 44 | rejected: boolean, 45 | fulfilled: boolean, 46 | value: ?any, 47 | reason: ?Error, 48 | }; 49 | 50 | export type FetcherConfig = { 51 | fetchFunction?: typeof fetch, 52 | checkStatus?: (response: Response) => Promise, 53 | parseBody?: (response: Response) => Promise, 54 | parseError?: (response: Response, value: any) => Promise, 55 | }; 56 | 57 | export type Fetcher = ( 58 | url: string | Request, 59 | options?: RequestOptions 60 | ) => Promise; 61 | 62 | /* 63 | * Fetch 64 | */ 65 | 66 | export type FetchState = FetcherState & { 67 | invalidate: () => void, 68 | read: () => void, 69 | refresh: () => void, 70 | }; 71 | 72 | export type FetchProps = { 73 | url: string, 74 | options: RequestOptions, 75 | maxAge: number, 76 | 77 | children: (FetchState) => React.Node, 78 | 79 | /* Optional configuration overrides */ 80 | cache: Map<*, *> | CacheState, 81 | fetcher: Fetcher, 82 | }; 83 | 84 | /* 85 | * Resource 86 | */ 87 | 88 | export type ResourceState = { 89 | state: FetchState, 90 | actions: { 91 | [key: string]: (*) => Promise<*>, 92 | }, 93 | meta: ResourceProps, 94 | }; 95 | 96 | export type ResourceProps = { 97 | url: string, 98 | options: RequestOptions, 99 | maxAge: number, 100 | actions: { 101 | [key: string]: ( 102 | * 103 | ) => { 104 | url?: string, 105 | options?: RequestOptions, 106 | maxAge?: number, 107 | invalidates?: Array, 108 | }, 109 | }, 110 | 111 | children: (ResourceState) => React.Node, 112 | fetcher: Fetcher, 113 | }; 114 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/Cache.test.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from "react"; 4 | import { render, cleanup } from "@testing-library/react"; 5 | import HLRU from "hashlru"; 6 | import { Cache } from "../src"; 7 | 8 | afterEach(cleanup); 9 | 10 | function setup(increment) { 11 | let renderProps = {}; 12 | const children = (arg) => { 13 | renderProps = { ...arg }; 14 | increment(); 15 | return null; 16 | }; 17 | render( 18 | 19 | {children} 20 | 21 | ); 22 | return { 23 | renderProps, 24 | }; 25 | } 26 | 27 | test("cache provider only updates on mutations", async () => { 28 | let counter = 0; 29 | 30 | const increment = () => { 31 | counter += 1; 32 | }; 33 | const { renderProps } = setup(increment); 34 | 35 | expect([...renderProps.entries()]).toEqual([]); 36 | expect(counter).toBe(1); 37 | 38 | expect(renderProps.set("foo", "bar")).toEqual(new Map([["foo", "bar"]])); 39 | expect(counter).toBe(2); 40 | 41 | expect(renderProps.get("foo")).toEqual("bar"); 42 | expect(counter).toBe(2); 43 | 44 | expect(renderProps.has("foo")).toEqual(true); 45 | expect(counter).toBe(2); 46 | 47 | expect([...renderProps.entries()]).toEqual([["foo", "bar"]]); 48 | expect(counter).toBe(2); 49 | 50 | expect([...renderProps.values()]).toEqual(["bar"]); 51 | expect(counter).toBe(2); 52 | 53 | expect([...renderProps.keys()]).toEqual(["foo"]); 54 | expect(counter).toBe(2); 55 | 56 | expect(renderProps.set("baz", "qux")).toEqual( 57 | new Map([ 58 | ["foo", "bar"], 59 | ["baz", "qux"], 60 | ]) 61 | ); 62 | expect(counter).toEqual(3); 63 | 64 | expect(renderProps.delete("foo")).toBe(true); 65 | expect(counter).toEqual(4); 66 | 67 | // TODO: add this back if we need it 68 | // expect(renderProps.delete('foo')).toBe(false) 69 | // expect(counter).toEqual(4) 70 | 71 | renderProps.clear(); 72 | expect([...renderProps.entries()]).toEqual([]); 73 | expect(counter).toEqual(5); 74 | }); 75 | 76 | test("accommodates custom caches that do not return boolean from delete", async () => { 77 | let renderProps = {}; 78 | const children = (arg) => { 79 | renderProps = { ...arg }; 80 | return null; 81 | }; 82 | 83 | const lru = HLRU(5); 84 | lru.delete = lru.remove; 85 | 86 | await render( 87 | 88 | {children} 89 | 90 | ); 91 | 92 | expect(renderProps.delete("foo")).toEqual(false); 93 | 94 | renderProps.set("foo", "bar"); 95 | expect(renderProps.get("foo")).toEqual("bar"); 96 | expect(renderProps.delete("foo")).toEqual(true); 97 | }); 98 | -------------------------------------------------------------------------------- /test/Fetch.test.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from "react"; 4 | import { cleanup, render, waitFor } from "@testing-library/react"; 5 | import fetch from "jest-fetch-mock"; 6 | import { Cache, Fetch } from "../src"; 7 | 8 | beforeEach(cleanup); 9 | afterEach(() => { 10 | fetch.resetMocks(); 11 | }); 12 | 13 | test(" should fulfill requests and stay in sync", async () => { 14 | fetch.mockResponses( 15 | [ 16 | JSON.stringify({ data: "alpha" }), 17 | { 18 | status: 200, 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | }, 23 | ], 24 | [ 25 | JSON.stringify({ data: "bravo" }), 26 | { 27 | status: 200, 28 | headers: { 29 | "Content-Type": "application/json", 30 | }, 31 | }, 32 | ], 33 | [ 34 | JSON.stringify({ data: "charlie" }), 35 | { 36 | status: 200, 37 | headers: { 38 | "Content-Type": "application/json", 39 | }, 40 | }, 41 | ], 42 | [ 43 | JSON.stringify({ data: "delta" }), 44 | { 45 | status: 200, 46 | headers: { 47 | "Content-Type": "application/json", 48 | }, 49 | }, 50 | ], 51 | [ 52 | JSON.stringify({ data: "echo" }), 53 | { 54 | status: 200, 55 | headers: { 56 | "Content-Type": "application/json", 57 | }, 58 | }, 59 | ] 60 | ); 61 | 62 | let renderProps1 = {}; 63 | const children1 = (arg) => { 64 | renderProps1 = { ...arg }; 65 | return null; 66 | }; 67 | 68 | let renderProps2 = {}; 69 | const children2 = (arg) => { 70 | renderProps2 = { ...arg }; 71 | return null; 72 | }; 73 | 74 | // 75 | // We are using Fetch with Cache in order to test expected use where 76 | // `componentDidUpdate` checks allow components relying on the same endpoint 77 | // to stay in sync. The default cache used by Fetch (i.e. `new Map`) is 78 | // unaware of state and not expected to keep components in sync. 79 | // 80 | // For example, the below implementation will not trigger a state change at 81 | // the cache level and thus never trigger `componentDidUpdate`: 82 | // 83 | // const Component = ({ url1 = "alpha", url2 = "alpha" }) => ( 84 | // <> 85 | // {children1} 86 | // {children2} 87 | // 88 | // ); 89 | // 90 | type Props = { 91 | url1?: string, 92 | url2?: string, 93 | }; 94 | const Component = ({ url1, url2 }: Props) => ( 95 | 96 | 97 | {(cache) => ( 98 | <> 99 | {/* $FlowIgnore */} 100 | 101 | {children1} 102 | 103 | {/* $FlowIgnore */} 104 | 105 | {children2} 106 | 107 | 108 | )} 109 | 110 | 111 | ); 112 | Component.defaultProps = { 113 | url1: "foo", 114 | url2: "foo", 115 | }; 116 | 117 | const { rerender } = await render(); 118 | 119 | expect(renderProps1.pending).toBe(true); 120 | expect(renderProps2.pending).toBe(true); 121 | // $FlowIgnore 122 | await waitFor(() => expect(renderProps1.value.data).toBe("alpha")); 123 | // $FlowIgnore 124 | await waitFor(() => expect(renderProps2.value.data).toBe("alpha")); 125 | expect(fetch).toHaveBeenCalledTimes(1); 126 | 127 | // read should not trigger a refetch or a state change at the cache level 128 | renderProps1.read(); 129 | expect(renderProps1.pending).toBe(true); 130 | expect(renderProps2.pending).toBe(false); 131 | // $FlowIgnore 132 | await waitFor(() => expect(renderProps1.value.data).toBe("alpha")); 133 | // $FlowIgnore 134 | await waitFor(() => expect(renderProps2.value.data).toBe("alpha")); 135 | expect(fetch).toHaveBeenCalledTimes(1); 136 | 137 | // refresh and invalidate should trigger both fetch states to update even if 138 | // the key already exists for the other fetch (see Fetch #componentDidUpdate) 139 | renderProps1.refresh(); 140 | expect(renderProps1.pending).toBe(true); 141 | expect(renderProps2.pending).toBe(true); 142 | // $FlowIgnore 143 | await waitFor(() => expect(renderProps1.value.data).toBe("bravo")); 144 | // $FlowIgnore 145 | await waitFor(() => expect(renderProps2.value.data).toBe("bravo")); 146 | expect(fetch).toHaveBeenCalledTimes(2); 147 | 148 | renderProps2.refresh(); 149 | expect(renderProps1.pending).toBe(true); 150 | expect(renderProps2.pending).toBe(true); 151 | // $FlowIgnore 152 | await waitFor(() => expect(renderProps1.value.data).toBe("charlie")); 153 | // $FlowIgnore 154 | await waitFor(() => expect(renderProps2.value.data).toBe("charlie")); 155 | expect(fetch).toHaveBeenCalledTimes(3); 156 | 157 | renderProps1.invalidate(); 158 | expect(renderProps1.pending).toBe(true); 159 | expect(renderProps2.pending).toBe(true); 160 | // $FlowIgnore 161 | await waitFor(() => expect(renderProps1.value.data).toBe("delta")); 162 | // $FlowIgnore 163 | await waitFor(() => expect(renderProps2.value.data).toBe("delta")); 164 | expect(fetch).toHaveBeenCalledTimes(4); 165 | 166 | // changing the url of one fetch should not impact the other 167 | rerender(); 168 | expect(renderProps1.pending).toBe(true); 169 | expect(renderProps2.pending).toBe(false); 170 | // $FlowIgnore 171 | await waitFor(() => expect(renderProps1.value.data).toBe("echo")); 172 | // $FlowIgnore 173 | await waitFor(() => expect(renderProps2.value.data).toBe("delta")); 174 | expect(fetch).toHaveBeenCalledTimes(5); 175 | }); 176 | 177 | test(" should set state to rejected for no-OK response", async () => { 178 | fetch.mockResponses([ 179 | JSON.stringify("FAIL"), 180 | { 181 | status: 500, 182 | headers: { 183 | "Content-Type": "text/plain", 184 | }, 185 | }, 186 | ]); 187 | 188 | let renderProps = {}; 189 | const children = (arg) => { 190 | renderProps = { ...arg }; 191 | return null; 192 | }; 193 | const Component = () => {children}; 194 | await render(); 195 | 196 | expect(renderProps.pending).toBe(true); 197 | await waitFor(() => expect(renderProps.rejected).toBe(true)); 198 | }); 199 | 200 | test(" should refetch on stale cache hit", async () => { 201 | fetch.mockResponses( 202 | [ 203 | JSON.stringify({ data: "alpha" }), 204 | { 205 | status: 200, 206 | headers: { 207 | "Content-Type": "text/plain", 208 | }, 209 | }, 210 | ], 211 | [ 212 | JSON.stringify({ data: "bravo" }), 213 | { 214 | status: 200, 215 | headers: { 216 | "Content-Type": "text/plain", 217 | }, 218 | }, 219 | ] 220 | ); 221 | 222 | const sleep = (time) => 223 | new Promise((res) => { 224 | setTimeout(res, time); 225 | }); 226 | 227 | let renderProps = {}; 228 | const children = (arg) => { 229 | renderProps = { ...arg }; 230 | return null; 231 | }; 232 | const Component = () => ( 233 | 234 | {children} 235 | 236 | ); 237 | 238 | await render(); 239 | 240 | expect(fetch).toHaveBeenCalledTimes(1); 241 | 242 | await sleep(100); 243 | renderProps.read(); 244 | expect(fetch).toHaveBeenCalledTimes(1); 245 | 246 | await sleep(1000); 247 | renderProps.read(); 248 | expect(fetch).toHaveBeenCalledTimes(2); 249 | }); 250 | -------------------------------------------------------------------------------- /test/Resource.test.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from "react"; 4 | import { render, cleanup, waitFor } from "@testing-library/react"; 5 | import fetch from "jest-fetch-mock"; 6 | import { Cache, Resource } from "../src"; 7 | 8 | import { getInvalidKeys } from "../src/Resource"; 9 | 10 | afterEach(cleanup); 11 | 12 | test("#getInvalidKeys", async () => { 13 | const keys = [ 14 | "/foo", 15 | "/foo?q=1", 16 | "/foo/quux", 17 | "/bar", 18 | "/bar?q=1", 19 | "/bar/quux", 20 | ]; 21 | 22 | expect(getInvalidKeys(keys, (key) => ["/foo"].includes(key))).toEqual([ 23 | "/foo", 24 | ]); 25 | expect(getInvalidKeys(keys, "/bar")).toEqual(["/bar", "/bar?q=1"]); 26 | expect(getInvalidKeys(keys, "/bar?q=3")).toEqual(["/bar", "/bar?q=1"]); 27 | expect(getInvalidKeys(keys, ["/foo?q=2", "/bar"])).toEqual([ 28 | "/foo", 29 | "/foo?q=1", 30 | "/bar", 31 | "/bar?q=1", 32 | ]); 33 | }); 34 | 35 | test("", async () => { 36 | fetch.mockResponses([ 37 | JSON.stringify({ data: "ok" }), 38 | { 39 | status: 200, 40 | headers: { 41 | "Content-Type": "application/json", 42 | }, 43 | }, 44 | ]); 45 | let renderProps = {}; 46 | const children = (arg) => { 47 | renderProps = { ...arg }; 48 | return null; 49 | }; 50 | 51 | const url = "https://foo.bar.com/baz/quux"; 52 | 53 | await render( 54 | 55 | ({ 60 | options: { 61 | body: JSON.stringify(data), 62 | method: "POST", 63 | }, 64 | invalidates: [url], 65 | }), 66 | }} 67 | > 68 | {children} 69 | 70 | 71 | ); 72 | 73 | expect(fetch).toHaveBeenCalledTimes(1); 74 | 75 | renderProps.state.read(); 76 | expect(fetch).toHaveBeenCalledTimes(1); 77 | 78 | renderProps.state.invalidate(); 79 | renderProps.state.read(); 80 | expect(fetch).toHaveBeenCalledTimes(2); 81 | 82 | renderProps.state.refresh(); 83 | expect(fetch).toHaveBeenCalledTimes(3); 84 | 85 | await waitFor(() => 86 | renderProps.actions 87 | .create({ foo: "bar" }) 88 | .then(() => {}) 89 | .catch(() => {}) 90 | ); 91 | 92 | expect(fetch).toHaveBeenCalledTimes(5); 93 | 94 | renderProps.state.read(); 95 | expect(fetch).toHaveBeenCalledTimes(5); 96 | }); 97 | -------------------------------------------------------------------------------- /test/checkStatus.test.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | /* eslint-disable compat/compat */ 4 | 5 | import { cleanup, waitFor } from "@testing-library/react"; 6 | import checkStatus from "../src/checkStatus"; 7 | 8 | afterEach(cleanup); 9 | 10 | test("#checkStatus ok", async () => { 11 | const body = ""; 12 | const init = { 13 | ok: true, 14 | status: 200, 15 | statusText: "OK", 16 | }; 17 | const res = new Response(body, init); 18 | await waitFor(() => expect(checkStatus(res)).resolves.toEqual(res)); 19 | }); 20 | 21 | /* 22 | test('#checkStatus !ok', async () => { 23 | const body = '' 24 | const init = { 25 | ok: false, 26 | status: 404, 27 | statusText: 'NOT FOUND', 28 | } 29 | const res = new Response(body, init) 30 | await waitFor(() => expect(checkStatus(res)).rejects.toEqual(new Error(res.statusText))) 31 | }) 32 | */ 33 | -------------------------------------------------------------------------------- /test/createFetcher.test.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import { cleanup } from "@testing-library/react"; 4 | import fetch from "jest-fetch-mock"; 5 | import createFetcher from "../src/createFetcher"; 6 | 7 | afterEach(cleanup); 8 | 9 | test("#createFetcher returns a fetcher that updates state on successful fetches", async () => { 10 | fetch.mockResponses([ 11 | JSON.stringify({ data: "ok" }), 12 | { 13 | status: 200, 14 | headers: { 15 | "Content-Type": "application/json", 16 | }, 17 | }, 18 | ]); 19 | const value = await createFetcher()("/foo"); 20 | expect(value).toEqual({ data: "ok" }); 21 | }); 22 | 23 | test("#createFetcher returns a fetcher that updates state on unsuccessful fetches", async () => { 24 | const error = new Error( 25 | 'HTTP 500 Internal Server Error: "{\\"message\\":\\"Whoops\\"}"' 26 | ); 27 | fetch.mockResponses([ 28 | JSON.stringify({ message: "Whoops" }), 29 | { 30 | status: 500, 31 | headers: { 32 | "Content-Type": "text/plain", 33 | }, 34 | }, 35 | ]); 36 | expect(createFetcher()("/foo")).rejects.toEqual(error); 37 | }); 38 | -------------------------------------------------------------------------------- /test/parseBody.test.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | /* eslint-disable compat/compat */ 4 | 5 | import { cleanup, waitFor } from "@testing-library/react"; 6 | import parseBody from "../src/parseBody"; 7 | 8 | afterEach(cleanup); 9 | 10 | test("#parseBody 204 empty", async () => { 11 | const body = "Yeehaw"; 12 | const init = { 13 | status: 204, 14 | }; 15 | const res = new Response(body, init); 16 | await waitFor(() => expect(parseBody(res)).resolves.toBe(null)); 17 | }); 18 | 19 | test("#parseBody no content-type", async () => { 20 | const body = "Yeehaw"; 21 | const init = { 22 | headers: { 23 | "Content-Type": "", 24 | }, 25 | }; 26 | const res = new Response(body, init); 27 | await waitFor(() => expect(parseBody(res)).resolves.toBe(null)); 28 | }); 29 | 30 | test("#parseBody text", async () => { 31 | const body = "Yeehaw"; 32 | const init = { 33 | headers: { 34 | "Content-Type": "text/plain", 35 | }, 36 | }; 37 | const res = new Response(body, init); 38 | await waitFor(() => expect(parseBody(res)).resolves.toEqual(body)); 39 | }); 40 | 41 | test("#parseBody json", async () => { 42 | const body = '{ "foo": "giddyup" }'; 43 | const init = { 44 | headers: { 45 | "Content-Type": "application/json", 46 | }, 47 | }; 48 | const res = new Response(body, init); 49 | await waitFor(() => 50 | expect(parseBody(res)).resolves.toEqual(JSON.parse(body)) 51 | ); 52 | }); 53 | 54 | test("#parseBody other -> arraybuffer", async () => { 55 | const body = ""; 56 | const init = { 57 | headers: { 58 | "Content-Type": "something/else", 59 | }, 60 | }; 61 | const res = new Response(body, init); 62 | 63 | // NOTE: we are coercing to string to work around an issue where ArrayBuffer 64 | // instance types cannot be compared. 65 | // 66 | // https://github.com/facebook/jest/issues/7780 67 | // 68 | // expect(parseBody(res)).resolves.toBeInstanceOf(ArrayBuffer) 69 | // 70 | // ● #parseBody other -> arraybuffer 71 | // 72 | // expect(received).resolves.toBeInstanceOf(expected) 73 | // 74 | // Expected constructor: ArrayBuffer 75 | // Received constructor: ArrayBuffer 76 | 77 | await waitFor(() => 78 | expect(parseBody(res).then((p) => p.toString())).resolves.toBe( 79 | "[object ArrayBuffer]" 80 | ) 81 | ); 82 | }); 83 | -------------------------------------------------------------------------------- /test/parseError.test.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | /* eslint-disable compat/compat */ 4 | 5 | import { cleanup } from "@testing-library/react"; 6 | import parseError from "../src/parseError"; 7 | 8 | afterEach(cleanup); 9 | 10 | test("#parseError", async () => { 11 | const body = JSON.stringify({ message: "Whoops" }); 12 | const init = { 13 | status: 404, 14 | headers: { 15 | "Content-Type": "application/json", 16 | }, 17 | }; 18 | const res = new Response(body, init); 19 | const error = await parseError(res); 20 | expect(error.message).toBe(`HTTP 404 Not Found: ${body}`); 21 | }); 22 | -------------------------------------------------------------------------------- /test/setupJest.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | require("core-js/stable"); 4 | require("regenerator-runtime/runtime"); 5 | 6 | global.fetch = require("jest-fetch-mock"); 7 | -------------------------------------------------------------------------------- /website/.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["next/babel"], 3 | plugins: ["@babel/plugin-transform-flow-strip-types"], 4 | }; 5 | -------------------------------------------------------------------------------- /website/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/no-extraneous-dependencies": "off", 4 | "react/jsx-pascal-case": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /website/components/ColorModeToggle.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from "react"; 4 | import { Styled, useColorMode } from "theme-ui"; 5 | import { Box } from "rebass"; 6 | 7 | const ColorModeToggle = () => { 8 | const [colorMode, setColorMode] = useColorMode(); 9 | 10 | const toggleMode = () => { 11 | const modes = ["dark", "light"]; 12 | const currentIndex = modes.indexOf(colorMode); 13 | let i = currentIndex + 1; 14 | if (currentIndex === modes.length - 1) { 15 | i = 0; 16 | } 17 | setColorMode(modes[i]); 18 | }; 19 | 20 | return ( 21 | 26 | 27 | 35 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | export default ColorModeToggle; 48 | -------------------------------------------------------------------------------- /website/components/Container.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from "react"; 4 | import { Box } from "rebass"; 5 | 6 | type Props = { 7 | children: React.Node, 8 | as: string, 9 | }; 10 | 11 | const Container = ({ children, as = "div" }: Props) => ( 12 | 13 | {children} 14 | 15 | ); 16 | 17 | export default Container; 18 | -------------------------------------------------------------------------------- /website/components/Footer.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from "react"; 4 | import { Flex, Box } from "rebass"; 5 | import { Styled } from "theme-ui"; 6 | import Container from "./Container"; 7 | import Link from "./Link"; 8 | import SigSciLogo from "./SigSciLogo"; 9 | 10 | const Footer = () => { 11 | return ( 12 | 13 | 14 | 15 | 16 | {`© ${new Date().getFullYear()} Signal Sciences`} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default Footer; 30 | -------------------------------------------------------------------------------- /website/components/Header.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from "react"; 4 | import { Flex, Box } from "rebass"; 5 | import { Styled } from "theme-ui"; 6 | import Container from "./Container"; 7 | import RsrcLogo from "./RsrcLogo"; 8 | import Link from "./Link"; 9 | import ColorModeToggle from "./ColorModeToggle"; 10 | 11 | const Header = () => { 12 | return ( 13 | 14 | 20 | 21 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 41 | Docs 42 | 43 | 44 | 45 | 52 | Github 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | export default Header; 66 | -------------------------------------------------------------------------------- /website/components/Layout.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* eslint-disable react/jsx-props-no-spreading */ 3 | 4 | import * as React from "react"; 5 | import { Global } from "@emotion/react"; 6 | import { ThemeProvider, Styled } from "theme-ui"; 7 | import Prism from "@theme-ui/prism"; 8 | import { Flex, Box, Image } from "rebass"; 9 | 10 | import Link from "./Link"; 11 | import Footer from "./Footer"; 12 | import Header from "./Header"; 13 | import Main from "./Main"; 14 | import theme from "../theme"; 15 | 16 | type Props = { 17 | children: React.Node, 18 | }; 19 | 20 | const withLink = (Tag) => (props: Props & { id: string }) => { 21 | const { id } = props; 22 | if (!id) return ; 23 | const { children } = props; 24 | return ( 25 | 26 | {children} 27 | 28 | ); 29 | }; 30 | 31 | const components = { 32 | pre: ({ children }: Props) => <>{children}, 33 | code: Prism, 34 | inlineCode: ({ children }: Props) => {children}, 35 | a: Link, 36 | img: Image, 37 | h2: withLink("h2"), 38 | h3: withLink("h3"), 39 | }; 40 | 41 | const Layout = ({ children }: Props) => ( 42 | 43 | 55 | 56 | 63 | 64 |
65 | 66 | 67 |
{children}
68 |
69 | 70 |