├── .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 | getNewUser(2)}>Get user with id 2
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 | toggleList(true)}
248 | // 👉 we can preload the resource as soon as the user
249 | // shows any intent of interacting with the button
250 | onMouseOver={() => preloadResource(fetchUserPosts, props.user.id)}
251 | >
252 | show user posts
253 |
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 |
--------------------------------------------------------------------------------