├── .github ├── dependabot.yml └── workflows │ └── pr-test.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── lib ├── AsyncResourceContent.d.ts ├── AsyncResourceContent.js ├── AsyncResourceContent.js.map ├── AsyncResourceErrorBoundary.d.ts ├── AsyncResourceErrorBoundary.js ├── AsyncResourceErrorBoundary.js.map ├── cache.d.ts ├── cache.js ├── cache.js.map ├── dataReaderInitializer.d.ts ├── dataReaderInitializer.js ├── dataReaderInitializer.js.map ├── fileResource.d.ts ├── fileResource.js ├── fileResource.js.map ├── index.d.ts ├── index.js ├── index.js.map ├── types.d.ts ├── types.js ├── types.js.map ├── useAsyncResource.d.ts ├── useAsyncResource.js └── useAsyncResource.js.map ├── package.json ├── src ├── AsyncResourceContent.tsx ├── AsyncResourceErrorBoundary.tsx ├── cache.test.ts ├── cache.ts ├── dataReaderInitializer.test.ts ├── dataReaderInitializer.ts ├── fileResource.ts ├── index.ts ├── test.helpers.ts ├── types.ts ├── useAsyncResource.test.ts └── useAsyncResource.ts ├── tsconfig.json └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | -------------------------------------------------------------------------------- /.github/workflows/pr-test.yml: -------------------------------------------------------------------------------- 1 | name: PR test 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | node-version: 12 15 | registry-url: https://registry.npmjs.org/ 16 | 17 | - name: Install dependencies 18 | run: yarn install 19 | 20 | - name: Run tests 21 | run: yarn test 22 | -------------------------------------------------------------------------------- /.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 | .idea 16 | .vscode 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # misc 10 | .idea 11 | .vscode 12 | .github 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020, Andrei Duca 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # useAsyncResource - data fetching hook for React Suspense 2 | 3 | Convert any function that returns a Promise into a data reader function. 4 | The data reader can then be consumed by a "suspendable" React component. 5 | 6 | The hook also returns an updater handler that triggers new api calls. 7 | The handler refreshes the data reader with each call. 8 | 9 | 10 | ## ✨ Basic usage 11 | 12 | ``` 13 | yarn add use-async-resource 14 | ``` 15 | 16 | then: 17 | 18 | ```tsx 19 | import { useAsyncResource } from 'use-async-resource'; 20 | 21 | // a simple api function that fetches a user 22 | const fetchUser = (id: number) => fetch(`.../get/user/by/${id}`).then(res => res.json()); 23 | 24 | function App() { 25 | // 👉 initialize the data reader and start fetching the user immediately 26 | const [userReader, getNewUser] = useAsyncResource(fetchUser, 1); 27 | 28 | return ( 29 | <> 30 | 31 | 32 | 33 | 34 | 35 | 36 | {/* clicking the button 👆 will start fetching a new user */} 37 | 38 | ); 39 | } 40 | 41 | function User({ userReader }) { 42 | const userData = userReader(); // 😎 just call the data reader function to get the user object 43 | 44 | return
{userData.name}
; 45 | } 46 | ``` 47 | 48 | 49 | ### Data Reader and Refresh handler 50 | 51 | The `useAsyncResource` hook returns a pair: 52 | - the **data reader function**, which returns the expected result, or throws if the result is not yet available; 53 | - a **refresh handler to fetch new data** with new parameters. 54 | 55 | The returned data reader `userReader` is a function that returns the user object if the api call completed successfully. 56 | 57 | If the api call has not finished, the data reader function throws the promise, which is caught by the `React.Suspense` boundary. 58 | Suspense will retry to render the child component until it's successful, meaning the promised completed, the data is available, and the data reader doesn't throw anymore. 59 | 60 | If the api call fails with an error, that error is thrown, and the `ErrorBoundary` component will catch it. 61 | 62 | The refresh handler is identical with the original wrapped function, except it doesn't return anything - it only triggers new api calls. 63 | The data is retrievable with the data reader function. 64 | 65 | Notice the returned items are a pair, so you can name them whatever you want, using the array destructuring: 66 | 67 | ```tsx 68 | const [userReader, getUser] = useAsyncResource(fetchUser, id); 69 | 70 | const [postsReader, getPosts] = useAsyncResource(fetchPosts, category); 71 | 72 | const [commentsReader, getComments] = useAsyncResource(fetchPostComments, postId, { orderBy: "date", order: "desc" }); 73 | ``` 74 | 75 | 76 | ### Api functions that don't accept parameters 77 | 78 | If the api function doesn't accept any parameters, just pass an empty array as the second argument: 79 | 80 | ```tsx 81 | const fetchToggles = () => fetch('/path/to/global/toggles').then(res => res.json()); 82 | 83 | // in App.jsx 84 | const [toggles] = useAsyncResource(fetchToggles, []); 85 | ``` 86 | 87 | Just like before, the api call is immediately invoked and the `toggles` data reader can be passed to a suspendable child component. 88 | 89 | 90 | ## 🦥 Lazy initialization 91 | 92 | All of the above examples are eagerly initialized, meaning the data starts fetching as soon as the `useAsyncResource` is called. 93 | But in some cases you would want to start fetching data only after a user interaction. 94 | 95 | To lazily initialize the data reader, just pass the api function without any parameters: 96 | 97 | ```tsx 98 | const [userReader, getUserDetails] = useAsyncResource(fetchUserDetails); 99 | ``` 100 | 101 | Then use the refresh handler to start fetching data when needed: 102 | 103 | ```tsx 104 | const [selectedUserId, setUserId] = React.useState(); 105 | 106 | const selectUserHandler = React.useCallback((userId) => { 107 | setUserId(userId); 108 | getUserDetails(userId); // 👈 call the refresh handler to trigger new api calls 109 | }, []); 110 | 111 | return ( 112 | <> 113 | 114 | {selectedUserId && ( 115 | 116 | 117 | 118 | )} 119 | 120 | ); 121 | ``` 122 | 123 | The only difference between a lazy data reader and an eagerly initialized one is that 124 | the lazy data reader can also return `undefined` if the data fetching hasn't stared yet. 125 | 126 | Be aware of this difference when consuming the data in the child component: 127 | 128 | ```tsx 129 | function UserDetails({ userReader }) { 130 | const userData = userReader(); 131 | // 👆 this may be `undefined` at first, so we need to check for it 132 | 133 | if (userData === undefined) { 134 | return null; 135 | } 136 | 137 | return
{userData.username} - {userData.email}
138 | } 139 | ``` 140 | 141 | 142 | ## 📦 Resource caching 143 | 144 | All resources are cached, so subsequent calls with the same parameters for the same api function 145 | return the same resource, and don't trigger new, identical api calls. 146 | 147 | This is useful for many reasons. First, it means you don't have to necessarily initialize the data reader 148 | in a parent component. You only have to wrap the child component in a Suspense boundary: 149 | 150 | ```tsx 151 | function App() { 152 | return ( 153 | 154 | 155 | 156 | ); 157 | } 158 | 159 | function Posts(props) { 160 | // as usual, initialize the data reader and start fetching the posts 161 | const [postsReader] = useAsyncResource(fetchPosts, []); 162 | 163 | // now read the posts and render a list 164 | const postsList = postsReader(); 165 | 166 | return postsList.map(post => ); 167 | } 168 | ``` 169 | 170 | This still works as you'd expect, even if the `App` component re-renders for any other reason, 171 | before, during or even after the posts have loaded. Because the data reader gets cached, only the first initialization will trigger an api call. 172 | 173 | 174 | This also means you can write code like this, without having to think about deduplicating requests for the same user id: 175 | 176 | ```tsx 177 | function App() { 178 | // just like before, start fetching posts 179 | const [postsReader] = useAsyncResource(fetchPosts, []); 180 | 181 | return ( 182 | 183 | 184 | 185 | ); 186 | } 187 | 188 | 189 | function Posts(props) { 190 | // read the posts and render a list 191 | const postsList = props.dataReader(); 192 | 193 | return postsList.map(post => ); 194 | } 195 | 196 | 197 | function Post(props) { 198 | // start fetching users for each individual post 199 | const [userReader] = useAsyncResource(fetchUser, props.post.authorId); 200 | // 👉 notice we don't need to deduplicate the user resource for potentially identical author ids 201 | 202 | return ( 203 |
204 |

{props.post.title}

205 | 206 | 207 | 208 |

{props.post.body}

209 |
210 | ); 211 | } 212 | 213 | 214 | function Author(props) { 215 | // get the user object as usual 216 | const user = props.dataReader(); 217 | 218 | return
{user.displayName}
; 219 | } 220 | ``` 221 | 222 | 223 | ### 🚚 Preloading resources 224 | 225 | When you know a resource will be consumed by a child component, you can preload it ahead of time. 226 | This is useful in cases such as lazy loaded components, or when trying to predict a user's intent. 227 | 228 | ```tsx 229 | // 👉 import the `preloadResource` helper 230 | import { useAsyncResource, preloadResource } from 'use-async-resource'; 231 | 232 | // a lazy-loaded React component 233 | const PostsList = React.lazy(() => import('./PostsListComponent')); 234 | 235 | // some api function 236 | const fetchUserPosts = (userId) => fetch(`/path/to/get/user/${userId}/posts`).then(res => res.json()) 237 | 238 | 239 | function UserProfile(props) { 240 | const [showPostsList, toggleList] = React.useState(false); 241 | 242 | return ( 243 | <> 244 |

{props.user.name}

245 | 254 | 255 | {showPostsList && ( 256 | // this child will suspend if either: 257 | // - the `PostList` component code is not yet loaded 258 | // - or the data reader inside it is not yet ready 259 | // 👉 notice we're not initializing any resource to pass it to the child component 260 | 261 | 262 | 263 | )} 264 | 265 | ); 266 | } 267 | 268 | // in PostsListComponent.tsx 269 | function PostsList(props) { 270 | // 👉 instead, we initialize the data reader inside the child component directly 271 | const [posts] = useAsyncResource(fetchUserPosts, props.userId); 272 | 273 | // ✨ because we preloaded it in the parent with the same `userId` parameter, 274 | // it will get initialized with that cached version 275 | 276 | // also, the outer React.Suspense boundary in the parent will take care of rendering the fallback 277 | return ( 278 |
    279 | {posts().map(post =>
  • )} 280 |
281 | ); 282 | } 283 | ``` 284 | 285 | In the above example, even if the child component loads faster than the data, 286 | re-rendering it multiple times until the data is ready is ok, because every time 287 | the data reader will be initialized from the same cached version. 288 | No api call will ever be triggered from the child component, 289 | because that happened in the parent when the user hovered the button. 290 | 291 | At the same time, if the data is ready before the code loads, it will be available immediately 292 | when the child component will render for the first time. 293 | 294 | 295 | ### Clearing caches 296 | 297 | Finally, you can manually clear caches by using the `resourceCache` helper. 298 | 299 | ```tsx 300 | import { useAsyncResource, resourceCache } from 'use-async-resource'; 301 | 302 | // ... 303 | 304 | const [latestPosts, getPosts] = useAsyncResource(fetchLatestPosts, []); 305 | 306 | const refreshLatestPosts = React.useCallback(() => { 307 | // 🧹 clear the cache so we can make a new api call 308 | resourceCache(fetchLatestPosts).clear(); 309 | // 🙌 refresh the data reader 310 | getPosts(); 311 | }, []); 312 | ``` 313 | 314 | In this case, we're clearing the entire cache for the `fetchLatestPosts` api function. 315 | But you can also use the `delete()` method with parameters, so you only delete the cache for those specific ones: 316 | 317 | ```tsx 318 | const [user, getUser] = useAsyncResource(fetchUser, id); 319 | 320 | const refreshUserProfile = React.useCallback((userId) => { 321 | // only clear the cache for that id 322 | resourceCache(fetchUser).delete(userId); 323 | // get new user data 324 | getUser(userId); 325 | }, []); 326 | ``` 327 | 328 | 329 | ## Data modifiers 330 | 331 | When consumed, the data reader can take an optional argument: a function to modify the data. 332 | This function receives the original data as a parameter, and the transformation logic is up to you. 333 | 334 | ```tsx 335 | const userDisplayName = userDataReader(user => `${user.firstName} ${user.lastName}`); 336 | ``` 337 | 338 | 339 | ## File resource helpers 340 | 341 | Suspense is not just about fetching data in a declarative way, but about fetching resources in general, including images and scripts. 342 | 343 | The included `fileResource` helper will turn a URL string into a resource "data reader" function, but it will load a resource instead of data. 344 | When the resource finishes loading, the "data reader" function will return the URL you passed in. Until then, it will throw a Promise, so Suspense can render a fallback. 345 | 346 | Here's an example for an image resource: 347 | 348 | ```tsx 349 | import { useAsyncResource, fileResource } from 'use-async-resource'; 350 | 351 | function Author({ user }) { 352 | // initialize the image "data reader" 353 | const [userImageReader] = useAsyncResource(fileResource.image, user.profilePicUrl); 354 | 355 | return ( 356 |
357 | {/* render a fallback until the image is downloaded */} 358 | }> 359 | {/* pass the resource "data reader" to a suspendable component */} 360 | 361 | 362 |

{user.name}

363 |

{user.bio}

364 |
365 | ); 366 | } 367 | 368 | function ProfilePhoto(props) { 369 | // just read back the URL and use it in an `img` tag when the image is ready 370 | const imageSrc = props.resource(); 371 | 372 | return ; 373 | } 374 | ``` 375 | 376 | Using the `fileResource` to load external scripts is just as easy: 377 | 378 | ```tsx 379 | function App() { 380 | const [jq] = useAsyncResource(fileResource.script, 'https://code.jquery.com/jquery-3.4.1.slim.min.js'); 381 | 382 | return ( 383 | 384 | 385 | 386 | ); 387 | } 388 | 389 | function JQComponent(props) { 390 | const jQ = props.jQueryResource(); 391 | 392 | // jQuery should be available and you can do something with it 393 | return
jQuery version: {window.jQuery.fn.jquery}
394 | } 395 | ``` 396 | 397 | Notice we don’t do anything with the `const jQ`, but we still need to call `props.jQueryResource()` so it can throw, 398 | rendering the fallback until the library is fully loaded on the page. 399 | 400 | 401 | ## 📘 TypeScript support 402 | 403 | The `useAsyncResource` hook infers all types from the api function passed in. 404 | The arguments it accepts after the api function are exactly the parameters of the original api function. 405 | 406 | ```tsx 407 | const fetchUser = (userId: number): Promise => fetch('...'); 408 | 409 | const [wrongUserReader] = useAsyncResource(fetchUser, "some", "string", "params"); // 🚨 TS will complain about this 410 | const [correctUserReader] = useAsyncResource(fetchUser, 1); // 👌 just right 411 | const [lazyUserReader] = useAsyncResource(fetchUser); // 🦥 also ok, but lazily initialized 412 | ``` 413 | 414 | The only exception is the api function without parameters: 415 | - the hook doesn't accept any other arguments than the api function, meaning it's lazily initialized; 416 | - or it accepts a single extra argument, an empty array, when we want the resource to start loading immediately. 417 | 418 | ```tsx 419 | const [lazyToggles] = useAsyncResource(fetchToggles); // 🦥 ok, but lazily initialized 420 | const [eagerToggles] = useAsyncResource(fetchToggles, []); // 🚀 ok, starts fetching immediately 421 | const [wrongToggles] = useAsyncResource(fetchToggles, "some", "params"); // 🚨 TS will complain about this 422 | ``` 423 | 424 | 425 | ### Type inference for the data reader 426 | 427 | The data reader will return exactly the type the original api function returns as a Promise. 428 | 429 | ```tsx 430 | const fetchUser = (userId: number): Promise => fetch('...'); 431 | 432 | const [userReader] = useAsyncResource(fetchUser, 1); 433 | ``` 434 | 435 | `userReader` is inferred as `() => UserType`, meaning a `function` that returns a `UserType` object. 436 | 437 | If the resource is lazily initialized, the `userReader` can also return `undefined`: 438 | 439 | ```tsx 440 | const [userReader] = useAsyncResource(fetchUser); 441 | ``` 442 | 443 | Here, `userReader` is inferred as `() => (UserType | undefined)`, meaning a `function` that returns either a `UserType` object, or `undefined`. 444 | 445 | 446 | ### Type inference for the refresh handler 447 | 448 | Not just the data reader types are inferred, but also the arguments of the refresh handler: 449 | 450 | ```tsx 451 | const fetchUser = (userId: number): Promise => fetch('...'); 452 | 453 | const [userReader, getNewUser] = useAsyncResource(fetchUser, 1); 454 | ``` 455 | 456 | The `getNewUser` handler is inferred as `(userId: number) => void`, meaning a `function` that takes a numeric argument `userId`, but doesn't return anything. 457 | 458 | Remember: the return type of the handler is always `void`, because the handler only kicks off new data api calls. 459 | The data is still retrievable via the data reader function. 460 | 461 | 462 | ## Default Suspense and ErrorBoundary wrappers 463 | 464 | Again, a component consuming a data reader needs to be wrapped in both a `React.Suspense` boundary and a custom `ErrorBoundary`. 465 | 466 | For convenience, you can use the bundled `AsyncResourceContent` that provides both: 467 | 468 | ```tsx 469 | import { useAsyncResource, AsyncResourceContent } from 'use-async-resource'; 470 | 471 | // ... 472 | 473 | 477 | 478 | 479 | ``` 480 | 481 | The `fallback` can be a `string` or a React component. 482 | 483 | The `errorMessage` can be either a `string`, a React component, 484 | or a function that takes the thrown error as an argument and returns a `string` or a React component. 485 | 486 | ```tsx 487 | } 489 | errorMessage={(e: CustomErrorType) => {e.message}} 490 | > 491 | 492 | 493 | ``` 494 | 495 | 496 | ### Custom Error Boundary 497 | 498 | Optionally, you can pass a custom error boundary component to be used instead of the default one: 499 | 500 | ```tsx 501 | class MyCustomErrorBoundary extends React.Component { ... } 502 | 503 | // ... 504 | 505 | 510 | 511 | 512 | ``` 513 | 514 | If you also pass the `errorMessage` prop, your custom error boundary will receive it as a prop. 515 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // babel.config.js 2 | module.exports = { 3 | presets: [ 4 | ['@babel/preset-env', { modules: 'commonjs', targets: { node: 'current' } }], 5 | '@babel/preset-typescript', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/lv/5d3639z92ks7zv22bsvj9lvw0000gp/T/jest_dy", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: undefined, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | coveragePathIgnorePatterns: [ 31 | "test.helpers.ts" 32 | ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | coverageThreshold: { 44 | global: { 45 | branches: 100, 46 | functions: 100, 47 | lines: 100, 48 | statements: 100, 49 | }, 50 | }, 51 | 52 | // A path to a custom dependency extractor 53 | // dependencyExtractor: undefined, 54 | 55 | // Make calling deprecated APIs throw helpful error messages 56 | // errorOnDeprecated: false, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "json", 82 | // "jsx", 83 | // "ts", 84 | // "tsx", 85 | // "node" 86 | // ], 87 | 88 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 89 | // moduleNameMapper: {}, 90 | 91 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 92 | // modulePathIgnorePatterns: [], 93 | 94 | // Activates notifications for test results 95 | // notify: false, 96 | 97 | // An enum that specifies notification mode. Requires { notify: true } 98 | // notifyMode: "failure-change", 99 | 100 | // A preset that is used as a base for Jest's configuration 101 | // preset: undefined, 102 | 103 | // Run tests from one or more projects 104 | // projects: undefined, 105 | 106 | // Use this configuration option to add custom reporters to Jest 107 | // reporters: undefined, 108 | 109 | // Automatically reset mock state between every test 110 | // resetMocks: false, 111 | 112 | // Reset the module registry before running each individual test 113 | // resetModules: false, 114 | 115 | // A path to a custom resolver 116 | // resolver: undefined, 117 | 118 | // Automatically restore mock state between every test 119 | // restoreMocks: false, 120 | 121 | // The root directory that Jest should scan for tests and modules within 122 | // rootDir: undefined, 123 | 124 | // A list of paths to directories that Jest should use to search for files in 125 | // roots: [ 126 | // "" 127 | // ], 128 | 129 | // Allows you to use a custom runner instead of Jest's default test runner 130 | // runner: "jest-runner", 131 | 132 | // The paths to modules that run some code to configure or set up the testing environment before each test 133 | // setupFiles: [], 134 | 135 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 136 | // setupFilesAfterEnv: [], 137 | 138 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 139 | // snapshotSerializers: [], 140 | 141 | // The test environment that will be used for testing 142 | testEnvironment: "jest-environment-jsdom", 143 | 144 | // Options that will be passed to the testEnvironment 145 | // testEnvironmentOptions: {}, 146 | 147 | // Adds a location field to test results 148 | // testLocationInResults: false, 149 | 150 | // The glob patterns Jest uses to detect test files 151 | // testMatch: [ 152 | // "**/__tests__/**/*.[jt]s?(x)", 153 | // "**/?(*.)+(spec|test).[tj]s?(x)" 154 | // ], 155 | 156 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 157 | // testPathIgnorePatterns: [ 158 | // "/node_modules/" 159 | // ], 160 | 161 | // The regexp pattern or array of patterns that Jest uses to detect test files 162 | // testRegex: [], 163 | 164 | // This option allows the use of a custom results processor 165 | // testResultsProcessor: undefined, 166 | 167 | // This option allows use of a custom test runner 168 | // testRunner: "jasmine2", 169 | 170 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 171 | // testURL: "http://localhost", 172 | 173 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 174 | // timers: "real", 175 | 176 | // A map from regular expressions to paths to transformers 177 | // transform: undefined, 178 | 179 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 180 | // transformIgnorePatterns: [ 181 | // "/node_modules/" 182 | // ], 183 | 184 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 185 | // unmockedModulePathPatterns: undefined, 186 | 187 | // Indicates whether each individual test should be reported during the run 188 | // verbose: undefined, 189 | 190 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 191 | // watchPathIgnorePatterns: [], 192 | 193 | // Whether to use watchman for file crawling 194 | // watchman: true, 195 | }; 196 | -------------------------------------------------------------------------------- /lib/AsyncResourceContent.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Props as ErrorBoundaryProps } from './AsyncResourceErrorBoundary'; 3 | interface AsyncResourceContentProps { 4 | fallback: NonNullable | null; 5 | errorComponent?: React.ComponentType; 6 | } 7 | declare type Props = AsyncResourceContentProps & ErrorBoundaryProps; 8 | declare const AsyncResourceContent: ({ children, fallback, errorMessage, errorComponent: ErrorComponent, }: React.PropsWithChildren>) => JSX.Element; 9 | export default AsyncResourceContent; 10 | -------------------------------------------------------------------------------- /lib/AsyncResourceContent.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 10 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 11 | }) : function(o, v) { 12 | o["default"] = v; 13 | }); 14 | var __importStar = (this && this.__importStar) || function (mod) { 15 | if (mod && mod.__esModule) return mod; 16 | var result = {}; 17 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 18 | __setModuleDefault(result, mod); 19 | return result; 20 | }; 21 | var __importDefault = (this && this.__importDefault) || function (mod) { 22 | return (mod && mod.__esModule) ? mod : { "default": mod }; 23 | }; 24 | Object.defineProperty(exports, "__esModule", { value: true }); 25 | const React = __importStar(require("react")); 26 | const AsyncResourceErrorBoundary_1 = __importDefault(require("./AsyncResourceErrorBoundary")); 27 | const AsyncResourceContent = ({ children, fallback, errorMessage, errorComponent: ErrorComponent, }) => { 28 | const ErrorBoundary = ErrorComponent || AsyncResourceErrorBoundary_1.default; 29 | return (React.createElement(ErrorBoundary, { errorMessage: errorMessage }, 30 | React.createElement(React.Suspense, { fallback: fallback }, children))); 31 | }; 32 | exports.default = AsyncResourceContent; 33 | //# sourceMappingURL=AsyncResourceContent.js.map -------------------------------------------------------------------------------- /lib/AsyncResourceContent.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"AsyncResourceContent.js","sourceRoot":"","sources":["../src/AsyncResourceContent.tsx"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAAA,6CAA+B;AAC/B,8FAAuG;AASvG,MAAM,oBAAoB,GAAG,CAA4B,EACvD,QAAQ,EACR,QAAQ,EACR,YAAY,EACZ,cAAc,EAAE,cAAc,GACI,EAAE,EAAE;IACtC,MAAM,aAAa,GAAG,cAAc,IAAI,oCAA0B,CAAC;IAEnE,OAAO,CACL,oBAAC,aAAa,IAAC,YAAY,EAAE,YAAY;QACvC,oBAAC,KAAK,CAAC,QAAQ,IAAC,QAAQ,EAAE,QAAQ,IAC/B,QAAQ,CACM,CACH,CACjB,CAAC;AACJ,CAAC,CAAC;AAEF,kBAAe,oBAAoB,CAAC"} -------------------------------------------------------------------------------- /lib/AsyncResourceErrorBoundary.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | interface State { 3 | error?: Error; 4 | errorMessage?: string; 5 | } 6 | export interface Props { 7 | errorMessage?: React.ReactComponentElement | string | ((error: E) => string | React.ReactComponentElement); 8 | } 9 | declare class AsyncResourceErrorBoundary extends React.Component, State> { 10 | static getDerivedStateFromError(error: Error): { 11 | error: Error; 12 | }; 13 | static getDerivedStateFromProps({ errorMessage }: Props, state: State): State | { 14 | errorMessage: string | React.ReactComponentElement>; 15 | }; 16 | constructor(props: Props); 17 | render(): React.ReactNode; 18 | } 19 | export default AsyncResourceErrorBoundary; 20 | -------------------------------------------------------------------------------- /lib/AsyncResourceErrorBoundary.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 10 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 11 | }) : function(o, v) { 12 | o["default"] = v; 13 | }); 14 | var __importStar = (this && this.__importStar) || function (mod) { 15 | if (mod && mod.__esModule) return mod; 16 | var result = {}; 17 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 18 | __setModuleDefault(result, mod); 19 | return result; 20 | }; 21 | Object.defineProperty(exports, "__esModule", { value: true }); 22 | const React = __importStar(require("react")); 23 | class AsyncResourceErrorBoundary extends React.Component { 24 | static getDerivedStateFromError(error) { 25 | return { error }; 26 | } 27 | static getDerivedStateFromProps({ errorMessage }, state) { 28 | if (state.error) { 29 | return { 30 | errorMessage: typeof errorMessage === 'function' 31 | ? errorMessage(state.error) 32 | : (errorMessage || state.error.message), 33 | }; 34 | } 35 | return state; 36 | } 37 | constructor(props) { 38 | super(props); 39 | this.state = {}; 40 | } 41 | render() { 42 | if (this.state.errorMessage) { 43 | return this.state.errorMessage; 44 | } 45 | return this.props.children; 46 | } 47 | } 48 | exports.default = AsyncResourceErrorBoundary; 49 | //# sourceMappingURL=AsyncResourceErrorBoundary.js.map -------------------------------------------------------------------------------- /lib/AsyncResourceErrorBoundary.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"AsyncResourceErrorBoundary.js","sourceRoot":"","sources":["../src/AsyncResourceErrorBoundary.tsx"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA,6CAA+B;AAa/B,MAAM,0BAA4C,SAAQ,KAAK,CAAC,SAAwC;IAC/F,MAAM,CAAC,wBAAwB,CAAC,KAAY;QACjD,OAAO,EAAE,KAAK,EAAE,CAAC;IACnB,CAAC;IAEM,MAAM,CAAC,wBAAwB,CAAC,EAAE,YAAY,EAAS,EAAE,KAAY;QAC1E,IAAI,KAAK,CAAC,KAAK,EAAE;YACf,OAAO;gBACL,YAAY,EAAE,OAAO,YAAY,KAAK,UAAU;oBAC9C,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,KAAK,CAAC;oBAC3B,CAAC,CAAC,CAAC,YAAY,IAAI,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC;aAC1C,CAAC;SACH;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IAKD,YAAY,KAA6B;QACvC,KAAK,CAAC,KAAK,CAAC,CAAC;QAEb,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC;IAClB,CAAC;IAEM,MAAM;QACX,IAAI,IAAI,CAAC,KAAK,CAAC,YAAY,EAAE;YAC3B,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC;SAChC;QAED,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC;IAC7B,CAAC;CACF;AAED,kBAAe,0BAA0B,CAAC"} -------------------------------------------------------------------------------- /lib/cache.d.ts: -------------------------------------------------------------------------------- 1 | import { ApiFn, DataOrModifiedFn } from './types'; 2 | export declare function resourceCache(apiFn: ApiFn): { 3 | get(...params: A | never[]): DataOrModifiedFn | undefined; 4 | set(dataFn: DataOrModifiedFn, ...params: A | never[]): Map>; 5 | delete(...params: A | never[]): boolean; 6 | clear(): void; 7 | }; 8 | -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.resourceCache = void 0; 7 | const object_hash_1 = __importDefault(require("object-hash")); 8 | const caches = new Map(); 9 | function resourceCache(apiFn) { 10 | if (!caches.has(apiFn)) { 11 | caches.set(apiFn, new Map()); 12 | } 13 | const apiCache = caches.get(apiFn); 14 | return { 15 | get(...params) { 16 | return apiCache.get((0, object_hash_1.default)(params)); 17 | }, 18 | set(dataFn, ...params) { 19 | return apiCache.set((0, object_hash_1.default)(params), dataFn); 20 | }, 21 | delete(...params) { 22 | return apiCache.delete((0, object_hash_1.default)(params)); 23 | }, 24 | clear() { 25 | caches.delete(apiFn); 26 | return apiCache.clear(); 27 | }, 28 | }; 29 | } 30 | exports.resourceCache = resourceCache; 31 | //# sourceMappingURL=cache.js.map -------------------------------------------------------------------------------- /lib/cache.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":";;;;;;AACA,8DAA+B;AAI/B,MAAM,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;AAMzB,SAAgB,aAAa,CAAyB,KAAkB;IAEtE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE;QACtB,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,GAAG,EAAE,CAAC,CAAC;KAC9B;IAGD,MAAM,QAAQ,GAAqC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAGrE,OAAO;QAEL,GAAG,CAAC,GAAG,MAAmB;YACxB,OAAO,QAAQ,CAAC,GAAG,CAAC,IAAA,qBAAI,EAAC,MAAM,CAAC,CAAC,CAAC;QACpC,CAAC;QAED,GAAG,CAAC,MAA2B,EAAE,GAAG,MAAmB;YACrD,OAAO,QAAQ,CAAC,GAAG,CAAC,IAAA,qBAAI,EAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC;QAC5C,CAAC;QAED,MAAM,CAAC,GAAG,MAAmB;YAC3B,OAAO,QAAQ,CAAC,MAAM,CAAC,IAAA,qBAAI,EAAC,MAAM,CAAC,CAAC,CAAC;QACvC,CAAC;QAED,KAAK;YACH,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACrB,OAAO,QAAQ,CAAC,KAAK,EAAE,CAAC;QAC1B,CAAC;KACF,CAAC;AACJ,CAAC;AA7BD,sCA6BC"} -------------------------------------------------------------------------------- /lib/dataReaderInitializer.d.ts: -------------------------------------------------------------------------------- 1 | import { ApiFn, DataOrModifiedFn } from './types'; 2 | export declare function initializeDataReader(apiFn: ApiFn): DataOrModifiedFn; 3 | export declare function initializeDataReader(apiFn: ApiFn, ...parameters: ArgTypes): DataOrModifiedFn; 4 | -------------------------------------------------------------------------------- /lib/dataReaderInitializer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.initializeDataReader = void 0; 4 | const cache_1 = require("./cache"); 5 | function initializeDataReader(apiFn, ...parameters) { 6 | const apiFnCache = (0, cache_1.resourceCache)(apiFn); 7 | const cachedResource = apiFnCache.get(...parameters); 8 | if (cachedResource) { 9 | return cachedResource; 10 | } 11 | let data; 12 | let status = 'init'; 13 | let error; 14 | const fetchingPromise = apiFn(...parameters) 15 | .then((result) => { 16 | data = result; 17 | status = 'done'; 18 | return result; 19 | }) 20 | .catch((err) => { 21 | error = err; 22 | status = 'error'; 23 | }); 24 | function dataReaderFn(modifier) { 25 | if (status === 'init') { 26 | throw fetchingPromise; 27 | } 28 | else if (status === 'error') { 29 | throw error; 30 | } 31 | return typeof modifier === 'function' 32 | ? modifier(data) 33 | : data; 34 | } 35 | apiFnCache.set(dataReaderFn, ...parameters); 36 | return dataReaderFn; 37 | } 38 | exports.initializeDataReader = initializeDataReader; 39 | //# sourceMappingURL=dataReaderInitializer.js.map -------------------------------------------------------------------------------- /lib/dataReaderInitializer.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"dataReaderInitializer.js","sourceRoot":"","sources":["../src/dataReaderInitializer.ts"],"names":[],"mappings":";;;AACA,mCAAwC;AA2BxC,SAAgB,oBAAoB,CAClC,KAAoC,EACpC,GAAG,UAAoB;IAIvB,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,KAAK,CAAC,CAAC;IACxC,MAAM,cAAc,GAAG,UAAU,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC,CAAC;IAErD,IAAI,cAAc,EAAE;QAClB,OAAO,cAAc,CAAC;KACvB;IAED,IAAI,IAAkB,CAAC;IACvB,IAAI,MAAM,GAAgB,MAAM,CAAC;IACjC,IAAI,KAAc,CAAC;IAEnB,MAAM,eAAe,GAAG,KAAK,CAAC,GAAG,UAAU,CAAC;SACzC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE;QACf,IAAI,GAAG,MAAM,CAAC;QACd,MAAM,GAAG,MAAM,CAAC;QAChB,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC;SACD,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;QACb,KAAK,GAAG,GAAG,CAAC;QACZ,MAAM,GAAG,OAAO,CAAC;IACnB,CAAC,CAAC,CAAC;IAKL,SAAS,YAAY,CAAI,QAAsC;QAC7D,IAAI,MAAM,KAAK,MAAM,EAAE;YACrB,MAAM,eAAe,CAAC;SACvB;aAAM,IAAI,MAAM,KAAK,OAAO,EAAE;YAC7B,MAAM,KAAK,CAAC;SACb;QAED,OAAO,OAAO,QAAQ,KAAK,UAAU;YACnC,CAAC,CAAE,QAAQ,CAAC,IAAI,CAAO;YACvB,CAAC,CAAE,IAAqB,CAAC;IAC7B,CAAC;IAED,UAAU,CAAC,GAAG,CAAC,YAAY,EAAE,GAAG,UAAU,CAAC,CAAC;IAE5C,OAAO,YAAY,CAAC;AACtB,CAAC;AA9CD,oDA8CC"} -------------------------------------------------------------------------------- /lib/fileResource.d.ts: -------------------------------------------------------------------------------- 1 | export declare function image(imageSrc: string): Promise; 2 | export declare function script(scriptSrc: string): Promise; 3 | -------------------------------------------------------------------------------- /lib/fileResource.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.script = exports.image = void 0; 4 | function image(imageSrc) { 5 | return new Promise((resolve, reject) => { 6 | const file = new Image(); 7 | file.onload = () => { 8 | resolve(imageSrc); 9 | }; 10 | file.onerror = reject; 11 | file.src = imageSrc; 12 | }); 13 | } 14 | exports.image = image; 15 | function script(scriptSrc) { 16 | return new Promise((resolve, reject) => { 17 | const file = document.createElement('script'); 18 | file.onload = () => { 19 | resolve(scriptSrc); 20 | }; 21 | file.onerror = reject; 22 | file.src = scriptSrc; 23 | document.getElementsByTagName('body')[0].appendChild(file); 24 | }); 25 | } 26 | exports.script = script; 27 | //# sourceMappingURL=fileResource.js.map -------------------------------------------------------------------------------- /lib/fileResource.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"fileResource.js","sourceRoot":"","sources":["../src/fileResource.ts"],"names":[],"mappings":";;;AAGA,SAAgB,KAAK,CAAC,QAAgB;IACpC,OAAO,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QAC7C,MAAM,IAAI,GAAG,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,CAAC,MAAM,GAAG,GAAG,EAAE;YACjB,OAAO,CAAC,QAAQ,CAAC,CAAC;QACpB,CAAC,CAAC;QAEF,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QAEtB,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC;IACtB,CAAC,CAAC,CAAC;AACL,CAAC;AAXD,sBAWC;AAKD,SAAgB,MAAM,CAAC,SAAiB;IACtC,OAAO,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QAC7C,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAC9C,IAAI,CAAC,MAAM,GAAG,GAAG,EAAE;YACjB,OAAO,CAAC,SAAS,CAAC,CAAC;QACrB,CAAC,CAAC;QAEF,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QAEtB,IAAI,CAAC,GAAG,GAAG,SAAS,CAAC;QAErB,QAAQ,CAAC,oBAAoB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;AACL,CAAC;AAbD,wBAaC"} -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | import { useAsyncResource } from './useAsyncResource'; 3 | import * as fileResource from './fileResource'; 4 | import { resourceCache } from './cache'; 5 | import { initializeDataReader as preloadResource } from './dataReaderInitializer'; 6 | import AsyncResourceContent from './AsyncResourceContent'; 7 | export { useAsyncResource, preloadResource, fileResource, resourceCache, AsyncResourceContent, }; 8 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 10 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 11 | }) : function(o, v) { 12 | o["default"] = v; 13 | }); 14 | var __exportStar = (this && this.__exportStar) || function(m, exports) { 15 | for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); 16 | }; 17 | var __importStar = (this && this.__importStar) || function (mod) { 18 | if (mod && mod.__esModule) return mod; 19 | var result = {}; 20 | if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 21 | __setModuleDefault(result, mod); 22 | return result; 23 | }; 24 | var __importDefault = (this && this.__importDefault) || function (mod) { 25 | return (mod && mod.__esModule) ? mod : { "default": mod }; 26 | }; 27 | Object.defineProperty(exports, "__esModule", { value: true }); 28 | exports.AsyncResourceContent = exports.resourceCache = exports.fileResource = exports.preloadResource = exports.useAsyncResource = void 0; 29 | __exportStar(require("./types"), exports); 30 | const useAsyncResource_1 = require("./useAsyncResource"); 31 | Object.defineProperty(exports, "useAsyncResource", { enumerable: true, get: function () { return useAsyncResource_1.useAsyncResource; } }); 32 | const fileResource = __importStar(require("./fileResource")); 33 | exports.fileResource = fileResource; 34 | const cache_1 = require("./cache"); 35 | Object.defineProperty(exports, "resourceCache", { enumerable: true, get: function () { return cache_1.resourceCache; } }); 36 | const dataReaderInitializer_1 = require("./dataReaderInitializer"); 37 | Object.defineProperty(exports, "preloadResource", { enumerable: true, get: function () { return dataReaderInitializer_1.initializeDataReader; } }); 38 | const AsyncResourceContent_1 = __importDefault(require("./AsyncResourceContent")); 39 | exports.AsyncResourceContent = AsyncResourceContent_1.default; 40 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /lib/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,0CAAwB;AAExB,yDAAsD;AAOpD,iGAPO,mCAAgB,OAOP;AANlB,6DAA+C;AAQ7C,oCAAY;AAPd,mCAAwC;AAQtC,8FARO,qBAAa,OAQP;AAPf,mEAAkF;AAKhF,gGAL+B,4CAAe,OAK/B;AAJjB,kFAA0D;AAOxD,+BAPK,8BAAoB,CAOL"} -------------------------------------------------------------------------------- /lib/types.d.ts: -------------------------------------------------------------------------------- 1 | export declare type ApiFn = (...args: A) => Promise; 2 | export declare type UpdaterFn = (...args: A) => void; 3 | declare type DataFn = () => R; 4 | declare type LazyDataFn = () => R | undefined; 5 | export declare type ModifierFn = (response: R) => M; 6 | declare type ModifiedDataFn = (modifier: ModifierFn) => M; 7 | declare type LazyModifiedDataFn = (modifier: ModifierFn) => M | undefined; 8 | export declare type DataOrModifiedFn = DataFn & ModifiedDataFn; 9 | export declare type LazyDataOrModifiedFn = LazyDataFn & LazyModifiedDataFn; 10 | export {}; 11 | -------------------------------------------------------------------------------- /lib/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | //# sourceMappingURL=types.js.map -------------------------------------------------------------------------------- /lib/types.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""} -------------------------------------------------------------------------------- /lib/useAsyncResource.d.ts: -------------------------------------------------------------------------------- 1 | import { ApiFn, UpdaterFn, DataOrModifiedFn, LazyDataOrModifiedFn } from './types'; 2 | export declare function useAsyncResource(apiFunction: ApiFn): [LazyDataOrModifiedFn, UpdaterFn]; 3 | export declare function useAsyncResource(apiFunction: ApiFn, eagerLoading: never[]): [DataOrModifiedFn, UpdaterFn]; 4 | export declare function useAsyncResource(apiFunction: ApiFn, ...parameters: ArgTypes): [DataOrModifiedFn, UpdaterFn]; 5 | -------------------------------------------------------------------------------- /lib/useAsyncResource.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.useAsyncResource = void 0; 4 | const react_1 = require("react"); 5 | const dataReaderInitializer_1 = require("./dataReaderInitializer"); 6 | function useAsyncResource(apiFunction, ...parameters) { 7 | const dataReaderObj = (0, react_1.useRef)(() => undefined); 8 | (0, react_1.useMemo)(() => { 9 | if (parameters.length) { 10 | if (!apiFunction.length && 11 | parameters.length === 1 && 12 | Array.isArray(parameters[0]) && 13 | parameters[0].length === 0) { 14 | dataReaderObj.current = (0, dataReaderInitializer_1.initializeDataReader)(apiFunction); 15 | } 16 | else { 17 | dataReaderObj.current = (0, dataReaderInitializer_1.initializeDataReader)(apiFunction, ...parameters); 18 | } 19 | } 20 | }, [apiFunction, ...parameters]); 21 | const [, forceRender] = (0, react_1.useState)(0); 22 | const updaterFn = (0, react_1.useCallback)((...newParameters) => { 23 | dataReaderObj.current = (0, dataReaderInitializer_1.initializeDataReader)(apiFunction, ...newParameters); 24 | forceRender(ct => 1 - ct); 25 | }, [apiFunction]); 26 | return [dataReaderObj.current, updaterFn]; 27 | } 28 | exports.useAsyncResource = useAsyncResource; 29 | //# sourceMappingURL=useAsyncResource.js.map -------------------------------------------------------------------------------- /lib/useAsyncResource.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"useAsyncResource.js","sourceRoot":"","sources":["../src/useAsyncResource.ts"],"names":[],"mappings":";;;AAAA,iCAA+D;AAQ/D,mEAA+D;AAiD/D,SAAgB,gBAAgB,CAC9B,WAAgE,EAChE,GAAG,UAAoB;IAIvB,MAAM,aAAa,GAAG,IAAA,cAAM,EAAsE,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IAGnH,IAAA,eAAO,EAAC,GAAG,EAAE;QACX,IAAI,UAAU,CAAC,MAAM,EAAE;YAErB,IAEE,CAAC,WAAW,CAAC,MAAM;gBAEnB,UAAU,CAAC,MAAM,KAAK,CAAC;gBACvB,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;gBAC5B,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,EAC1B;gBACA,aAAa,CAAC,OAAO,GAAG,IAAA,4CAAoB,EAAC,WAAkC,CAAC,CAAC;aAClF;iBAAM;gBAEL,aAAa,CAAC,OAAO,GAAG,IAAA,4CAAoB,EAC1C,WAA4C,EAC5C,GAAG,UAAU,CACd,CAAC;aACH;SACF;IACH,CAAC,EAAE,CAAC,WAAW,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC;IAGjC,MAAM,CAAC,EAAE,WAAW,CAAC,GAAG,IAAA,gBAAQ,EAAC,CAAC,CAAC,CAAC;IAEpC,MAAM,SAAS,GAAG,IAAA,mBAAW,EAAC,CAAC,GAAG,aAAuB,EAAE,EAAE;QAE3D,aAAa,CAAC,OAAO,GAAG,IAAA,4CAAoB,EAAC,WAA4C,EAAE,GAAG,aAAa,CAAC,CAAC;QAE7G,WAAW,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;IAC5B,CAAC,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC;IAElB,OAAO,CAAC,aAAa,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;AAC5C,CAAC;AA1CD,4CA0CC"} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-async-resource", 3 | "version": "2.2.2", 4 | "description": "A custom React hook for simple data fetching with React Suspense", 5 | "keywords": [ 6 | "react", 7 | "reactjs", 8 | "async", 9 | "data", 10 | "fetch", 11 | "cache", 12 | "suspense", 13 | "hooks", 14 | "custom-hook", 15 | "react-hook", 16 | "react-hooks", 17 | "react-cache", 18 | "react-suspense", 19 | "data-fetching" 20 | ], 21 | "author": { 22 | "name": "Andrei Duca", 23 | "email": "duca.andrei@gmail.com" 24 | }, 25 | "license": "ISC", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/andreiduca/use-async-resource.git" 29 | }, 30 | "bugs": "https://github.com/andreiduca/use-async-resource/issues", 31 | "main": "./lib/index.js", 32 | "types": "./lib/index.d.ts", 33 | "scripts": { 34 | "build": "rm -rf ./lib && tsc", 35 | "test": "jest --verbose --coverage" 36 | }, 37 | "dependencies": { 38 | "object-hash": "^2.0.3" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.9.0", 42 | "@babel/preset-env": "^7.9.5", 43 | "@babel/preset-typescript": "^7.9.0", 44 | "@testing-library/dom": "8", 45 | "@testing-library/react-hooks": "^7.0.1", 46 | "@types/jest": "27", 47 | "@types/react": "16 || 17", 48 | "@types/react-dom": "16 || 17", 49 | "babel-jest": "27", 50 | "jest": "27", 51 | "react": "16 || 17 || 18", 52 | "react-test-renderer": "16 || 17", 53 | "typescript": "4" 54 | }, 55 | "peerDependencies": { 56 | "react": "16 || 17 || 18" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/AsyncResourceContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import AsyncResourceErrorBoundary, { Props as ErrorBoundaryProps } from './AsyncResourceErrorBoundary'; 3 | 4 | interface AsyncResourceContentProps { 5 | fallback: NonNullable | null; 6 | errorComponent?: React.ComponentType; 7 | } 8 | 9 | type Props = AsyncResourceContentProps & ErrorBoundaryProps; 10 | 11 | const AsyncResourceContent = ({ 12 | children, 13 | fallback, 14 | errorMessage, 15 | errorComponent: ErrorComponent, 16 | }: React.PropsWithChildren>) => { 17 | const ErrorBoundary = ErrorComponent || AsyncResourceErrorBoundary; 18 | 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default AsyncResourceContent; 29 | -------------------------------------------------------------------------------- /src/AsyncResourceErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface State { 4 | error?: Error, 5 | errorMessage?: string; 6 | } 7 | 8 | export interface Props { 9 | errorMessage?: React.ReactComponentElement | string | ((error: E) => string | React.ReactComponentElement); 10 | // todo: flag to reset the error and errorMessage states and try to render the content again 11 | // retry?: boolean; 12 | } 13 | 14 | class AsyncResourceErrorBoundary extends React.Component, State> { 15 | public static getDerivedStateFromError(error: Error) { 16 | return { error }; 17 | } 18 | 19 | public static getDerivedStateFromProps({ errorMessage }: Props, state: State) { 20 | if (state.error) { 21 | return { 22 | errorMessage: typeof errorMessage === 'function' 23 | ? errorMessage(state.error) 24 | : (errorMessage || state.error.message), 25 | }; 26 | } 27 | return state; 28 | } 29 | 30 | // todo: log this somewhere 31 | // public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {} 32 | 33 | constructor(props: Props) { 34 | super(props); 35 | 36 | this.state = {}; 37 | } 38 | 39 | public render() { 40 | if (this.state.errorMessage) { 41 | return this.state.errorMessage; 42 | } 43 | 44 | return this.props.children; 45 | } 46 | } 47 | 48 | export default AsyncResourceErrorBoundary; 49 | -------------------------------------------------------------------------------- /src/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { DataOrModifiedFn } from './types'; 2 | import { resourceCache } from './cache'; 3 | 4 | describe('resourceCache', () => { 5 | const apiFn = (_: number) => Promise.resolve(true); 6 | 7 | it('should not get a cached resource', async () => { 8 | expect(resourceCache(apiFn).get()).toBe(undefined); 9 | expect(resourceCache(apiFn).get(1)).toBe(undefined); 10 | }); 11 | 12 | it('should cache a new resource', async () => { 13 | function getData() { return true; } 14 | resourceCache(apiFn).set(getData as DataOrModifiedFn, 1); 15 | 16 | expect(resourceCache(apiFn).get(1)).toBe(getData); 17 | }); 18 | 19 | it('should delete a cached resource', async () => { 20 | function getData() { return true; } 21 | resourceCache(apiFn).set(getData as DataOrModifiedFn, 1); 22 | 23 | resourceCache(apiFn).delete(1); 24 | expect(resourceCache(apiFn).get(1)).toBe(undefined); 25 | }); 26 | 27 | it('should clear all cached resources', async () => { 28 | function getData1() { return true; } 29 | function getData2() { return true; } 30 | resourceCache(apiFn).set(getData1 as DataOrModifiedFn, 1); 31 | resourceCache(apiFn).set(getData2 as DataOrModifiedFn, 2); 32 | 33 | expect(resourceCache(apiFn).get(1)).toBe(getData1); 34 | expect(resourceCache(apiFn).get(2)).toBe(getData2); 35 | 36 | resourceCache(apiFn).clear(); 37 | 38 | expect(resourceCache(apiFn).get(1)).toBe(undefined); 39 | expect(resourceCache(apiFn).get(2)).toBe(undefined); 40 | }); 41 | 42 | it('should not collide with other api functions', async () => { 43 | const apiFn2 = (_: number) => Promise.resolve(true); 44 | function getData() { return true; } 45 | 46 | resourceCache(apiFn).set(getData as DataOrModifiedFn, 1); 47 | 48 | expect(resourceCache(apiFn).get(1)).toBe(getData); 49 | expect(resourceCache(apiFn2).get(1)).toBe(undefined); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import hash from 'object-hash'; 3 | import { ApiFn, DataOrModifiedFn } from './types'; 4 | 5 | // keep separate caches for each api function 6 | const caches = new Map(); 7 | 8 | // todo: implement a LRU maybe? 9 | 10 | // A simple resource cache helper. 11 | // Caches are kept individually for each api function. 12 | export function resourceCache(apiFn: ApiFn) { 13 | // initialize a new cache for this api function if it doesn't exist 14 | if (!caches.has(apiFn)) { 15 | caches.set(apiFn, new Map()); 16 | } 17 | 18 | // get the cache for this api function 19 | const apiCache: Map> = caches.get(apiFn); 20 | 21 | // return an object with helper methods to manage the cache for this api function 22 | return { 23 | // gets the cached data reader for the given params 24 | get(...params: A | never[]) { 25 | return apiCache.get(hash(params)); 26 | }, 27 | // caches the data reader for the given params 28 | set(dataFn: DataOrModifiedFn, ...params: A | never[]) { 29 | return apiCache.set(hash(params), dataFn); 30 | }, 31 | // deletes the cached data reader for the given params 32 | delete(...params: A | never[]) { 33 | return apiCache.delete(hash(params)); 34 | }, 35 | // clears the entire cache for this api function 36 | clear() { 37 | caches.delete(apiFn); 38 | return apiCache.clear(); 39 | }, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/dataReaderInitializer.test.ts: -------------------------------------------------------------------------------- 1 | import { initializeDataReader } from './dataReaderInitializer'; 2 | import { resourceCache } from './cache'; 3 | import { suspendFor } from './test.helpers'; 4 | 5 | describe('initializeDataReader', () => { 6 | const apiFn = (id: number) => Promise.resolve({ id, name: 'test name' }); 7 | const apiFn2 = (id: number) => Promise.resolve({ id, title: 'some title' }); 8 | const apiFailureFn = (id: number) => Promise.reject(`User ${id} not found`); 9 | 10 | afterEach(() => { 11 | resourceCache(apiFn).clear(); 12 | resourceCache(apiFailureFn).clear(); 13 | }); 14 | 15 | it('should create a new data reader', async () => { 16 | const dataReader = initializeDataReader(apiFn, 1); 17 | 18 | // "Suspend" until the promise is fulfilled 19 | await suspendFor(dataReader); 20 | 21 | // now we should be able to get the raw data from the function 22 | expect(dataReader()).toStrictEqual({ id: 1, name: 'test name' }); 23 | }); 24 | 25 | it('should create an erroneous data reader', async () => { 26 | const dataReader = initializeDataReader(apiFailureFn, 1); 27 | 28 | // "Suspend" until the promise is fulfilled 29 | await suspendFor(dataReader); 30 | 31 | // the data reader should throw an error because the promise failed 32 | expect(dataReader).toThrowError(Error('User 1 not found')); 33 | }); 34 | 35 | it('a data reader should accept an optional modifier function', async () => { 36 | const dataReader = initializeDataReader(apiFn, 1); 37 | await suspendFor(dataReader); 38 | 39 | // a "modifier" function 40 | // only returns the id of the user 41 | function getId(user: { id: number, name: string }) { 42 | return user.id; 43 | } 44 | 45 | // should only return the user id 46 | expect(dataReader(getId)).toStrictEqual(1); 47 | }); 48 | 49 | it('a data reader should be cached and reused', async () => { 50 | const dataReader = initializeDataReader(apiFn, 1); 51 | await suspendFor(dataReader); 52 | 53 | // initialize a new data reader with the same params 54 | const similarDataReader = initializeDataReader(apiFn, 1); 55 | 56 | // because it was previously cached, the new data reader is immediately available as a synchronous read 57 | expect(similarDataReader).not.toThrow(); 58 | 59 | // ...and it's actually the exact same function as the previous data reader 60 | expect(similarDataReader).toStrictEqual(dataReader); 61 | }); 62 | 63 | it('a cached data reader should be unique', async () => { 64 | const dataReader = initializeDataReader(apiFn, 1); 65 | await suspendFor(dataReader); 66 | 67 | // initializing with other params 68 | const dataReader2 = initializeDataReader(apiFn, 2); 69 | // we will need to wait for it to resolve 70 | await suspendFor(dataReader2); 71 | 72 | // the resulting data reader is different than the previous one 73 | expect(dataReader2).not.toStrictEqual(dataReader); 74 | expect(dataReader2(u => u.id)).toStrictEqual(2); 75 | 76 | 77 | // initializing from a different api function, but same params 78 | const dataReader3 = initializeDataReader(apiFn2, 1); 79 | // we also need to wait for it to resolve 80 | await suspendFor(dataReader3); 81 | 82 | // the resulting data reader is different than everything cached before 83 | expect(dataReader3).not.toStrictEqual(dataReader); 84 | // as well as the data returned 85 | expect(dataReader3()).not.toStrictEqual(dataReader()); 86 | }); 87 | 88 | it('clearing the cache should not reuse the data reader', async () => { 89 | const dataReader = initializeDataReader(apiFn, 1); 90 | await suspendFor(dataReader); 91 | 92 | // clear the cache for this resource 93 | resourceCache(apiFn).delete(1); 94 | 95 | // initialize a new data reader with the same params 96 | const similarDataReader = initializeDataReader(apiFn, 1); 97 | // the new data reader has to resolve 98 | await suspendFor(similarDataReader); 99 | 100 | // the data readers are different function 101 | expect(similarDataReader).not.toStrictEqual(dataReader); 102 | 103 | // ...but the data returned should be the same 104 | expect(similarDataReader()).toStrictEqual(dataReader()); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/dataReaderInitializer.ts: -------------------------------------------------------------------------------- 1 | import { ApiFn, DataOrModifiedFn, ModifierFn } from './types'; 2 | import { resourceCache } from './cache'; 3 | 4 | /** 5 | * Wrapper for an apiFunction without params. 6 | * It only takes the api function as an argument. 7 | * It returns a data reader with an optional modifier function. 8 | * 9 | * @param apiFn A typical api function that doesn't take any parameters. 10 | */ 11 | export function initializeDataReader( 12 | apiFn: ApiFn, 13 | ): DataOrModifiedFn; 14 | 15 | /** 16 | * Wrapper for an apiFunction with params. 17 | * It takes the api function and all its expected arguments. 18 | * Also returns a data reader with an optional modifier function. 19 | * 20 | * @param apiFn A typical api function with parameters. 21 | * @param parameters An arbitrary number of parameters. 22 | */ 23 | export function initializeDataReader( 24 | apiFn: ApiFn, 25 | ...parameters: ArgTypes 26 | ): DataOrModifiedFn; 27 | 28 | // implementation that covers the above overloads 29 | export function initializeDataReader( 30 | apiFn: ApiFn, 31 | ...parameters: ArgTypes 32 | ) { 33 | type AsyncStatus = 'init' | 'done' | 'error'; 34 | 35 | const apiFnCache = resourceCache(apiFn); 36 | const cachedResource = apiFnCache.get(...parameters); 37 | 38 | if (cachedResource) { 39 | return cachedResource; 40 | } 41 | 42 | let data: ResponseType; 43 | let status: AsyncStatus = 'init'; 44 | let error: unknown; 45 | 46 | const fetchingPromise = apiFn(...parameters) 47 | .then((result) => { 48 | data = result; 49 | status = 'done'; 50 | return result; 51 | }) 52 | .catch((err) => { 53 | error = err; 54 | status = 'error'; 55 | }); 56 | 57 | // the return type successfully satisfies DataOrModifiedFn 58 | function dataReaderFn(): ResponseType; 59 | function dataReaderFn(modifier: ModifierFn): M; 60 | function dataReaderFn(modifier?: ModifierFn) { 61 | if (status === 'init') { 62 | throw fetchingPromise; 63 | } else if (status === 'error') { 64 | throw error; 65 | } 66 | 67 | return typeof modifier === 'function' 68 | ? (modifier(data) as M) 69 | : (data as ResponseType); 70 | } 71 | 72 | apiFnCache.set(dataReaderFn, ...parameters); 73 | 74 | return dataReaderFn; 75 | } 76 | -------------------------------------------------------------------------------- /src/fileResource.ts: -------------------------------------------------------------------------------- 1 | // An image file resource helper. 2 | // It preloads an image in the background. 3 | // The returned Promise resolves once the image is downloaded. 4 | export function image(imageSrc: string) { 5 | return new Promise((resolve, reject) => { 6 | const file = new Image(); 7 | file.onload = () => { 8 | resolve(imageSrc); 9 | }; 10 | 11 | file.onerror = reject; 12 | 13 | file.src = imageSrc; 14 | }); 15 | } 16 | 17 | // A script resource helper. 18 | // It creates a script tag and injects it into the DOM. 19 | // The returned Promise resolves once the script is loaded. 20 | export function script(scriptSrc: string) { 21 | return new Promise((resolve, reject) => { 22 | const file = document.createElement('script'); 23 | file.onload = () => { 24 | resolve(scriptSrc); 25 | }; 26 | 27 | file.onerror = reject; 28 | 29 | file.src = scriptSrc; 30 | 31 | document.getElementsByTagName('body')[0].appendChild(file); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | 3 | import { useAsyncResource } from './useAsyncResource'; 4 | import * as fileResource from './fileResource'; 5 | import { resourceCache } from './cache'; 6 | import { initializeDataReader as preloadResource } from './dataReaderInitializer'; 7 | import AsyncResourceContent from './AsyncResourceContent'; 8 | 9 | export { 10 | useAsyncResource, 11 | preloadResource, 12 | fileResource, 13 | resourceCache, 14 | AsyncResourceContent, 15 | } 16 | -------------------------------------------------------------------------------- /src/test.helpers.ts: -------------------------------------------------------------------------------- 1 | // mimics how Suspense treats thrown promises 2 | export async function suspendFor unknown>(throwablePromise: T) { 3 | // initially, the data reader throws the new promise 4 | expect(throwablePromise).toThrow(); 5 | 6 | try { 7 | // calling the function will throw the promise 8 | throwablePromise(); 9 | } catch(tp) { 10 | // Suspense will catch it and wait its fulfilment 11 | await tp; 12 | expect('awaited successfully').toBeTruthy(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A typical api function: takes an arbitrary number of arguments of type A 3 | * and returns a Promise which resolves with a specific response type of R. 4 | */ 5 | export type ApiFn = (...args: A) => Promise; 6 | 7 | /** 8 | * An updater function: has a similar signature with the original api function, 9 | * but doesn't return anything because it only triggers new api calls. 10 | */ 11 | export type UpdaterFn = (...args: A) => void; 12 | 13 | /** 14 | * A simple data reader function: returns the response type R. 15 | */ 16 | type DataFn = () => R; 17 | /** 18 | * A lazy data reader function: can return the response type R or `undefined`. 19 | */ 20 | type LazyDataFn = () => R | undefined; 21 | 22 | /** 23 | * A modifier function which takes as only argument the response type R and returns a different type M. 24 | */ 25 | export type ModifierFn = (response: R) => M; 26 | 27 | /** 28 | * A data reader with a modifier function, 29 | * returning the modified type M instead of the response type R. 30 | */ 31 | type ModifiedDataFn = (modifier: ModifierFn) => M; 32 | /** 33 | * A lazy data reader with a modifier function, 34 | * returning the modified type M instead of the response type R, or `undefined`. 35 | */ 36 | type LazyModifiedDataFn = (modifier: ModifierFn) => M | undefined; 37 | 38 | // Finally, our actual eager and lazy implementations will use both versions (with and without a modifier function), 39 | // so we need overloaded types that will satisfy them simultaneously 40 | 41 | /** 42 | * A data reader function with an optional modifier function. 43 | */ 44 | export type DataOrModifiedFn = DataFn & ModifiedDataFn; 45 | /** 46 | * A lazy data reader function with an optional modifier function. 47 | */ 48 | export type LazyDataOrModifiedFn = LazyDataFn & LazyModifiedDataFn; 49 | -------------------------------------------------------------------------------- /src/useAsyncResource.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { waitFor } from '@testing-library/dom'; 3 | 4 | import { useAsyncResource } from './useAsyncResource'; 5 | import { resourceCache } from './cache'; 6 | import { initializeDataReader as preloadResource } from './dataReaderInitializer'; 7 | import { suspendFor } from './test.helpers'; 8 | 9 | describe('useAsyncResource', () => { 10 | const apiFn = (id: number) => Promise.resolve({ id, name: 'test name' }); 11 | const apiSimpleFn = () => Promise.resolve({ message: 'success' }); 12 | const apiFailingFn = () => Promise.reject({ message: 'error' }); 13 | 14 | afterEach(() => { 15 | resourceCache(apiFn).clear(); 16 | resourceCache(apiSimpleFn).clear(); 17 | resourceCache(apiFailingFn).clear(); 18 | }); 19 | 20 | it('should create a new data reader', async () => { 21 | // get the data reader from the custom hook, with params 22 | const { result } = renderHook(() => useAsyncResource(apiFn, 1)); 23 | const [dataReader] = result.current; 24 | 25 | // wait for it to fulfill 26 | await suspendFor(dataReader); 27 | 28 | // should be able to get raw data from the data reader 29 | expect(dataReader()).toStrictEqual({ id: 1, name: 'test name' }); 30 | 31 | // same for api functions without params 32 | const { result: simpleResult } = renderHook(() => useAsyncResource(apiSimpleFn, [])); 33 | const [simpleData] = simpleResult.current; 34 | await suspendFor(simpleData); 35 | expect(simpleData()).toStrictEqual({ message: 'success' }); 36 | }); 37 | 38 | it('should throw an error', async () => { 39 | const { result } = renderHook(() => useAsyncResource(apiFailingFn, [])); 40 | const [dataReader] = result.current; 41 | 42 | // wait for it to fulfill 43 | await suspendFor(dataReader); 44 | 45 | expect(dataReader).toThrowError(Error('error')); 46 | }); 47 | 48 | it('should trigger an update for the data reader', async () => { 49 | // get the data reader and the updater function from the custom hook 50 | const { result } = renderHook(() => useAsyncResource(apiFn, 1)); 51 | const [dataReader, updateDataReader] = result.current; 52 | 53 | // wait for it to fulfill 54 | await suspendFor(dataReader); 55 | 56 | // make sure we're able to get raw data from it 57 | expect(dataReader(u => u.id)).toStrictEqual(1); 58 | 59 | // call the updater function with new params 60 | act(() => updateDataReader(2)); 61 | 62 | // this should generate a brand new data reader 63 | const [newDataReader] = result.current; 64 | // we will need to wait for its fulfillment 65 | await suspendFor(newDataReader); 66 | 67 | // check that it's indeed a new one 68 | expect(newDataReader).not.toStrictEqual(dataReader); 69 | // and that it returns different data 70 | expect(newDataReader(u => u.id)).toStrictEqual(2); 71 | }); 72 | 73 | it('should reuse a cached data reader', async () => { 74 | // get the data reader and the updater function from the custom hook 75 | const { result } = renderHook(() => useAsyncResource(apiFn, 1)); 76 | const [dataReader, updateDataReader] = result.current; 77 | 78 | // wait for it to fulfill 79 | await suspendFor(dataReader); 80 | 81 | // call the updater function with new params 82 | act(() => updateDataReader(2)); 83 | 84 | // this should generate a brand new data reader 85 | const [newDataReader] = result.current; 86 | // we will need to wait for its fulfillment 87 | await suspendFor(newDataReader); 88 | 89 | // call the updater one more time, but with the previous param 90 | act(() => updateDataReader(1)); 91 | 92 | // the new data reader should use the previously cached version 93 | const [cachedDataReader] = result.current; 94 | // so nothing to wait for 95 | expect(cachedDataReader).not.toThrow(); 96 | 97 | // make sure it's the exact same as the very first one 98 | expect(cachedDataReader).toStrictEqual(dataReader); 99 | // and that it returns the same data 100 | expect(cachedDataReader(u => u.id)).toStrictEqual(1); 101 | }); 102 | 103 | it('should create a lazy data reader', async () => { 104 | // initialize a lazy data reader 105 | const { result } = renderHook(() => useAsyncResource(apiFn)); 106 | const [dataReader, updateDataReader] = result.current; 107 | 108 | // it should be available immediately, but should return `undefined` 109 | expect(dataReader).not.toThrow(); 110 | expect(dataReader()).toStrictEqual(undefined); 111 | 112 | // triggering an api call 113 | act(() => updateDataReader(1)); 114 | const [updatedDataReader] = result.current; 115 | 116 | // requires waiting for a fulfillment 117 | await suspendFor(updatedDataReader); 118 | 119 | // finally, we should have some data available 120 | expect(updatedDataReader(u => u.id)).toStrictEqual(1); 121 | }); 122 | 123 | it('should call the api function again if the cache is cleared', async () => { 124 | // get the data reader and the updater function from the custom hook 125 | const { result } = renderHook(() => useAsyncResource(apiFn, 1)); 126 | const [dataReader, updateDataReader] = result.current; 127 | await suspendFor(dataReader); 128 | 129 | // clear the cache before calling the updater with the previous param 130 | resourceCache(apiFn).delete(1); 131 | 132 | // call the updater function with same params 133 | act(() => updateDataReader(1)); 134 | // this should generate a brand new data reader 135 | const [newDataReader] = result.current; 136 | // and we will need to wait for its fulfillment 137 | await suspendFor(newDataReader); 138 | 139 | // make sure it's different than the first one 140 | expect(newDataReader).not.toStrictEqual(dataReader); 141 | // but that it returns the same data 142 | expect(newDataReader(u => u.id)).toStrictEqual(1); 143 | }); 144 | 145 | it('should trigger new api calls if the params of the hook change', async () => { 146 | // get the data reader and the updater function, injecting a prop that we'll update later 147 | const { result, rerender } = renderHook( 148 | ({ paramId }) => useAsyncResource(apiFn, paramId), 149 | { initialProps: { paramId: 1 }}, 150 | ); 151 | 152 | // check that it suspends and it resolves with the expected data 153 | let [dataReader] = result.current; 154 | await suspendFor(dataReader); 155 | expect(dataReader()).toStrictEqual({ id: 1, name: 'test name' }); 156 | 157 | // re-render with new props 158 | rerender({ paramId: 2 }); 159 | 160 | // check that it suspends again and renders with new data 161 | const [newDataReader] = result.current; 162 | await suspendFor(newDataReader); 163 | expect(newDataReader()).toStrictEqual({ id: 2, name: 'test name' }); 164 | }); 165 | 166 | it('should persist the data reader between renders - for api function with params', async () => { 167 | // get the data reader and the updater function 168 | const { result, rerender } = renderHook( 169 | ({ paramId }) => useAsyncResource(apiFn, paramId), 170 | { initialProps: { paramId: 1 }}, 171 | ); 172 | 173 | // check that it suspends and it resolves with the expected data 174 | let [dataReader] = result.current; 175 | await suspendFor(dataReader); 176 | expect(dataReader()).toStrictEqual({ id: 1, name: 'test name' }); 177 | 178 | // re-render with same props 179 | rerender({ paramId: 1 }); 180 | 181 | // check that it doesn't suspend again and the data reader is reused 182 | const [newDataReader] = result.current; 183 | expect(newDataReader).toStrictEqual(dataReader); 184 | }); 185 | 186 | it('should persist the data reader between renders - for api function without params', async () => { 187 | // get the data reader and the updater function 188 | const { result, rerender } = renderHook(() => useAsyncResource(apiSimpleFn, [])); 189 | 190 | // check that it suspends and it resolves with the expected data 191 | let [dataReader] = result.current; 192 | await suspendFor(dataReader); 193 | expect(dataReader()).toStrictEqual({ message: 'success' }); 194 | 195 | // render again 196 | rerender(); 197 | 198 | // check that it doesn't suspend again and the data reader is reused 199 | const [newDataReader] = result.current; 200 | expect(newDataReader()).toStrictEqual(dataReader()); 201 | // expect(newDataReader).toStrictEqual(dataReader); 202 | }); 203 | 204 | it('should preload a resource before rendering', async () => { 205 | // start preloading the resource 206 | preloadResource(apiSimpleFn); 207 | 208 | // expect the resource to load faster than the component that will consume it 209 | const preloadedResource = resourceCache(apiSimpleFn).get(); 210 | if (preloadedResource) { 211 | await waitFor(() => preloadedResource()); 212 | } 213 | 214 | // a component consuming the preloaded resource should have the data readily available 215 | const { result } = renderHook(() => useAsyncResource(apiSimpleFn, [])); 216 | let [dataReader] = result.current; 217 | expect(dataReader()).toStrictEqual({ message: 'success' }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/useAsyncResource.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef, useState } from 'react'; 2 | 3 | import { 4 | ApiFn, 5 | UpdaterFn, 6 | DataOrModifiedFn, 7 | LazyDataOrModifiedFn, 8 | } from './types'; 9 | import { initializeDataReader } from './dataReaderInitializer'; 10 | 11 | /** 12 | * Lazy initializer. 13 | * The only param passed is the api function that will be wrapped. 14 | * The returned data reader LazyDataOrModifiedFn is "lazy", 15 | * meaning it can return `undefined` if the api call hasn't started. 16 | * The returned updater function UpdaterFn 17 | * can take any number of arguments, just like the wrapped api function 18 | * 19 | * @param apiFunction A typical api function. 20 | */ 21 | export function useAsyncResource( 22 | apiFunction: ApiFn, 23 | ): [LazyDataOrModifiedFn, UpdaterFn]; 24 | 25 | /** 26 | * Eager initializer for an api function without params. 27 | * The second param must be `[]` to indicate we want to start the api call immediately. 28 | * The returned data reader DataOrModifiedFn is "eager", 29 | * meaning it will always return the ResponseType 30 | * (or a modified version of it, if requested). 31 | * The returned updater function doesn't take any arguments, 32 | * just like the wrapped api function 33 | * 34 | * @param apiFunction A typical api function that doesn't take any parameters. 35 | * @param eagerLoading If present, the api function will get executed immediately. 36 | */ 37 | export function useAsyncResource( 38 | apiFunction: ApiFn, 39 | eagerLoading: never[], // the type of an empty array `[]` is `never[]` 40 | ): [DataOrModifiedFn, UpdaterFn]; 41 | 42 | /** 43 | * Eager initializer for an api function with params. 44 | * The returned data reader is "eager", meaning it will return the ResponseType 45 | * (or a modified version of it, if requested). 46 | * The returned updater function can take any number of arguments, 47 | * just like the wrapped api function 48 | * 49 | * @param apiFunction A typical api function with an arbitrary number of parameters. 50 | * @param parameters If present, the api function will get executed immediately with these parameters. 51 | */ 52 | export function useAsyncResource( 53 | apiFunction: ApiFn, 54 | ...parameters: ArgTypes 55 | ): [DataOrModifiedFn, UpdaterFn]; 56 | 57 | // implementation that covers the above overloads 58 | export function useAsyncResource( 59 | apiFunction: ApiFn | ApiFn, 60 | ...parameters: ArgTypes 61 | ) { 62 | // keep the data reader inside a mutable object ref 63 | // always initialize with a lazy data reader, as it can be overwritten by the useMemo immediately 64 | const dataReaderObj = useRef | LazyDataOrModifiedFn>(() => undefined); 65 | 66 | // like useEffect, but runs immediately 67 | useMemo(() => { 68 | if (parameters.length) { 69 | // eager initialization for api functions that don't accept arguments 70 | if ( 71 | // check that the api function doesn't take any arguments 72 | !apiFunction.length && 73 | // but the user passed an empty array as the only parameter 74 | parameters.length === 1 && 75 | Array.isArray(parameters[0]) && 76 | parameters[0].length === 0 77 | ) { 78 | dataReaderObj.current = initializeDataReader(apiFunction as ApiFn); 79 | } else { 80 | // eager initialization for all other cases 81 | dataReaderObj.current = initializeDataReader( 82 | apiFunction as ApiFn, 83 | ...parameters, 84 | ); 85 | } 86 | } 87 | }, [apiFunction, ...parameters]); 88 | 89 | // state to force re-render 90 | const [, forceRender] = useState(0); 91 | 92 | const updaterFn = useCallback((...newParameters: ArgTypes) => { 93 | // update the object ref 94 | dataReaderObj.current = initializeDataReader(apiFunction as ApiFn, ...newParameters); 95 | // update state to force a re-render 96 | forceRender(ct => 1 - ct); 97 | }, [apiFunction]); 98 | 99 | return [dataReaderObj.current, updaterFn]; 100 | } 101 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "target": "es2015", 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "noImplicitAny": true, 8 | "strictNullChecks": true, 9 | "removeComments": true, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "jsx": "react", 13 | "typeRoots": [ 14 | "node_modules/@types" 15 | ] 16 | }, 17 | "include": [ 18 | "src" 19 | ], 20 | "exclude": [ 21 | "src/*.test.ts", 22 | "src/test.*.ts", 23 | "lib", 24 | "node_modules" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------