├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── examples └── todos │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.css │ ├── App.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ └── serviceWorker.js │ └── yarn.lock ├── package.json ├── rollup.config.js ├── src ├── Mirror.ts ├── MirrorActions.tsx ├── MirrorContext.tsx ├── MirrorHandles.tsx ├── MirrorSnapshots.ts ├── NamespacedMirrorImplementation.tsx ├── createMirror.ts ├── index.ts ├── useDocument.ts └── useSnapshot.ts ├── test ├── MirrorKeyHandle.test.tsx ├── MirrorKeyListHandle.test.tsx ├── createMirror.test.ts └── useSnapshot.test.tsx ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'react-app', 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .vscode 3 | dist 4 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "jsxBracketSameLine": true, 8 | "parser": "typescript", 9 | "semi": false, 10 | "rcVerbose": false 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 James K Nelson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | 'Software'), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-zen 2 | ========= 3 | 4 | NPM 5 | 6 | **A collection of simple utilities for React** 7 | 8 | ```bash 9 | yarn add react-zen 10 | ``` 11 | 12 | What? 13 | ----- 14 | 15 | Currently, react-zen contains just two utilities: 16 | 17 | - `createMirror(fetcher)` 18 | - `useSnapshot(handle)` 19 | 20 | Together, these two functions let you easily consume asynchronous data in your React components. For example, here's how you'd load and display data from a REST API: 21 | 22 | ```js 23 | import { Suspense } from 'react' 24 | import { createMirror, useSnapshot } from 'react-zen' 25 | 26 | // A mirror automatically fetches data as it is required, and purges it 27 | // once it is no longer in use. 28 | const api = createMirror(async url => { 29 | let response = await fetch(BaseURL+url) 30 | return response.json() 31 | }) 32 | 33 | function Screen() { 34 | // useSnapshot returns your data, loading status, etc. 35 | let { data } = useSnapshot(api.key('/todos/1')) 36 | return
{data.title}
37 | } 38 | 39 | function App() { 40 | return ( 41 | // Make sure to wrap your any component that use `useSnapshot()` 42 | // with a tag. 43 | Loading}> 44 | 45 | 46 | ) 47 | } 48 | ``` 49 | 50 | Of course, you'll also want to be able to refresh and update your data. Mirrors and snapshots both have a suite of methods to make this easy. You can see how this works at the live example: 51 | 52 | [See a full featured example at CodeSandbox](https://codesandbox.io/s/broken-water-48o94) 53 | 54 | 55 | 56 | API 57 | --- 58 | 59 | ### `useSnapshot()` 60 | 61 | Returns a snapshot of the data specified by the given handle. 62 | 63 | ```typescript 64 | export function useSnapshot( 65 | handle: MirrorHandle, 66 | ): { 67 | data: Data 68 | key: Key 69 | 70 | /** 71 | * Set to true after `invalidate` has been called, and stays true until a 72 | * more recent version of the document is received. 73 | */ 74 | invalidated: boolean 75 | 76 | /** 77 | * Indicates that a fetch is currently taking place. 78 | */ 79 | pending: boolean 80 | 81 | /** 82 | * Starts as false, and becomes true once `data` is set for the first time. 83 | */ 84 | primed: boolean 85 | 86 | /** 87 | * Marks this key's currently stored snapshot as invalid. 88 | * 89 | * If there's an active subscription, a new version of the data will be 90 | * fetched. 91 | */ 92 | invalidate(): void 93 | 94 | /** 95 | * Stores the given data. If there is no subscription for it, then the data 96 | * will be immediately scheduled for purge. 97 | * 98 | * In the case a function is given, if this key has a non-empty snapshot, 99 | * then the updater callback will be called and the returned value will be 100 | * set as the current data. If the key's snapshot is empty or is not yet 101 | * primed, then an error will be thrown. 102 | * 103 | * This will not change the `pending` status of your data. 104 | */ 105 | update(dataOrUpdater: Data | MirrorUpdaterCallback): void 106 | } 107 | ``` 108 | 109 | 110 | ### `createMirror()` 111 | 112 | Create a mirror of the data in some asynchronous source, where data is automatically fetched as required, and purged when no longer needed. 113 | 114 | ```typescript 115 | createMirror(fetch: ( 116 | snapshot: Snapshot, 117 | context: Context 118 | ) => Promise) 119 | ``` 120 | 121 | 122 | ### `Mirror` 123 | 124 | The object returned by `createMirror()`. 125 | 126 | ```typescript 127 | interface Mirror { 128 | /** 129 | * Return a handle for the specified key, from which you can get 130 | * and subscribe to its value. 131 | */ 132 | key(key: string): MirrorKeyHandle 133 | 134 | /** 135 | * Return a handle for the specified keys, from which you can get 136 | * and subscribe to all their values at once. 137 | */ 138 | key(keys: string[]): MirrorKeyListHandle 139 | 140 | /** 141 | * Return a list of the keys currently stored in the mirror for the given 142 | * deps array. 143 | */ 144 | knownKeys(): Key[] 145 | } 146 | ``` 147 | 148 | 149 | ### `MirrorHandle` 150 | 151 | As returned by `mirror.key(key)` and `mirror.keys(keys)` 152 | 153 | ```typescript 154 | interface MirrorHandle { 155 | key: Key 156 | 157 | /** 158 | * Returns a promise to a mirrored snapshot for this key. 159 | */ 160 | get(): Promise> 161 | 162 | /** 163 | * Returns the latest snapshot for the given data. 164 | */ 165 | getLatest(): MirrorSnapshot 166 | 167 | /** 168 | * Subscribe to updates to snapshots for the given key. Note that this will 169 | * not immediately emit a snapshot unless subscribing triggers a fetch, and 170 | * adds/updates a snapshot in the process. 171 | */ 172 | subscribe( 173 | callback: MirrorSubscribeCallback, 174 | ): MirrorUnsubscribeFunction 175 | 176 | /** 177 | * Marks this key's currently stored snapshot as invalid. 178 | * 179 | * If there's an active subscription, a new version of the data will be 180 | * fetched. 181 | */ 182 | invalidate(): void 183 | 184 | /** 185 | * Stores the given data. If there is no subscription for it, then the data 186 | * will be immediately scheduled for purge. 187 | * 188 | * In the case a function is given, if this key has a non-empty snapshot, 189 | * then the updater callback will be called and the returned value will be 190 | * set as the current data. If the key's snapshot is empty or is not yet 191 | * primed, then an error will be thrown. 192 | * 193 | * This will not change the `pending` status of your data. 194 | */ 195 | update(dataOrUpdater: Data | MirrorUpdaterCallback): void 196 | } 197 | ``` 198 | 199 | 200 | Contributing / Plans 201 | -------------------- 202 | 203 | A number of undocumented placeholder functions currently exist, which throw an exception when called. PRs implementing theses would be very welcome. Functions include: 204 | 205 | - `mirror.keys()`, which should allow you to get/subscribe to a list of keys at once. 206 | - `mirror.hydrateFromState()`, which should allow serialized data to be passed from the server to the client. 207 | - `mirror.purge()`, which should allow all data within a mirror to be immediately purged. 208 | - `handle.predictUpdate()`, which should allow for optimistic updates to be recorded against a key. 209 | 210 | A number of *other* features have already been implemented, but need documentation and testing. These include namespaces and `extractState()`, both of which would be useful for server side rendering. 211 | 212 | Once `mirror.keys()` has been implemented, it should be possible to create a `CollectionMirror`, which allows you to subscribe to queries/collections as well as individual records. 213 | 214 | On other feature that would go a long way to improving real world use would configurable strategies for fetching invalidated/initial data -- instead of just giving up after the first fetch, as currently happens. Alongside this, it may make sense to have the ability to command a fetch, as opposed to just marking data as invalidated and allowing fetch to happen automatically. 215 | 216 | 217 | License 218 | ------- 219 | 220 | react-zen is MIT licensed. 221 | -------------------------------------------------------------------------------- /examples/todos/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/todos/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /examples/todos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todos", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react-scripts": "3.2.0", 7 | "react-zen": "link:../.." 8 | }, 9 | "scripts": { 10 | "start": "react-scripts start", 11 | "build": "react-scripts build", 12 | "test": "react-scripts test", 13 | "eject": "react-scripts eject" 14 | }, 15 | "eslintConfig": { 16 | "extends": "react-app" 17 | }, 18 | "browserslist": { 19 | "production": [ 20 | ">0.2%", 21 | "not dead", 22 | "not op_mini all" 23 | ], 24 | "development": [ 25 | "last 1 chrome version", 26 | "last 1 firefox version", 27 | "last 1 safari version" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/todos/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesknelson/react-zen/b18d59ece6a63e46bfe18cac27ecd691c2e11794/examples/todos/public/favicon.ico -------------------------------------------------------------------------------- /examples/todos/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/todos/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesknelson/react-zen/b18d59ece6a63e46bfe18cac27ecd691c2e11794/examples/todos/public/logo192.png -------------------------------------------------------------------------------- /examples/todos/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesknelson/react-zen/b18d59ece6a63e46bfe18cac27ecd691c2e11794/examples/todos/public/logo512.png -------------------------------------------------------------------------------- /examples/todos/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/todos/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /examples/todos/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | } 8 | 9 | .App-header { 10 | background-color: #282c34; 11 | min-height: 100vh; 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | justify-content: center; 16 | font-size: calc(10px + 2vmin); 17 | color: white; 18 | } 19 | 20 | .App-link { 21 | color: #09d3ac; 22 | } 23 | 24 | input { 25 | width: 50px; 26 | } -------------------------------------------------------------------------------- /examples/todos/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useState, useEffect } from 'react' 2 | import { createMirror, useSnapshot } from 'react-zen' 3 | import './App.css' 4 | 5 | const BaseURL = 'https://jsonplaceholder.typicode.com' 6 | 7 | // A mirror automatically fetches data as it is required, and purges it 8 | // once it is no longer in use. 9 | const api = createMirror(async url => { 10 | let response = await fetch(BaseURL + url) 11 | return response.json() 12 | }) 13 | 14 | function Screen({ id }) { 15 | // useSnapshot returns your data, loading status, etc. 16 | let snapshot = useSnapshot(api.key(`/todos/${id}`)) 17 | 18 | const handleToggle = async () => { 19 | let request = await fetch(BaseURL + `/todos/${id}`, { 20 | method: 'PATCH', 21 | body: JSON.stringify({ 22 | completed: !snapshot.data.completed, 23 | }), 24 | headers: { 25 | 'Content-type': 'application/json; charset=UTF-8', 26 | }, 27 | }) 28 | snapshot.update(await request.json()) 29 | } 30 | 31 | return ( 32 | 40 | ) 41 | } 42 | 43 | function App() { 44 | let [knownKeys, setKnownKeys] = useState(api.knownKeys().join(',')) 45 | let [id, setId] = useState(1) 46 | 47 | // Keep re-rendering the known keys 48 | useEffect(() => { 49 | let interval = setInterval(() => { 50 | setKnownKeys(api.knownKeys().join(',')) 51 | }, 1000) 52 | return () => { 53 | clearInterval(interval) 54 | } 55 | }, []) 56 | 57 | return ( 58 |
59 |

Todos

60 |

61 | id:{' '} 62 | setId(parseInt(event.target.value || 0))} 65 | /> 66 |

67 | {/* 68 | Make sure to wrap your any component that use `useSnapshot()` 69 | with a tag. 70 | */} 71 | Loading
}> 72 | 73 |
74 |

cached keys: {knownKeys}

75 | 76 | ) 77 | } 78 | 79 | export default App 80 | -------------------------------------------------------------------------------- /examples/todos/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /examples/todos/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /examples/todos/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/todos/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zen", 3 | "version": "0.2.0", 4 | "description": "A collection of simple hooks, components and functions for React", 5 | "author": "James K Nelson ", 6 | "license": "MIT", 7 | "main": "dist/commonjs/index.js", 8 | "module": "dist/es/index.js", 9 | "types": "dist/types/index.d.ts", 10 | "scripts": { 11 | "clean": "rimraf dist", 12 | "build:commonjs": "tsc --pretty --module commonjs --outDir dist/commonjs", 13 | "build:es": "tsc --pretty --module es2015 --outDir dist/es", 14 | "build:types": "tsc --pretty --declaration --emitDeclarationOnly --outDir dist/types --isolatedModules false", 15 | "build:umd": "tsc --pretty --declaration --module es2015 --outDir dist/umd-intermediate && cross-env NODE_ENV=development rollup -c -o dist/umd/navi.js && rimraf dist/umd-intermediate", 16 | "build:umd:min": "tsc --pretty --declaration --module es2015 --outDir dist/umd-intermediate && cross-env NODE_ENV=production rollup -c -o dist/umd/navi.min.js && rimraf dist/umd-intermediate", 17 | "build": "yarn run clean && yarn build:es && yarn build:commonjs && yarn build:types && yarn build:umd && yarn build:umd:min", 18 | "build:watch": "yarn run clean && yarn build:es -- --types --watch", 19 | "prepare": "yarn test && yarn build", 20 | "test": "jest", 21 | "test:watch": "jest --watch" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^24.0.19", 25 | "@types/node": "^12.11.7", 26 | "@types/react": "^16.9.11", 27 | "@types/react-dom": "^16.9.3", 28 | "cross-env": "^6.0.3", 29 | "eslint-config-react-app": "^5.0.2", 30 | "jest": "^24.9.0", 31 | "react": "^16.11.0", 32 | "react-dom": "^16.11.0", 33 | "react-test-renderer": "^16.11.0", 34 | "rimraf": "^3.0.0", 35 | "rollup": "^1.25.2", 36 | "rollup-plugin-commonjs": "^10.1.0", 37 | "rollup-plugin-node-resolve": "^5.2.0", 38 | "rollup-plugin-replace": "^2.2.0", 39 | "rollup-plugin-terser": "^5.1.2", 40 | "ts-jest": "^24.1.0", 41 | "typescript": "^3.6.4" 42 | }, 43 | "peerDependencies": { 44 | "react": "^16.11.0" 45 | }, 46 | "files": [ 47 | "dist" 48 | ], 49 | "keywords": [ 50 | "react", 51 | "zen", 52 | "data" 53 | ], 54 | "jest": { 55 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 56 | "testEnvironment": "jsdom", 57 | "moduleFileExtensions": [ 58 | "js", 59 | "json", 60 | "jsx", 61 | "ts", 62 | "tsx" 63 | ], 64 | "preset": "ts-jest", 65 | "testMatch": null, 66 | "globals": { 67 | "ts-jest": { 68 | "babelConfig": null, 69 | "diagnostics": false 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is based on the rollup config from Redux 3 | * Copyright (c) 2015-present Dan Abramov 4 | */ 5 | 6 | import nodeResolve from 'rollup-plugin-node-resolve' 7 | import commonjs from 'rollup-plugin-commonjs' 8 | import replace from 'rollup-plugin-replace' 9 | import { terser } from 'rollup-plugin-terser' 10 | 11 | const env = process.env.NODE_ENV 12 | const config = { 13 | external: ['react'], 14 | input: 'dist/umd-intermediate/index.js', 15 | output: { 16 | format: 'umd', 17 | globals: { 18 | react: 'React', 19 | }, 20 | name: 'ReactZenData', 21 | }, 22 | onwarn: function(warning) { 23 | // Suppress warning caused by TypeScript classes using "this" 24 | // https://github.com/rollup/rollup/wiki/Troubleshooting#this-is-undefined 25 | if (warning.code === 'THIS_IS_UNDEFINED') { 26 | return 27 | } 28 | console.error(warning.message) 29 | }, 30 | plugins: [ 31 | nodeResolve({ 32 | jsnext: true, 33 | main: true, 34 | }), 35 | 36 | commonjs(), 37 | 38 | replace({ 39 | 'process.env.NODE_ENV': JSON.stringify(env), 40 | }), 41 | ], 42 | } 43 | 44 | if (env === 'production') { 45 | config.plugins.push(terser()) 46 | } 47 | 48 | export default config 49 | -------------------------------------------------------------------------------- /src/Mirror.ts: -------------------------------------------------------------------------------- 1 | import { MirrorDocumentHandle, MirrorDocumentListHandle } from './MirrorHandles' 2 | import { MirrorDocumentSnapshot } from './MirrorSnapshots' 3 | 4 | export interface Mirror 5 | extends NamespacedMirror { 6 | /** 7 | * Returns a namespace to hold data associated with the specified context. 8 | */ 9 | namespace(context: Context): NamespacedMirror 10 | } 11 | 12 | export interface NamespacedMirror { 13 | context: Context 14 | 15 | /** 16 | * Allows the current state for a given set of context to be extracted 17 | * so that it can be sent with a server-rendered page, and then 18 | * hydrated on the client. 19 | */ 20 | extractState(): any 21 | 22 | /** 23 | * Call this with the state returned from `serializeState()` to hydrate the 24 | * store with data fetched on the server. 25 | */ 26 | hydrateFromState(extractedState: any): void 27 | 28 | /** 29 | * Return a handle for the specified key, from which you can get 30 | * and subscribe to its value. 31 | */ 32 | key(key: Key): MirrorDocumentHandle 33 | 34 | /** 35 | * Return a handle for an array of keys, from which you can get 36 | * and subscribe to multiple values at once. 37 | */ 38 | keys(keys: Key[]): MirrorDocumentListHandle 39 | 40 | /** 41 | * Return a list of the keys currently stored in the mirror for the given 42 | * deps array. 43 | */ 44 | knownKeys(): Key[] 45 | 46 | /** 47 | * Immediately purges all cached data for this context. 48 | * 49 | * Note that even if `purge` is *not* called, data will usually be cleaned up 50 | * after it no longer has any subscriptions -- depending on how schedulePurge 51 | * is configured. 52 | * 53 | * This is useful for server-side rendering, where once the request is done, 54 | * you probably don't want any of the data to stick around. 55 | */ 56 | purge(): void 57 | } 58 | 59 | export interface MirrorOptions { 60 | /** 61 | * An optional function for computing string keys from mirror keys, 62 | * which is required as documents are stored with string keys internally. 63 | * 64 | * By default, this uses JSON.stringify() 65 | */ 66 | computeHashForKey: (key: Key) => string 67 | 68 | /** 69 | * If provided, this will be called whenever the latest snapshot changes. 70 | * Then, if a function is returned, it will be called after the snapshot 71 | * changes again, or after the data is purged. 72 | * 73 | * Use this function to perform side effects based on the currently stored 74 | * data. 75 | * 76 | * For example, if this mirror contains lists of keys, you could create an 77 | * effect to hold those keys within another mirror. Similarly, if this 78 | * mirror contains items that are indexed in another mirror, you coudl use 79 | * an effect to invalidate indexes as the indexed items change. 80 | */ 81 | effect?: MirrorEffectFunction 82 | 83 | /** 84 | * This function is called by the mirror to fetch data once a subscription 85 | * has been made to the specified key. 86 | * 87 | * At minimum, the function should return the data associated with the 88 | * specified key, or `null` if there is no data associated with the given 89 | * key. 90 | * 91 | * If nested data is also available in the response, it can be stored by 92 | * calling `store` or `update` on this or other mirror namespaces. 93 | */ 94 | fetch?: MirrorFetchFunction 95 | 96 | // If supplied, this will be used instead of fetch, and can fetch multiple 97 | // keys in a single call. 98 | fetchMany?: MirrorFetchManyFunction 99 | 100 | /** 101 | * Configures how to purge data when there are no longer any active 102 | * subscriptions. 103 | * 104 | * If a number is given, data will be purged that many milliseconds after 105 | * it is no longer required. 106 | * 107 | * If a function is given, it'll be called with a purge function that should 108 | * be called once the data should be purged. This function should also 109 | * return a function which can be called to *cancel* a purge, should the 110 | * data become required before the purge takes place. 111 | */ 112 | schedulePurge: number | MirrorPurgeScheduler 113 | } 114 | 115 | export type MirrorCancelScheduledPurgeFunction = () => void 116 | 117 | export type MirrorCleanupEffectFunction = () => void 118 | 119 | export type MirrorEffectFunction = ( 120 | snapshot: MirrorDocumentSnapshot, 121 | context: Context, 122 | ) => MirrorCleanupEffectFunction 123 | 124 | export type MirrorFetchFunction = ( 125 | key: Key, 126 | context: Context, 127 | mirror: NamespacedMirror, 128 | ) => Promise 129 | 130 | export type MirrorFetchManyFunction = ( 131 | key: Key[], 132 | context: Context, 133 | mirror: NamespacedMirror, 134 | ) => Promise 135 | 136 | export type MirrorPurgeScheduler = ( 137 | purge: () => void, 138 | snapshot: MirrorDocumentSnapshot, 139 | context: Context, 140 | ) => MirrorCancelScheduledPurgeFunction 141 | -------------------------------------------------------------------------------- /src/MirrorActions.tsx: -------------------------------------------------------------------------------- 1 | export type MirrorCancelHoldFunction = () => void 2 | export type MirrorUpdaterCallback = ( 3 | data: UpdateData, 4 | key: Key, 5 | ) => UpdateData 6 | 7 | export interface MirrorActions { 8 | /** 9 | * Instructs the mirror to keep any snapshots of this key, even when there 10 | * is no active subscription. 11 | * 12 | * Note that holding a key will not actively fetch its contents. 13 | */ 14 | hold(): MirrorCancelHoldFunction 15 | 16 | /** 17 | * Marks this key's currently stored snapshot as invalid. 18 | * 19 | * If there's an active subscription, a new version of the data will be 20 | * fetched if possible. 21 | */ 22 | invalidate(): void 23 | 24 | /** 25 | * Indicate that you expect the snapshot for this key to change in the near 26 | * future. 27 | * 28 | * This prevents any fetches from taking place, sets the `pending` 29 | * indicator to `true`, and if an updater is provided, sets a temporary 30 | * value for `data` from until the returned promise resolves. If the 31 | * returned promise is rejected, the reason will be added to `failure`. 32 | */ 33 | predictUpdate( 34 | dataOrUpdater?: UpdateData | MirrorUpdaterCallback, 35 | ): Promise 36 | 37 | /** 38 | * Stores the given data. If there is no subscription for it, then the data 39 | * will be immediately scheduled for purge. 40 | * 41 | * In the case a function is given, if this key has a non-empty snapshot, 42 | * then the updater callback will be called and the returned value will be 43 | * set as the current data. If the key's snapshot is empty or is not yet 44 | * primed, then an error will be thrown. 45 | * 46 | * This will not change the `pending` status of your data. 47 | */ 48 | update( 49 | dataOrUpdater: UpdateData | MirrorUpdaterCallback, 50 | ): void 51 | } 52 | -------------------------------------------------------------------------------- /src/MirrorContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import { Mirror } from './Mirror' 3 | 4 | export interface MirrorContext { 5 | namespace: any 6 | namespaces: Map 7 | } 8 | 9 | export const MirrorContext = React.createContext({ 10 | namespace: null, 11 | namespaces: new Map(), 12 | }) 13 | 14 | export function MirrorProvider({ children, namespace, namespaces }) { 15 | let context = useMemo(() => ({ namespace, namespaces }), [ 16 | namespace, 17 | namespaces, 18 | ]) 19 | 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /src/MirrorHandles.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MirrorDocumentSnapshot, 3 | MirrorPrimedDocumentSnapshot, 4 | MirrorSnapshot, 5 | MirrorPrimedSnapshot, 6 | } from './MirrorSnapshots' 7 | import { MirrorActions } from './MirrorActions' 8 | 9 | export type MirrorCancelHoldFunction = () => void 10 | export type MirrorSubscribeCallback = (output: Output) => void 11 | export type MirrorUnsubscribeFunction = () => void 12 | export type MirrorUpdaterCallback = (data: Data, key: Key) => Data 13 | 14 | export interface MirrorHandle< 15 | Key, 16 | UpdateData, 17 | Output extends MirrorSnapshot, 18 | PrimedOutput extends Output = Output 19 | > extends MirrorActions { 20 | key: Key 21 | 22 | /** 23 | * Returns a promise to a mirrored snapshot for this key. 24 | */ 25 | get(): Promise 26 | 27 | /** 28 | * Returns the latest snapshot for the given data. 29 | */ 30 | getLatest(): Output 31 | 32 | /** 33 | * Subscribe to updates to snapshots for the given key. Note that this will 34 | * not immediately emit a snapshot unless subscribing triggers a fetch, and 35 | * adds/updates a snapshot in the process. 36 | */ 37 | subscribe( 38 | callback: MirrorSubscribeCallback, 39 | ): MirrorUnsubscribeFunction 40 | } 41 | 42 | export interface MirrorDocumentHandle 43 | extends MirrorHandle< 44 | Key, 45 | Data, 46 | MirrorDocumentSnapshot, 47 | MirrorPrimedDocumentSnapshot 48 | > {} 49 | 50 | export interface MirrorDocumentListHandle 51 | extends MirrorHandle< 52 | Key[], 53 | Data[], 54 | MirrorSnapshot[], Key[]>, 55 | MirrorPrimedSnapshot[], Key[]> 56 | > {} 57 | 58 | export interface MirrorCollectionHandle 59 | extends MirrorHandle< 60 | Query, 61 | { key: Key; data: Data }[], 62 | MirrorDocumentSnapshot[], Query>, 63 | MirrorPrimedDocumentSnapshot< 64 | MirrorPrimedDocumentSnapshot[], 65 | Query 66 | > 67 | > {} 68 | -------------------------------------------------------------------------------- /src/MirrorSnapshots.ts: -------------------------------------------------------------------------------- 1 | export interface MirrorSnapshot { 2 | data?: Data 3 | 4 | /** 5 | * If a fetch failed or projection failed to materialize, this will hold 6 | * the time, and a reason (if one is supplied) that this occurred. 7 | * If a subsequent fetch succeeds or `receive` is manually called, this 8 | * will be removed. 9 | * 10 | * In cases where the data comes from multiple sources, this will contain 11 | * any current failure on any of the sources. 12 | */ 13 | failure: null | { 14 | at: number 15 | reason: any 16 | } 17 | 18 | /** 19 | * The document's primary key 20 | */ 21 | key: Key 22 | 23 | /** 24 | * Starts as false, and becomes true once `data` is set for the first time. 25 | * 26 | * In the case where the data comes from multiple sources, this will only be 27 | * primed if all sources are primed. 28 | */ 29 | primed: boolean 30 | } 31 | 32 | export interface MirrorPrimedSnapshot 33 | extends MirrorSnapshot { 34 | data: Data 35 | primed: true 36 | } 37 | 38 | export interface MirrorDocumentSnapshot 39 | extends MirrorSnapshot { 40 | /** 41 | * Set to true after `invalidate` has been called, and stays true until a 42 | * more recent version of the document is received. 43 | */ 44 | invalidated: boolean 45 | 46 | /** 47 | * Indicates that a fetch is currently taking place, is scheduled to take 48 | * place, or a change is projected to be received in the near future. 49 | */ 50 | pending: boolean 51 | 52 | /** 53 | * Stores the most recent time at which a full version of the document has 54 | * been received, or an update has been made. If nothing has been received 55 | * yet, this will be `null`. 56 | */ 57 | updatedAt: null | number 58 | } 59 | 60 | export interface MirrorPrimedDocumentSnapshot 61 | extends MirrorDocumentSnapshot { 62 | data: Data 63 | primed: true 64 | updatedAt: number 65 | } 66 | 67 | export interface MirrorCollectionSnapshot 68 | extends MirrorSnapshot, Query> { 69 | /** 70 | * Set to true after `invalidate` has been called, and stays true until a 71 | * more recent version of the full collection is received. 72 | */ 73 | invalidated: boolean 74 | 75 | /** 76 | * Indicates that a fetch for the full collection is taking place, or is 77 | * scheduled to take place. This does not become active when you're fetching 78 | * individual documents within the collection. 79 | */ 80 | pending: boolean 81 | 82 | /** 83 | * Stores the most recent time at which the full collection with index has 84 | * been received, or an update has been made. If nothing has been received 85 | * yet, this will be `null`. 86 | */ 87 | updatedAt: null | number 88 | } 89 | 90 | export interface MirrorPrimedCollectionSnapshot< 91 | Data = any, 92 | Key = any, 93 | Query = any 94 | > extends MirrorCollectionSnapshot { 95 | data: MirrorDocumentSnapshot 96 | primed: true 97 | updatedAt: number 98 | } 99 | -------------------------------------------------------------------------------- /src/NamespacedMirrorImplementation.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MirrorEffectFunction, 3 | MirrorFetchManyFunction, 4 | MirrorOptions, 5 | MirrorPurgeScheduler, 6 | NamespacedMirror, 7 | } from './Mirror' 8 | import { MirrorDocumentHandle, MirrorDocumentListHandle } from './MirrorHandles' 9 | import { 10 | MirrorSnapshot, 11 | MirrorPrimedSnapshot, 12 | MirrorDocumentSnapshot, 13 | MirrorPrimedDocumentSnapshot, 14 | } from './MirrorSnapshots' 15 | 16 | interface ScheduledPurge { 17 | canceller?: () => void 18 | } 19 | 20 | interface Subscription { 21 | hashes: string[] 22 | callback: (snapshots: MirrorDocumentSnapshot[]) => void 23 | } 24 | 25 | export default class NamespacedMirrorImplementation< 26 | Data, 27 | Key, 28 | Context extends object 29 | > implements NamespacedMirror { 30 | readonly context: Context 31 | 32 | _config: { 33 | computeHashForKey: (key: Key) => string 34 | effect?: MirrorEffectFunction 35 | fetchMany: MirrorFetchManyFunction 36 | schedulePurge: MirrorPurgeScheduler 37 | } 38 | _effectCleanups: { 39 | [hash: string]: () => void 40 | } 41 | _fetches: { 42 | [hash: string]: Promise 43 | } 44 | _holds: { 45 | [hash: string]: number 46 | } 47 | _scheduledPurges: { 48 | [hash: string]: ScheduledPurge 49 | } 50 | _subscriptions: { 51 | [hash: string]: Subscription[] 52 | } 53 | _snapshots: { 54 | [hash: string]: MirrorDocumentSnapshot 55 | } 56 | 57 | constructor(options: MirrorOptions, context: Context) { 58 | this.context = context 59 | 60 | let fetchMany: MirrorFetchManyFunction 61 | if (options.fetchMany) { 62 | fetchMany = options.fetchMany 63 | } else if (options.fetch) { 64 | // When not using nested collections, it's okay to fetchMany using 65 | // fetch. 66 | const fetch = options.fetch 67 | fetchMany = (keys: Key[]) => 68 | Promise.all(keys.map(key => fetch(key, context, this))) 69 | } else { 70 | fetchMany = async (keys: Key[]) => { 71 | // Wait for a small delay, then if the snapshot is still pending, 72 | // assume that the data ain't coming and throw an error. 73 | await new Promise(resolve => setTimeout(resolve)) 74 | const datas: Data[] = [] 75 | for (let snapshot of this._get(keys)) { 76 | if (snapshot === null || !snapshot.primed) { 77 | throw new Error(`Unable to fetch`) 78 | } else { 79 | datas.push(snapshot.data!) 80 | } 81 | } 82 | return datas 83 | } 84 | } 85 | 86 | this._config = { 87 | computeHashForKey: options.computeHashForKey, 88 | effect: options.effect, 89 | fetchMany, 90 | schedulePurge: 91 | typeof options.schedulePurge === 'number' 92 | ? createTimeoutPurgeScheduler(options.schedulePurge) 93 | : options.schedulePurge, 94 | } 95 | this._effectCleanups = {} 96 | this._fetches = {} 97 | this._holds = {} 98 | this._scheduledPurges = {} 99 | this._subscriptions = {} 100 | this._snapshots = {} 101 | } 102 | 103 | hydrateFromState(snapshots: any) { 104 | // TODO: can't just set state; need to actually update any subscriptions 105 | // on hydration. 106 | throw new Error('Unimplemented') 107 | } 108 | 109 | key(key: Key) { 110 | return new MirrorKeyHandle(this, key) 111 | } 112 | 113 | keys(keys: Key[]) { 114 | return new MirrorKeyListHandle(this, keys) 115 | } 116 | 117 | knownKeys() { 118 | return Object.values(this._snapshots).map(snapshot => snapshot.key) 119 | } 120 | 121 | purge() { 122 | // TODO: can't just clear state; need to actually update any subscriptions 123 | // on hydration. 124 | throw new Error('Unimplemented') 125 | } 126 | 127 | extractState(): any { 128 | return this._snapshots 129 | } 130 | 131 | _cancelScheduledPurge(hash: string) { 132 | const scheduledPurge = this._scheduledPurges[hash] 133 | if (scheduledPurge) { 134 | if (scheduledPurge.canceller) { 135 | scheduledPurge.canceller() 136 | } 137 | delete this._scheduledPurges[hash] 138 | } 139 | } 140 | 141 | _computeHashes(keys: Key[]) { 142 | return keys.map(this._config.computeHashForKey) 143 | } 144 | 145 | async _fetch(keys: Key[], hashes: string[]) { 146 | const pendingSnapshotsToStore: MirrorDocumentSnapshot[] = [] 147 | const keysToFetch: Key[] = [] 148 | const hashesToFetch: string[] = [] 149 | 150 | // Hold the pending snapshots while fetching 151 | const unhold = this._hold(hashes) 152 | 153 | for (let i = 0; i < keys.length; i++) { 154 | const key = keys[i] 155 | const hash = hashes[i] 156 | 157 | // If there's already a fetch for this key, then don't interrupt it. 158 | if (this._fetches[hash]) { 159 | continue 160 | } 161 | 162 | keysToFetch.push(key) 163 | hashesToFetch.push(hash) 164 | 165 | // Fetching will change `pending` to true, if it isn't already so. 166 | let snapshot = this._snapshots[hash] 167 | if (!snapshot || !snapshot.pending) { 168 | snapshot = getPendingSnapshot(key, snapshot) 169 | pendingSnapshotsToStore.push(snapshot) 170 | } 171 | } 172 | 173 | // Store any pending statuses 174 | this._store(pendingSnapshotsToStore) 175 | 176 | if (keysToFetch.length > 0) { 177 | let updatedSnapshots: MirrorDocumentSnapshot[] = [] 178 | try { 179 | const promise = this._config.fetchMany(keysToFetch, this.context, this) 180 | for (let i = 0; i < hashesToFetch.length; i++) { 181 | this._fetches[hashesToFetch[i]] = promise 182 | } 183 | 184 | // Careful, this could fail -- and the fetches need to be cleaned up in either case. 185 | const datas = await promise 186 | 187 | const updatedAt = Date.now() 188 | for (let i = 0; i < hashesToFetch.length; i++) { 189 | delete this._fetches[hashesToFetch[i]] 190 | updatedSnapshots.push( 191 | getUpdatedSnapshot(keysToFetch[i], datas[i], false, updatedAt), 192 | ) 193 | } 194 | } catch (reason) { 195 | const failedAt = Date.now() 196 | for (let i = 0; i < hashesToFetch.length; i++) { 197 | const hash = hashesToFetch[i] 198 | delete this._fetches[hash] 199 | updatedSnapshots.push( 200 | getFailureSnapshot(this._snapshots[hash], reason, failedAt), 201 | ) 202 | } 203 | } 204 | 205 | this._store(updatedSnapshots) 206 | } 207 | 208 | unhold() 209 | } 210 | 211 | _get(keys: Key[]): (MirrorDocumentSnapshot | null)[] { 212 | return keys.map( 213 | key => this._snapshots[this._config.computeHashForKey(key)] || null, 214 | ) 215 | } 216 | 217 | _hold(hashes: string[]): () => void { 218 | for (let i = 0; i < hashes.length; i++) { 219 | // Cancel any scheduled purges of the given keys. 220 | const hash = hashes[i] 221 | this._cancelScheduledPurge(hash) 222 | 223 | // Update hold count 224 | let currentHoldCount = this._holds[hash] || 0 225 | this._holds[hash] = currentHoldCount + 1 226 | } 227 | 228 | return () => { 229 | const hashesToSchedulePurge: string[] = [] 230 | for (let i = 0; i < hashes.length; i++) { 231 | const hash = hashes![i] 232 | const currentHoldCount = this._holds[hash] 233 | if (currentHoldCount > 1) { 234 | this._holds[hash] = currentHoldCount - 1 235 | } else { 236 | delete this._holds[hash] 237 | hashesToSchedulePurge.push(hash) 238 | } 239 | } 240 | this._schedulePurge(hashesToSchedulePurge) 241 | } 242 | } 243 | 244 | _invalidate(keys: Key[]): void { 245 | const hashes = this._computeHashes(keys) 246 | const snapshotsToStore: MirrorDocumentSnapshot[] = [] 247 | const keysToFetch: Key[] = [] 248 | const hashesToFetch: string[] = [] 249 | for (let i = 0; i < hashes.length; i++) { 250 | const key = keys[i] 251 | const hash = hashes[i] 252 | const snapshot = this._snapshots[hash] 253 | 254 | // If the has doesn't exist or is already invalid, then there's no need 255 | // to touch it. 256 | if (!snapshot || snapshot.invalidated) { 257 | continue 258 | } 259 | 260 | snapshotsToStore.push({ 261 | ...snapshot, 262 | invalidated: true, 263 | }) 264 | 265 | // If the hash has an active subscription, then we'll want to initiate a 266 | // fetch. We don't worry about held-but-not-subscribed keys, as if a 267 | // subscription is made, then invalidated snapshots will be fetched then. 268 | if (this._subscriptions[hash]) { 269 | keysToFetch.push(key) 270 | hashesToFetch.push(hash) 271 | } 272 | } 273 | 274 | // TODO: This can result in a double update. 275 | this._store(snapshotsToStore) 276 | if (keysToFetch.length) { 277 | this._fetch(keysToFetch, hashesToFetch) 278 | } 279 | } 280 | 281 | _performScheduledPurge(hash: string) { 282 | // Wait a tick to avoid purging something that gets held again in the 283 | // next tick. 284 | Promise.resolve().then(() => { 285 | const scheduledPurge = this._scheduledPurges[hash] 286 | if (scheduledPurge) { 287 | // Clean up effects 288 | const effectCleanup = this._effectCleanups[hash] 289 | if (effectCleanup) { 290 | effectCleanup() 291 | delete this._effectCleanups[hash] 292 | } 293 | 294 | // Remove stored snapshot data, and remove the scheduled purge 295 | delete this._snapshots[hash] 296 | delete this._scheduledPurges[hash] 297 | } 298 | }) 299 | } 300 | 301 | _schedulePurge(hashes: string[]) { 302 | // Schedule purges individually, as purging, scheduling purges and 303 | // cancelling scheduled purges will never result in UI updates, and thus 304 | // they don't need batching -- but there *are* situations where we'll need 305 | // to cancel individual purges from the group. 306 | for (let i = 0; i < hashes.length; i++) { 307 | const hash = hashes[i] 308 | const snapshot = this._snapshots[hash] 309 | 310 | if (this._scheduledPurges[hash] || !snapshot) { 311 | continue 312 | } 313 | 314 | const scheduledPurge: ScheduledPurge = {} 315 | const purge = this._performScheduledPurge.bind(this, hash) 316 | this._scheduledPurges[hash] = scheduledPurge 317 | scheduledPurge.canceller = this._config.schedulePurge( 318 | purge, 319 | snapshot, 320 | this.context, 321 | ) 322 | } 323 | } 324 | 325 | _store(snapshots: MirrorDocumentSnapshot[]): void { 326 | const hashes: string[] = [] 327 | const hashesToSchedulePurge: string[] = [] 328 | const updatedSubscriptions: Set> = new Set() 329 | 330 | for (let i = 0; i < snapshots.length; i++) { 331 | const snapshot = snapshots[i] 332 | const hash = this._config.computeHashForKey(snapshot.key) 333 | 334 | hashes.push(hash) 335 | 336 | // Update the stored snapshot 337 | this._snapshots[hash] = snapshot 338 | 339 | // Mark down any subscriptions which need to be run, to run them 340 | // all in a single update 341 | let subscriptions = this._subscriptions[hash] 342 | if (subscriptions) { 343 | subscriptions.forEach(subscription => 344 | updatedSubscriptions.add(subscription), 345 | ) 346 | } 347 | 348 | // If this snapshot isn't held, schedule a purge 349 | if (!this._holds[hash]) { 350 | hashesToSchedulePurge.push(hash) 351 | } 352 | } 353 | 354 | // Notify subscribers in a single update 355 | let subscriptions = Array.from(updatedSubscriptions.values()) 356 | for (let i = 0; i < subscriptions.length; i++) { 357 | const { hashes, callback } = subscriptions[i] 358 | callback(hashes.map(hash => this._snapshots[hash])) 359 | } 360 | 361 | // Run effects, and clean up any previously run effects 362 | const effect = this._config.effect 363 | if (effect) { 364 | for (let i = 0; i < hashes.length; i++) { 365 | const hash = hashes[i] 366 | 367 | // Get latest snapshot values in case they've changed within the 368 | // subscription callbacks 369 | const snapshot = this._snapshots[hash] 370 | 371 | const previousCleanup = this._effectCleanups[hash] 372 | this._effectCleanups[hash] = effect(snapshot, this.context) 373 | previousCleanup() 374 | } 375 | } 376 | 377 | if (hashesToSchedulePurge.length) { 378 | this._schedulePurge(hashesToSchedulePurge) 379 | } 380 | } 381 | 382 | _subscribe( 383 | keys: Key[], 384 | callback: (snapshots: MirrorDocumentSnapshot[]) => void, 385 | ): () => void { 386 | const keysToFetch: Key[] = [] 387 | const hashes: string[] = this._computeHashes(keys) 388 | const hashesToFetch: string[] = [] 389 | 390 | const unhold = this._hold(hashes) 391 | 392 | const subscription = { 393 | hashes, 394 | callback, 395 | } 396 | 397 | for (let i = 0; i < keys.length; i++) { 398 | const key = keys[i] 399 | const hash = hashes[i] 400 | 401 | // Add the same subscription object for every key's hash. This will create 402 | // duplicates, but they can be easily deduped when changes actually occur. 403 | let subscriptions = this._subscriptions[hash] 404 | if (!subscriptions) { 405 | this._subscriptions[hash] = subscriptions = [] 406 | } 407 | subscriptions.push(subscription) 408 | 409 | // If there's no snapshot, or a pending snapshot with no fetch, then 410 | // add this id to the list of ids to fetch. 411 | if (!this._fetches[hash]) { 412 | const snapshot = this._snapshots[hash] 413 | if (!snapshot || snapshot.pending || snapshot.invalidated) { 414 | keysToFetch.push(key) 415 | hashesToFetch.push(hash) 416 | } 417 | } 418 | } 419 | 420 | if (keysToFetch.length) { 421 | this._fetch(keysToFetch, hashesToFetch) 422 | } 423 | 424 | return () => { 425 | // Remove subscriptions 426 | for (let i = 0; i < hashes.length; i++) { 427 | const hash = hashes[i] 428 | const subscriptions = this._subscriptions[hash] 429 | if (subscriptions.length === 1) { 430 | delete this._subscriptions[hash] 431 | } else { 432 | subscriptions.splice(subscriptions.indexOf(subscription), 1) 433 | } 434 | } 435 | 436 | unhold() 437 | } 438 | } 439 | } 440 | 441 | class MirrorKeyHandle implements MirrorDocumentHandle { 442 | readonly key: Key 443 | readonly impl: NamespacedMirrorImplementation 444 | 445 | constructor(impl: NamespacedMirrorImplementation, key: Key) { 446 | this.key = key 447 | this.impl = impl 448 | } 449 | 450 | getLatest() { 451 | let snapshot = this.impl._get([this.key])[0] 452 | if (snapshot === null) { 453 | snapshot = getInitialSnapshot(this.key) 454 | this.impl._store([snapshot]) 455 | } 456 | return snapshot 457 | } 458 | 459 | get(): Promise> { 460 | const currentSnapshot = this.impl._get([this.key])[0] 461 | if (currentSnapshot && currentSnapshot.primed) { 462 | return Promise.resolve(currentSnapshot as MirrorPrimedDocumentSnapshot< 463 | Data, 464 | Key 465 | >) 466 | } else { 467 | return new Promise>( 468 | (resolve, reject) => { 469 | const unsubscribe = this.impl._subscribe([this.key], ([snapshot]) => { 470 | if (!snapshot.pending) { 471 | unsubscribe() 472 | if (snapshot.primed) { 473 | resolve(snapshot as MirrorPrimedDocumentSnapshot) 474 | } else { 475 | reject(snapshot.failure && snapshot.failure.reason) 476 | } 477 | } 478 | }) 479 | }, 480 | ) 481 | } 482 | } 483 | 484 | hold() { 485 | return this.impl._hold(this.impl._computeHashes([this.key])) 486 | } 487 | 488 | invalidate() { 489 | this.impl._invalidate([this.key]) 490 | } 491 | 492 | async predictUpdate(dataOrUpdater) { 493 | // TODO: 494 | // - should be possible to store predicted state for a short period of 495 | // time, setting a flag noting that the state is just a prediction. 496 | throw new Error('Unimplemented') 497 | } 498 | 499 | subscribe(callback) { 500 | return this.impl._subscribe([this.key], ([snapshot]) => { 501 | callback(snapshot) 502 | }) 503 | } 504 | 505 | update(dataOrUpdater) { 506 | let data: Data 507 | let [currentSnapshot] = this.impl._get([this.key]) 508 | if (typeof dataOrUpdater === 'function') { 509 | if (!currentSnapshot || !currentSnapshot.primed) { 510 | throw new MissingDataError() 511 | } 512 | data = dataOrUpdater(currentSnapshot.data!) 513 | } else { 514 | data = dataOrUpdater 515 | } 516 | 517 | this.impl._store([ 518 | getUpdatedSnapshot( 519 | this.key, 520 | data, 521 | currentSnapshot ? currentSnapshot.pending : false, 522 | Date.now(), 523 | ), 524 | ]) 525 | } 526 | } 527 | 528 | class MirrorKeyListHandle 529 | implements MirrorDocumentListHandle { 530 | readonly key: Key[] 531 | readonly impl: NamespacedMirrorImplementation 532 | 533 | constructor( 534 | impl: NamespacedMirrorImplementation, 535 | key: Key[], 536 | ) { 537 | this.key = key 538 | this.impl = impl 539 | } 540 | 541 | getLatest() { 542 | const snapshotsToStore: MirrorDocumentSnapshot[] = [] 543 | const maybeSnapshots = this.impl._get(this.key) 544 | const snapshots: MirrorDocumentSnapshot[] = [] 545 | let primed = true 546 | for (let i = 0; i < maybeSnapshots.length; i++) { 547 | const key = this.key[i] 548 | let snapshot = maybeSnapshots[i] 549 | if (snapshot === null) { 550 | snapshot = getInitialSnapshot(key) 551 | snapshotsToStore.push(snapshot) 552 | primed = false 553 | } else if (!snapshot.primed) { 554 | primed = false 555 | } 556 | snapshots.push(snapshot!) 557 | } 558 | if (snapshotsToStore.length) { 559 | this.impl._store(snapshotsToStore) 560 | } 561 | return getListSnapshot(this.key, snapshots, primed) 562 | } 563 | 564 | get(): Promise< 565 | MirrorPrimedSnapshot[], Key[]> 566 | > { 567 | const maybeSnapshots = this.impl._get(this.key) 568 | const primed = maybeSnapshots.every(snapshot => snapshot && snapshot.primed) 569 | if (primed) { 570 | return Promise.resolve(getListSnapshot( 571 | this.key, 572 | maybeSnapshots as MirrorDocumentSnapshot[], 573 | true, 574 | ) as MirrorPrimedSnapshot[], Key[]>) 575 | } else { 576 | return new Promise< 577 | MirrorPrimedSnapshot[], Key[]> 578 | >((resolve, reject) => { 579 | const unsubscribe = this.impl._subscribe(this.key, snapshots => { 580 | const pending = snapshots.some(snapshot => snapshot.pending) 581 | if (!pending) { 582 | const primed = snapshots.every( 583 | snapshot => snapshot && snapshot.primed, 584 | ) 585 | const listSnapshot = getListSnapshot(this.key, snapshots, primed) 586 | unsubscribe() 587 | if (primed) { 588 | resolve(listSnapshot as MirrorPrimedSnapshot< 589 | MirrorDocumentSnapshot[], 590 | Key[] 591 | >) 592 | } else { 593 | reject(listSnapshot.failure && listSnapshot.failure.reason) 594 | } 595 | } 596 | }) 597 | }) 598 | } 599 | } 600 | 601 | hold() { 602 | return this.impl._hold(this.impl._computeHashes(this.key)) 603 | } 604 | 605 | invalidate() { 606 | this.impl._invalidate(this.key) 607 | } 608 | 609 | async predictUpdate(dataOrUpdater) { 610 | // TODO: 611 | // - should be possible to store predicted state for a short period of 612 | // time, setting a flag noting that the state is just a prediction. 613 | throw new Error('Unimplemented') 614 | } 615 | 616 | subscribe(callback) { 617 | return this.impl._subscribe(this.key, snapshots => { 618 | callback( 619 | getListSnapshot( 620 | this.key, 621 | snapshots, 622 | snapshots.every(snapshot => snapshot.primed), 623 | ), 624 | ) 625 | }) 626 | } 627 | 628 | update(dataOrUpdater) { 629 | let data: Data[] 630 | let maybeSnapshots = this.impl._get(this.key) 631 | if (typeof dataOrUpdater === 'function') { 632 | if (maybeSnapshots.some(snapshot => !snapshot || !snapshot.primed)) { 633 | throw new MissingDataError() 634 | } 635 | data = dataOrUpdater(maybeSnapshots.map(snapshot => snapshot!.data)) 636 | } else { 637 | data = dataOrUpdater 638 | } 639 | 640 | const updateTime = Date.now() 641 | this.impl._store( 642 | maybeSnapshots.map((snapshot, i) => 643 | getUpdatedSnapshot( 644 | this.key[i], 645 | data[i], 646 | snapshot ? snapshot.pending : false, 647 | updateTime, 648 | ), 649 | ), 650 | ) 651 | } 652 | } 653 | 654 | function getListSnapshot( 655 | key: Key[], 656 | snapshots: MirrorDocumentSnapshot[], 657 | primed: boolean, 658 | ): MirrorSnapshot[], Key[]> { 659 | const failedSnapshot = snapshots.find(snapshot => snapshot.failure) 660 | return { 661 | data: snapshots, 662 | key, 663 | primed, 664 | failure: failedSnapshot ? failedSnapshot.failure : null, 665 | } 666 | } 667 | 668 | function getFailureSnapshot( 669 | currentSnapshot: MirrorDocumentSnapshot, 670 | reason: any, 671 | failedAt: number, 672 | ): MirrorDocumentSnapshot { 673 | return { 674 | ...currentSnapshot, 675 | failure: { 676 | reason, 677 | at: failedAt, 678 | }, 679 | pending: false, 680 | } 681 | } 682 | 683 | function getInitialSnapshot(key: Key): MirrorDocumentSnapshot { 684 | return { 685 | data: undefined, 686 | failure: null, 687 | key, 688 | invalidated: false, 689 | pending: true, 690 | primed: false, 691 | updatedAt: null, 692 | } 693 | } 694 | 695 | function getPendingSnapshot( 696 | key: Key, 697 | currentSnapshot: null | MirrorDocumentSnapshot, 698 | ): MirrorDocumentSnapshot { 699 | if (!currentSnapshot) { 700 | return getInitialSnapshot(key) 701 | } else if (!currentSnapshot.pending) { 702 | return { 703 | ...currentSnapshot, 704 | pending: true, 705 | } 706 | } 707 | return currentSnapshot 708 | } 709 | 710 | function getUpdatedSnapshot( 711 | key: Key, 712 | data: Data, 713 | pending: boolean, 714 | updatedAt: number, 715 | ): MirrorDocumentSnapshot { 716 | return { 717 | data, 718 | failure: null, 719 | key, 720 | invalidated: false, 721 | pending, 722 | primed: true, 723 | updatedAt, 724 | } 725 | } 726 | 727 | function createTimeoutPurgeScheduler( 728 | milliseconds: number, 729 | ): MirrorPurgeScheduler { 730 | return (purge: () => void) => { 731 | const timeout = setTimeout(purge, milliseconds) 732 | return () => { 733 | clearTimeout(timeout) 734 | } 735 | } 736 | } 737 | 738 | class MissingDataError extends Error {} 739 | -------------------------------------------------------------------------------- /src/createMirror.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Mirror, 3 | MirrorFetchFunction, 4 | MirrorOptions, 5 | MirrorPurgeScheduler, 6 | NamespacedMirror, 7 | } from './Mirror' 8 | import { MirrorDocumentSnapshot } from './MirrorSnapshots' 9 | import NamespacedMirrorImplementation from './NamespacedMirrorImplementation' 10 | 11 | class MirrorImplementation< 12 | Data, 13 | Key, 14 | Context extends object 15 | > extends NamespacedMirrorImplementation { 16 | _namespaces: WeakMap> 17 | _options: MirrorOptions 18 | 19 | constructor(options: MirrorOptions) { 20 | super(options, {}) 21 | 22 | this._namespaces = new WeakMap() 23 | this._options = options 24 | } 25 | 26 | namespace(context: Context) { 27 | let namespace = this._namespaces.get(context) 28 | if (!namespace) { 29 | namespace = new NamespacedMirrorImplementation(this._options, context) 30 | this._namespaces.set(context, namespace) 31 | } 32 | return namespace 33 | } 34 | } 35 | 36 | interface CreateMirror { 37 | ( 38 | options: Partial>, 39 | ): Mirror 40 | ( 41 | options: MirrorFetchFunction, 42 | ): Mirror 43 | 44 | defaultComputeHashForKey: (key: any) => string 45 | defaultSchedulePurge: number | MirrorPurgeScheduler 46 | } 47 | 48 | /** 49 | * Create a new Mirror from the specified options. 50 | */ 51 | const createMirror: CreateMirror = function createMirror< 52 | Data, 53 | Key, 54 | Context extends object 55 | >( 56 | options: 57 | | Partial> 58 | | MirrorFetchFunction, 59 | ): Mirror { 60 | if (typeof options === 'function') { 61 | options = { 62 | fetch: options, 63 | } 64 | } 65 | 66 | return new MirrorImplementation({ 67 | computeHashForKey: createMirror.defaultComputeHashForKey, 68 | schedulePurge: createMirror.defaultSchedulePurge, 69 | 70 | ...options, 71 | }) 72 | } 73 | 74 | createMirror.defaultComputeHashForKey = (key: any) => 75 | typeof key === 'string' ? key : JSON.stringify(key) 76 | 77 | createMirror.defaultSchedulePurge = 1000 78 | 79 | export { createMirror } 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createMirror' 2 | export * from './Mirror' 3 | export * from './MirrorContext' 4 | export * from './MirrorHandles' 5 | export * from './MirrorSnapshots' 6 | export * from './MirrorSnapshots' 7 | export * from './useDocument' 8 | export * from './useSnapshot' 9 | -------------------------------------------------------------------------------- /src/useDocument.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Mirror } from './Mirror' 3 | import { MirrorContext } from './MirrorContext' 4 | import { useSnapshot, UseSnapshotOptions } from './useSnapshot' 5 | 6 | export function useDocument( 7 | mirror: Mirror, 8 | key: Key, 9 | options: UseSnapshotOptions = {}, 10 | ) { 11 | const mirrorContext = useContext(MirrorContext) 12 | const namespacedMirror = getNamespacedMirror(mirrorContext, mirror) 13 | const handle = namespacedMirror.key(key) 14 | return useSnapshot(handle, options) 15 | } 16 | 17 | function getNamespacedMirror( 18 | context: MirrorContext, 19 | mirror: Mirror, 20 | ) { 21 | const override = context.namespaces.get(mirror) 22 | if (override) { 23 | return mirror.namespace(override) 24 | } 25 | if (context.namespace) { 26 | return mirror.namespace(context.namespace) 27 | } 28 | return mirror 29 | } 30 | -------------------------------------------------------------------------------- /src/useSnapshot.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react' 2 | import { MirrorActions } from './MirrorActions' 3 | import { MirrorHandle } from './MirrorHandles' 4 | import { MirrorSnapshot } from './MirrorSnapshots' 5 | 6 | export interface UseSnapshotOptions { 7 | suspend?: boolean 8 | throwFailures?: boolean 9 | } 10 | 11 | useSnapshot.defaultOptions = { 12 | suspend: true, 13 | throwFailures: false, 14 | } as UseSnapshotOptions 15 | 16 | export function useSnapshot< 17 | Output extends MirrorSnapshot, 18 | UpdateData, 19 | Key, 20 | Handle extends MirrorHandle 21 | >( 22 | handle: MirrorHandle, 23 | options: UseSnapshotOptions = {}, 24 | ): Output & MirrorActions { 25 | const { 26 | suspend = useSnapshot.defaultOptions.suspend, 27 | throwFailures = useSnapshot.defaultOptions.throwFailures, 28 | } = options 29 | let [snapshot, setSnapshot] = useState(handle.getLatest()) 30 | 31 | // If the key changes, we want to immediately reflect the new key 32 | if (snapshot.key != handle.key) { 33 | snapshot = handle.getLatest() 34 | } 35 | 36 | if (!snapshot.primed) { 37 | if (suspend) { 38 | throw handle.get() 39 | } 40 | } else if (throwFailures && snapshot.failure) { 41 | throw snapshot.failure.reason 42 | } 43 | 44 | useEffect(() => handle.subscribe(setSnapshot)) 45 | 46 | const extendedSnapshot = useMemo( 47 | () => ({ 48 | ...snapshot, 49 | 50 | hold: handle.hold.bind(handle), 51 | invalidate: handle.invalidate.bind(handle), 52 | predictUpdate: handle.predictUpdate.bind(handle), 53 | update: handle.update.bind(handle), 54 | }), 55 | [snapshot], 56 | ) 57 | 58 | return extendedSnapshot 59 | } 60 | -------------------------------------------------------------------------------- /test/MirrorKeyHandle.test.tsx: -------------------------------------------------------------------------------- 1 | import { createMirror } from '../src' 2 | 3 | describe('MirrorKeyHandle', () => { 4 | test('supports get', async () => { 5 | const mirror = createMirror(async (id: number) => { 6 | return { 7 | test: id * 2, 8 | } 9 | }) 10 | 11 | const snapshot = await mirror.key(1).get() 12 | 13 | expect(snapshot).toEqual({ 14 | data: { test: 2 }, 15 | failure: null, 16 | key: 1, 17 | invalidated: false, 18 | pending: false, 19 | primed: true, 20 | updatedAt: snapshot.updatedAt, 21 | }) 22 | }) 23 | 24 | test('after an update, data should be immediately available', async () => { 25 | let fetched = false 26 | const mirror = createMirror(async (id: number) => { 27 | fetched = true 28 | return { 29 | test: id * 2, 30 | } 31 | }) 32 | 33 | const handle = mirror.key(1) 34 | 35 | handle.update({ test: 9 }) 36 | 37 | const latestSnapshot = handle.getLatest() 38 | const awaitedSnapshot = await handle.get() 39 | 40 | expect(latestSnapshot).toEqual(awaitedSnapshot) 41 | expect(fetched).toBe(false) 42 | }) 43 | 44 | test('when subscribed', async () => { 45 | let fetched = false 46 | const mirror = createMirror(async (id: number) => { 47 | fetched = true 48 | return { 49 | test: id * 2, 50 | } 51 | }) 52 | 53 | const handle = mirror.key(1) 54 | 55 | handle.update({ test: 9 }) 56 | 57 | const latestSnapshot = handle.getLatest() 58 | const awaitedSnapshot = await handle.get() 59 | 60 | expect(awaitedSnapshot.data.test).toEqual(9) 61 | expect(latestSnapshot).toEqual(awaitedSnapshot) 62 | expect(fetched).toBe(false) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/MirrorKeyListHandle.test.tsx: -------------------------------------------------------------------------------- 1 | import { createMirror } from '../src' 2 | 3 | describe('MirrorKeyListHandle', () => { 4 | test('supports get', async () => { 5 | const mirror = createMirror(async (id: number) => { 6 | return { 7 | test: id * 2, 8 | } 9 | }) 10 | 11 | const snapshot = await mirror.keys([1, 2]).get() 12 | const updatedAt = snapshot.data[0].updatedAt 13 | 14 | expect(snapshot).toEqual({ 15 | data: [ 16 | { 17 | data: { test: 2 }, 18 | failure: null, 19 | key: 1, 20 | invalidated: false, 21 | pending: false, 22 | primed: true, 23 | updatedAt, 24 | }, 25 | { 26 | data: { test: 4 }, 27 | failure: null, 28 | key: 2, 29 | invalidated: false, 30 | pending: false, 31 | primed: true, 32 | updatedAt, 33 | }, 34 | ], 35 | failure: null, 36 | key: [1, 2], 37 | primed: true, 38 | }) 39 | }) 40 | 41 | test('after an update, data should be immediately available', async () => { 42 | let fetched = false 43 | const mirror = createMirror(async (id: number) => { 44 | fetched = true 45 | return { 46 | test: id * 2, 47 | } 48 | }) 49 | 50 | const handle = mirror.keys([1, 2]) 51 | 52 | handle.update([{ test: 8 }, { test: 9 }]) 53 | 54 | const latestSnapshot = handle.getLatest() 55 | const awaitedSnapshot = await handle.get() 56 | 57 | expect(awaitedSnapshot.data.map(snapshot => snapshot.data.test)).toEqual([ 58 | 8, 59 | 9, 60 | ]) 61 | expect(latestSnapshot).toEqual(awaitedSnapshot) 62 | expect(fetched).toBe(false) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/createMirror.test.ts: -------------------------------------------------------------------------------- 1 | import { createMirror } from '../src' 2 | 3 | describe('createMirror', () => { 4 | test('supports namespaces', async () => { 5 | const mirror = createMirror( 6 | async (id: number, context: { multiplier: number }) => { 7 | return { 8 | test: id * context.multiplier, 9 | } 10 | }, 11 | ) 12 | 13 | const doubleNamespace = mirror.namespace({ multiplier: 2 }) 14 | const tripleNamespace = mirror.namespace({ multiplier: 3 }) 15 | 16 | const doubleSnapshot = await doubleNamespace.key(1).get() 17 | const tripleSnapshot = await tripleNamespace.key(1).get() 18 | 19 | expect(doubleSnapshot.data).toEqual({ 20 | test: 2, 21 | }) 22 | expect(tripleSnapshot.data).toEqual({ 23 | test: 3, 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/useSnapshot.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react' 2 | import TestRenderer from 'react-test-renderer' 3 | import { createMirror, useSnapshot } from '../src' 4 | 5 | const act = TestRenderer.act 6 | 7 | describe('useSnapshot', () => { 8 | test('immediately returns primed snapshots', async () => { 9 | const mirror = createMirror(async (id: number) => { 10 | return { 11 | test: id * 2, 12 | } 13 | }) 14 | 15 | await mirror.key(2).get() 16 | 17 | function App() { 18 | let snapshot = useSnapshot(mirror.key(2)) 19 | return <>{snapshot.data.test} 20 | } 21 | 22 | let component = TestRenderer.create() 23 | 24 | expect(component.toJSON()).toEqual('4') 25 | }) 26 | 27 | test('when used with { suspend: false }, immediately returns unprimed snapshots', async () => { 28 | const mirror = createMirror(async (id: number) => { 29 | return { 30 | test: id * 2, 31 | } 32 | }) 33 | 34 | function App() { 35 | let snapshot = useSnapshot(mirror.key(2), { suspend: false }) 36 | return ( 37 | <> 38 | {JSON.stringify({ 39 | data: snapshot.data, 40 | pending: snapshot.pending, 41 | primed: snapshot.primed, 42 | })} 43 | 44 | ) 45 | } 46 | 47 | let component = TestRenderer.create() 48 | 49 | expect(component.toJSON()).toEqual( 50 | JSON.stringify({ 51 | data: undefined, 52 | pending: true, 53 | primed: false, 54 | }), 55 | ) 56 | }) 57 | 58 | // test('when used without { suspend }, suspends then renders expected data', async () => { 59 | // const mirror = createMirror(async (id: number) => { 60 | // return { 61 | // test: id * 2, 62 | // } 63 | // }) 64 | 65 | // function App() { 66 | // let snapshot = useSnapshot(mirror, 2) 67 | // return <>{snapshot.data.test} 68 | // } 69 | 70 | // let component 71 | // await act(async () => { 72 | // component = TestRenderer.create( 73 | // }> 74 | // 75 | // , 76 | // ) 77 | // }) 78 | 79 | // expect(component.toJSON()).toEqual( 80 | // JSON.stringify({ 81 | // data: undefined, 82 | // pending: true, 83 | // primed: false, 84 | // }), 85 | // ) 86 | // }) 87 | }) 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "downlevelIteration": true, 4 | "esModuleInterop": true, 5 | "module": "es2015", 6 | "moduleResolution": "node", 7 | "jsx": "react", 8 | "outDir": "dist/es", 9 | "removeComments": false, 10 | "sourceMap": true, 11 | "strictNullChecks": true, 12 | "target": "es5", 13 | "lib": [ 14 | "dom", 15 | "dom.iterable", 16 | "es2015", 17 | "es2016", 18 | "es2017" 19 | ] 20 | }, 21 | "exclude": [ 22 | "dist", 23 | "examples", 24 | "node_modules", 25 | "notes", 26 | "test" 27 | ] 28 | } 29 | --------------------------------------------------------------------------------