@kirill.konshin/react 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +3 -3
- package/CHANGELOG.md +6 -0
- package/README.md +56 -0
- package/dist/useFetch.d.ts +28 -1
- package/dist/useFetch.d.ts.map +1 -1
- package/dist/useFetch.js +51 -5
- package/dist/useFetch.js.map +1 -1
- package/dist/useFetch.test.d.ts +2 -0
- package/dist/useFetch.test.d.ts.map +1 -0
- package/package.json +16 -4
- package/src/useFetch.test.tsx +424 -0
- package/src/useFetch.ts +91 -11
package/.turbo/turbo-build.log
CHANGED
|
@@ -12,14 +12,14 @@ transforming...
|
|
|
12
12
|
rendering chunks...
|
|
13
13
|
|
|
14
14
|
[vite:dts] Start generate declaration files...
|
|
15
|
-
[2mdist/[22m[36museFetch.js [39m[1m[2m0.57 kB[22m[1m[22m[2m │ map: 1.69 kB[22m
|
|
16
15
|
[2mdist/[22m[36mindex.js [39m[1m[2m0.67 kB[22m[1m[22m[2m │ map: 0.10 kB[22m
|
|
17
16
|
[2mdist/[22m[36mapiCall.js [39m[1m[2m0.69 kB[22m[1m[22m[2m │ map: 1.45 kB[22m
|
|
18
17
|
[2mdist/[22m[36museFetcher.js [39m[1m[2m1.18 kB[22m[1m[22m[2m │ map: 2.62 kB[22m
|
|
19
18
|
[2mdist/[22m[36mkeyboard.js [39m[1m[2m1.27 kB[22m[1m[22m[2m │ map: 2.81 kB[22m
|
|
20
19
|
[2mdist/[22m[36mform/client.js [39m[1m[2m1.58 kB[22m[1m[22m[2m │ map: 3.88 kB[22m
|
|
20
|
+
[2mdist/[22m[36museFetch.js [39m[1m[2m2.04 kB[22m[1m[22m[2m │ map: 5.62 kB[22m
|
|
21
21
|
[2mdist/[22m[36mform/form.js [39m[1m[2m2.89 kB[22m[1m[22m[2m │ map: 7.94 kB[22m
|
|
22
|
-
[vite:dts] Declaration files built in
|
|
22
|
+
[vite:dts] Declaration files built in 2092ms.
|
|
23
23
|
|
|
24
|
-
[32m✓ built in
|
|
24
|
+
[32m✓ built in 6.76s[39m
|
|
25
25
|
Updated package.json with exports
|
package/CHANGELOG.md
CHANGED
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# `useFetch`
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { useFetch } from '@kirill.konshin/react';
|
|
6
|
+
|
|
7
|
+
const [data, actionFn, isPending, error] = useFetch(async (args) => {
|
|
8
|
+
return callToBackend(args);
|
|
9
|
+
}, defaultValue);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
actionFn({ id: 1 });
|
|
13
|
+
}, []);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { useEffect } from 'react';
|
|
18
|
+
import { useFetch } from '@kirill.konshin/react';
|
|
19
|
+
|
|
20
|
+
const dataRef = useRef(data);
|
|
21
|
+
|
|
22
|
+
const [data, actionFn, isPending, error] = useFetch(async (args) => {
|
|
23
|
+
const data = await callToBackend(args);
|
|
24
|
+
return [...dataRef.current, data];
|
|
25
|
+
}, defaultValue);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
dataRef.current = value;
|
|
29
|
+
}, [data]);
|
|
30
|
+
|
|
31
|
+
useEffect(async () => {
|
|
32
|
+
await actionFn({ id: 1 });
|
|
33
|
+
await actionFn({ id: 2 });
|
|
34
|
+
await actionFn({ id: 3 });
|
|
35
|
+
}, []);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { useEffect } from 'react';
|
|
40
|
+
import { useFetch } from '@kirill.konshin/react';
|
|
41
|
+
|
|
42
|
+
const [data, actionFn, isPending, error] = useFetch(
|
|
43
|
+
// first function has to be synchronous, second can be async
|
|
44
|
+
(args) => async (oldData) => {
|
|
45
|
+
const data = await callToBackend(args);
|
|
46
|
+
return [...oldData, data];
|
|
47
|
+
},
|
|
48
|
+
defaultValue,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
useEffect(async () => {
|
|
52
|
+
await actionFn({ id: 1 });
|
|
53
|
+
await actionFn({ id: 2 });
|
|
54
|
+
await actionFn({ id: 3 });
|
|
55
|
+
}, []);
|
|
56
|
+
```
|
package/dist/useFetch.d.ts
CHANGED
|
@@ -1,2 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* TODO useFetch https://use-http.com
|
|
3
|
+
* TODO SWR?
|
|
4
|
+
* TODO Tanstack Query?
|
|
5
|
+
*
|
|
6
|
+
* Uses same return array patterns as useActionState https://react.dev/reference/react/useActionState + error,
|
|
7
|
+
* reason: simple var rename
|
|
8
|
+
*
|
|
9
|
+
* Function can be async, then it will be awaited and result set to state.
|
|
10
|
+
*
|
|
11
|
+
* Function can be sync, then it will be called args, and it should return another function,which will be called with
|
|
12
|
+
* old data and result set to state. This is useful for pagination and merging data.
|
|
13
|
+
*
|
|
14
|
+
* @param {(...args: any[]) => Promise<R> | R | ((oldData: R) => Promise<R> | R))} fn
|
|
15
|
+
* @param {any} defaultValue
|
|
16
|
+
* @param fetchOnMount
|
|
17
|
+
* @param throwAfterUnmount - throw if component is unmounted after fetch completed
|
|
18
|
+
* @returns {[R, (...args: any[]) => Promise<R>, boolean, Error | undefined]} //, ReturnType<typeof useState>, ReturnType<typeof useState>
|
|
19
|
+
*/
|
|
20
|
+
export declare function useFetch<R>(fn: (...args: any[]) => Promise<R> | R | ((oldData: R) => Promise<R> | R), defaultValue: R, { fetchOnMount, throwAfterUnmount, }?: {
|
|
21
|
+
fetchOnMount?: boolean;
|
|
22
|
+
throwAfterUnmount?: boolean;
|
|
23
|
+
}): [
|
|
24
|
+
R,
|
|
25
|
+
(...args: Parameters<typeof fn>) => Promise<R>,
|
|
26
|
+
boolean,
|
|
27
|
+
Error | undefined
|
|
28
|
+
];
|
|
2
29
|
//# sourceMappingURL=useFetch.d.ts.map
|
package/dist/useFetch.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useFetch.d.ts","sourceRoot":"","sources":["../src/useFetch.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useFetch.d.ts","sourceRoot":"","sources":["../src/useFetch.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EACtB,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,EACzE,YAAY,EAAE,CAAC,EACf,EACI,YAAoB,EACpB,iBAAyB,GAC5B,GAAE;IACC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC1B,GACP;IACC,CAAC;IACD,CAAC,GAAG,IAAI,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC;IAC9C,OAAO;IACP,KAAK,GAAG,SAAS;CAGpB,CAkEA"}
|
package/dist/useFetch.js
CHANGED
|
@@ -1,18 +1,64 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useTransition, useState, useCallback } from "react";
|
|
3
|
-
|
|
2
|
+
import { useTransition, useState, useRef, useCallback, useEffect } from "react";
|
|
3
|
+
const unmountError = "Component is unmounted after fetch completed";
|
|
4
|
+
function useFetch(fn, defaultValue, {
|
|
5
|
+
fetchOnMount = false,
|
|
6
|
+
throwAfterUnmount = false
|
|
7
|
+
} = {}) {
|
|
4
8
|
const [isPending, startTransition] = useTransition();
|
|
5
9
|
const [data, setData] = useState(defaultValue);
|
|
6
10
|
const [error, setError] = useState();
|
|
11
|
+
const [loading, setLoading] = useState(fetchOnMount);
|
|
12
|
+
const isMounted = useRef(false);
|
|
13
|
+
const oldData = useRef(data);
|
|
14
|
+
const throwAfterUnmountRef = useRef(throwAfterUnmount);
|
|
7
15
|
const actionFn = useCallback(
|
|
8
16
|
(...args) => {
|
|
9
|
-
const
|
|
10
|
-
|
|
17
|
+
const res = fn(...args);
|
|
18
|
+
const promise = typeof res === "function" ? res(oldData.current) : res;
|
|
19
|
+
startTransition(async () => {
|
|
20
|
+
try {
|
|
21
|
+
const newData = await promise;
|
|
22
|
+
if (!isMounted.current) {
|
|
23
|
+
if (throwAfterUnmountRef.current) throw new Error(unmountError);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
oldData.current = newData;
|
|
27
|
+
setData(newData);
|
|
28
|
+
setError(void 0);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (!isMounted.current) {
|
|
31
|
+
if (throwAfterUnmountRef.current) {
|
|
32
|
+
if (e.message !== unmountError) throw new Error("Component is unmounted", { cause: e });
|
|
33
|
+
else throw e;
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setError(e);
|
|
38
|
+
} finally {
|
|
39
|
+
if (isMounted.current) {
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
11
44
|
return promise;
|
|
12
45
|
},
|
|
13
46
|
[fn]
|
|
14
47
|
);
|
|
15
|
-
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!fetchOnMount) return;
|
|
50
|
+
actionFn().catch((e) => console.error("Fetch on mount failed", e));
|
|
51
|
+
}, [fetchOnMount, fn, actionFn]);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
throwAfterUnmountRef.current = throwAfterUnmount;
|
|
54
|
+
}, [throwAfterUnmount]);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
isMounted.current = true;
|
|
57
|
+
return () => {
|
|
58
|
+
isMounted.current = false;
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return [data, actionFn, isPending || loading, error];
|
|
16
62
|
}
|
|
17
63
|
export {
|
|
18
64
|
useFetch
|
package/dist/useFetch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useFetch.js","sources":["../src/useFetch.ts"],"sourcesContent":["'use client';\n\nimport { useCallback, useState, useTransition } from 'react';\n\n
|
|
1
|
+
{"version":3,"file":"useFetch.js","sources":["../src/useFetch.ts"],"sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useRef, useState, useTransition } from 'react';\n\nconst unmountError = 'Component is unmounted after fetch completed';\n\n/**\n * TODO useFetch https://use-http.com\n * TODO SWR?\n * TODO Tanstack Query?\n *\n * Uses same return array patterns as useActionState https://react.dev/reference/react/useActionState + error,\n * reason: simple var rename\n *\n * Function can be async, then it will be awaited and result set to state.\n *\n * Function can be sync, then it will be called args, and it should return another function,which will be called with\n * old data and result set to state. This is useful for pagination and merging data.\n *\n * @param {(...args: any[]) => Promise<R> | R | ((oldData: R) => Promise<R> | R))} fn\n * @param {any} defaultValue\n * @param fetchOnMount\n * @param throwAfterUnmount - throw if component is unmounted after fetch completed\n * @returns {[R, (...args: any[]) => Promise<R>, boolean, Error | undefined]} //, ReturnType<typeof useState>, ReturnType<typeof useState>\n */\nexport function useFetch<R>(\n fn: (...args: any[]) => Promise<R> | R | ((oldData: R) => Promise<R> | R),\n defaultValue: R,\n {\n fetchOnMount = false,\n throwAfterUnmount = false,\n }: {\n fetchOnMount?: boolean;\n throwAfterUnmount?: boolean;\n } = {},\n): [\n R,\n (...args: Parameters<typeof fn>) => Promise<R>,\n boolean,\n Error | undefined,\n // ReturnType<typeof useState<R>>[1],\n // ReturnType<typeof useState<Error>>[1],\n] {\n // An async function was passed to useActionState, but it was dispatched outside of an action context.\n // This is likely not what you intended. Either pass the dispatch function to an `action` prop, or dispatch manually inside `startTransition`\n const [isPending, startTransition] = useTransition();\n const [data, setData] = useState<R>(defaultValue);\n const [error, setError] = useState<Error>();\n const [loading, setLoading] = useState(fetchOnMount);\n const isMounted = useRef(false);\n const oldData = useRef(data);\n const throwAfterUnmountRef = useRef(throwAfterUnmount);\n\n const actionFn = useCallback(\n (...args: Parameters<typeof fn>) => {\n const res = fn(...args);\n\n const promise: Promise<R> = typeof res === 'function' ? (res as any)(oldData.current) : (res as Promise<R>);\n\n // https://react.dev/reference/react/useTransition#react-doesnt-treat-my-state-update-after-await-as-a-transition\n startTransition(async () => {\n try {\n const newData = await promise;\n if (!isMounted.current) {\n if (throwAfterUnmountRef.current) throw new Error(unmountError);\n return;\n }\n oldData.current = newData;\n setData(newData);\n setError(undefined);\n } catch (e) {\n if (!isMounted.current) {\n if (throwAfterUnmountRef.current) {\n if (e.message !== unmountError) throw new Error('Component is unmounted', { cause: e });\n else throw e;\n }\n return;\n }\n setError(e);\n } finally {\n if (isMounted.current) {\n setLoading(false);\n }\n }\n });\n\n return promise;\n },\n [fn],\n );\n\n useEffect(() => {\n if (!fetchOnMount) return;\n actionFn().catch((e) => console.error('Fetch on mount failed', e)); // catch actually will never happen\n }, [fetchOnMount, fn, actionFn]);\n\n useEffect(() => {\n throwAfterUnmountRef.current = throwAfterUnmount;\n }, [throwAfterUnmount]);\n\n useEffect(() => {\n isMounted.current = true;\n return () => {\n isMounted.current = false;\n };\n });\n\n return [data, actionFn, isPending || loading, error]; // , setData, setError\n}\n"],"names":[],"mappings":";;AAIA,MAAM,eAAe;AAqBd,SAAS,SACZ,IACA,cACA;AAAA,EACI,eAAe;AAAA,EACf,oBAAoB;AACxB,IAGI,IAQN;AAGE,QAAM,CAAC,WAAW,eAAe,IAAI,cAAA;AACrC,QAAM,CAAC,MAAM,OAAO,IAAI,SAAY,YAAY;AAChD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAA;AAC1B,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,YAAY;AACnD,QAAM,YAAY,OAAO,KAAK;AAC9B,QAAM,UAAU,OAAO,IAAI;AAC3B,QAAM,uBAAuB,OAAO,iBAAiB;AAErD,QAAM,WAAW;AAAA,IACb,IAAI,SAAgC;AAChC,YAAM,MAAM,GAAG,GAAG,IAAI;AAEtB,YAAM,UAAsB,OAAO,QAAQ,aAAc,IAAY,QAAQ,OAAO,IAAK;AAGzF,sBAAgB,YAAY;AACxB,YAAI;AACA,gBAAM,UAAU,MAAM;AACtB,cAAI,CAAC,UAAU,SAAS;AACpB,gBAAI,qBAAqB,QAAS,OAAM,IAAI,MAAM,YAAY;AAC9D;AAAA,UACJ;AACA,kBAAQ,UAAU;AAClB,kBAAQ,OAAO;AACf,mBAAS,MAAS;AAAA,QACtB,SAAS,GAAG;AACR,cAAI,CAAC,UAAU,SAAS;AACpB,gBAAI,qBAAqB,SAAS;AAC9B,kBAAI,EAAE,YAAY,aAAc,OAAM,IAAI,MAAM,0BAA0B,EAAE,OAAO,GAAG;AAAA,kBACjF,OAAM;AAAA,YACf;AACA;AAAA,UACJ;AACA,mBAAS,CAAC;AAAA,QACd,UAAA;AACI,cAAI,UAAU,SAAS;AACnB,uBAAW,KAAK;AAAA,UACpB;AAAA,QACJ;AAAA,MACJ,CAAC;AAED,aAAO;AAAA,IACX;AAAA,IACA,CAAC,EAAE;AAAA,EAAA;AAGP,YAAU,MAAM;AACZ,QAAI,CAAC,aAAc;AACnB,aAAA,EAAW,MAAM,CAAC,MAAM,QAAQ,MAAM,yBAAyB,CAAC,CAAC;AAAA,EACrE,GAAG,CAAC,cAAc,IAAI,QAAQ,CAAC;AAE/B,YAAU,MAAM;AACZ,yBAAqB,UAAU;AAAA,EACnC,GAAG,CAAC,iBAAiB,CAAC;AAEtB,YAAU,MAAM;AACZ,cAAU,UAAU;AACpB,WAAO,MAAM;AACT,gBAAU,UAAU;AAAA,IACxB;AAAA,EACJ,CAAC;AAED,SAAO,CAAC,MAAM,UAAU,aAAa,SAAS,KAAK;AACvD;"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useFetch.test.d.ts","sourceRoot":"","sources":["../src/useFetch.test.tsx"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kirill.konshin/react",
|
|
3
3
|
"description": "Utilities",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"----- BUILD -----": "",
|
|
@@ -24,7 +24,13 @@
|
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@kirill.konshin/utils-private": "*",
|
|
27
|
-
"
|
|
27
|
+
"@testing-library/dom": "^10.4.1",
|
|
28
|
+
"@testing-library/react": "^16.3.2",
|
|
29
|
+
"@testing-library/react-hooks": "^8.0.1",
|
|
30
|
+
"@types/react-dom": "^19",
|
|
31
|
+
"jsdom": "^29.0.1",
|
|
32
|
+
"react": "^19.1.1",
|
|
33
|
+
"react-dom": "19.1.1"
|
|
28
34
|
},
|
|
29
35
|
"peerDependencies": {
|
|
30
36
|
"react": "^19"
|
|
@@ -35,7 +41,8 @@
|
|
|
35
41
|
}
|
|
36
42
|
},
|
|
37
43
|
"publishConfig": {
|
|
38
|
-
"access": "public"
|
|
44
|
+
"access": "public",
|
|
45
|
+
"provenance": true
|
|
39
46
|
},
|
|
40
47
|
"author": "Kirill Konshin <kirill@konshin.org> (https://konshin.org)",
|
|
41
48
|
"license": "MIT",
|
|
@@ -55,5 +62,10 @@
|
|
|
55
62
|
},
|
|
56
63
|
"main": "./dist/index.js",
|
|
57
64
|
"module": "./dist/index.js",
|
|
58
|
-
"types": "./dist/index.d.ts"
|
|
65
|
+
"types": "./dist/index.d.ts",
|
|
66
|
+
"repository": {
|
|
67
|
+
"type": "git",
|
|
68
|
+
"url": "https://github.com/kirill-konshin/utils.git",
|
|
69
|
+
"directory": "packages/react"
|
|
70
|
+
}
|
|
59
71
|
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { expect, describe, test, vi } from 'vitest';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import { useFetch } from './useFetch';
|
|
4
|
+
import { act } from 'react';
|
|
5
|
+
|
|
6
|
+
describe('useFetch', () => {
|
|
7
|
+
test('initializes with default value', () => {
|
|
8
|
+
const mockFn = vi.fn(async () => 'result');
|
|
9
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
10
|
+
|
|
11
|
+
const [data, actionFn, isPending, error] = result.current;
|
|
12
|
+
|
|
13
|
+
expect(data).toBe('default');
|
|
14
|
+
expect(typeof actionFn).toBe('function');
|
|
15
|
+
expect(isPending).toBe(false);
|
|
16
|
+
expect(error).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('executes function and updates data on success', async () => {
|
|
20
|
+
const mockFn = vi.fn(async () => 'success');
|
|
21
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
22
|
+
|
|
23
|
+
await act(async () => {
|
|
24
|
+
result.current[1]();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await waitFor(() => {
|
|
28
|
+
expect(result.current[2]).toBe(false); // isPending becomes false
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.current[0]).toBe('success');
|
|
32
|
+
expect(result.current[3]).toBeUndefined();
|
|
33
|
+
expect(mockFn).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('handles function arguments', async () => {
|
|
37
|
+
const mockFn = vi.fn(async (a: number, b: string) => `${a}-${b}`);
|
|
38
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
39
|
+
|
|
40
|
+
await act(async () => {
|
|
41
|
+
result.current[1](42, 'test');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(result.current[0]).toBe('42-test');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(mockFn).toHaveBeenCalledWith(42, 'test');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('sets isPending to true during execution', async () => {
|
|
52
|
+
let resolvePromise: (value: string) => void;
|
|
53
|
+
const promise = new Promise<string>((resolve) => {
|
|
54
|
+
resolvePromise = resolve;
|
|
55
|
+
});
|
|
56
|
+
const mockFn = vi.fn(async () => promise);
|
|
57
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
result.current[1]();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Should be pending immediately
|
|
64
|
+
expect(result.current[2]).toBe(true);
|
|
65
|
+
|
|
66
|
+
await act(async () => {
|
|
67
|
+
resolvePromise!('resolved');
|
|
68
|
+
await promise;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await waitFor(() => {
|
|
72
|
+
expect(result.current[2]).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(result.current[0]).toBe('resolved');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('captures and stores errors', async () => {
|
|
79
|
+
const error = new Error('Test error');
|
|
80
|
+
const mockFn = vi.fn(async () => {
|
|
81
|
+
throw error;
|
|
82
|
+
});
|
|
83
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
84
|
+
|
|
85
|
+
await act(async () => {
|
|
86
|
+
result.current[1]();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(result.current[3]).toBe(error);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.current[0]).toBe('default'); // data remains unchanged
|
|
94
|
+
expect(result.current[2]).toBe(false); // isPending becomes false
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('does not update data if component unmounts before resolution', async () => {
|
|
98
|
+
let resolvePromise: (value: string) => void;
|
|
99
|
+
const promise = new Promise<string>((resolve) => {
|
|
100
|
+
resolvePromise = resolve;
|
|
101
|
+
});
|
|
102
|
+
const mockFn = vi.fn(async () => promise);
|
|
103
|
+
const { result, unmount } = renderHook(() => useFetch(mockFn, 'default'));
|
|
104
|
+
|
|
105
|
+
act(() => {
|
|
106
|
+
result.current[1]();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
unmount();
|
|
110
|
+
|
|
111
|
+
await act(async () => {
|
|
112
|
+
resolvePromise!('resolved');
|
|
113
|
+
await promise;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// No assertions needed - just verifying no errors on unmount
|
|
117
|
+
expect(mockFn).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('returns the promise from actionFn', async () => {
|
|
121
|
+
const mockFn = vi.fn(async () => 'result');
|
|
122
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
123
|
+
|
|
124
|
+
let returnedPromise: Promise<string>;
|
|
125
|
+
await act(async () => {
|
|
126
|
+
returnedPromise = result.current[1]();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await expect(returnedPromise!).resolves.toBe('result');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('handles multiple sequential calls', async () => {
|
|
133
|
+
const mockFn = vi.fn(async (n: number) => `result-${n}`);
|
|
134
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
135
|
+
|
|
136
|
+
await act(async () => {
|
|
137
|
+
result.current[1](1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(result.current[0]).toBe('result-1');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await act(async () => {
|
|
145
|
+
result.current[1](2);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(result.current[0]).toBe('result-2');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(mockFn).toHaveBeenCalledTimes(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('preserves actionFn reference when fn dependency does not change', () => {
|
|
156
|
+
const mockFn = vi.fn(async () => 'result');
|
|
157
|
+
const { result, rerender } = renderHook(() => useFetch(mockFn, 'default'));
|
|
158
|
+
|
|
159
|
+
const firstActionFn = result.current[1];
|
|
160
|
+
|
|
161
|
+
rerender();
|
|
162
|
+
|
|
163
|
+
const secondActionFn = result.current[1];
|
|
164
|
+
|
|
165
|
+
expect(firstActionFn).toBe(secondActionFn);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('updates actionFn when fn dependency changes', async () => {
|
|
169
|
+
const mockFn1 = vi.fn(async () => 'result1');
|
|
170
|
+
const mockFn2 = vi.fn(async () => 'result2');
|
|
171
|
+
|
|
172
|
+
const { result, rerender } = renderHook(({ fn }) => useFetch(fn, 'default'), {
|
|
173
|
+
initialProps: { fn: mockFn1 },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const firstActionFn = result.current[1];
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
result.current[1]();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
expect(result.current[0]).toBe('result1');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Change the function
|
|
187
|
+
rerender({ fn: mockFn2 });
|
|
188
|
+
|
|
189
|
+
const secondActionFn = result.current[1];
|
|
190
|
+
|
|
191
|
+
expect(firstActionFn).not.toBe(secondActionFn);
|
|
192
|
+
|
|
193
|
+
await act(async () => {
|
|
194
|
+
result.current[1]();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await waitFor(() => {
|
|
198
|
+
expect(result.current[0]).toBe('result2');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(mockFn1).toHaveBeenCalledTimes(1);
|
|
202
|
+
expect(mockFn2).toHaveBeenCalledTimes(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('does not trigger execution on rerender', () => {
|
|
206
|
+
const mockFn = vi.fn(async () => 'result');
|
|
207
|
+
const { rerender } = renderHook(() => useFetch(mockFn, 'default'));
|
|
208
|
+
|
|
209
|
+
rerender();
|
|
210
|
+
rerender();
|
|
211
|
+
rerender();
|
|
212
|
+
|
|
213
|
+
expect(mockFn).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('handles concurrent calls correctly', async () => {
|
|
217
|
+
let callCount = 0;
|
|
218
|
+
const mockFn = vi.fn(async () => {
|
|
219
|
+
callCount++;
|
|
220
|
+
const currentCall = callCount;
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
222
|
+
return `result-${currentCall}`;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
226
|
+
|
|
227
|
+
// Fire multiple calls at once
|
|
228
|
+
await act(async () => {
|
|
229
|
+
result.current[1]();
|
|
230
|
+
result.current[1]();
|
|
231
|
+
result.current[1]();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await waitFor(() => {
|
|
235
|
+
expect(result.current[2]).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Last call should win
|
|
239
|
+
expect(mockFn).toHaveBeenCalledTimes(3);
|
|
240
|
+
expect(['result-1', 'result-2', 'result-3']).toContain(result.current[0]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('maintains separate state for different hook instances', async () => {
|
|
244
|
+
const mockFn1 = vi.fn(async () => 'result1');
|
|
245
|
+
const mockFn2 = vi.fn(async () => 'result2');
|
|
246
|
+
|
|
247
|
+
const { result: result1 } = renderHook(() => useFetch(mockFn1, 'default1'));
|
|
248
|
+
const { result: result2 } = renderHook(() => useFetch(mockFn2, 'default2'));
|
|
249
|
+
|
|
250
|
+
expect(result1.current[0]).toBe('default1');
|
|
251
|
+
expect(result2.current[0]).toBe('default2');
|
|
252
|
+
|
|
253
|
+
await act(async () => {
|
|
254
|
+
result1.current[1]();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await waitFor(() => {
|
|
258
|
+
expect(result1.current[0]).toBe('result1');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result2.current[0]).toBe('default2'); // unchanged
|
|
262
|
+
|
|
263
|
+
await act(async () => {
|
|
264
|
+
result2.current[1]();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await waitFor(() => {
|
|
268
|
+
expect(result2.current[0]).toBe('result2');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(result1.current[0]).toBe('result1'); // still unchanged
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('handles closure over external state', async () => {
|
|
275
|
+
let externalValue = 10;
|
|
276
|
+
|
|
277
|
+
const { result, rerender } = renderHook(() => {
|
|
278
|
+
const mockFn = async () => `value-${externalValue}`;
|
|
279
|
+
return useFetch(mockFn, 'default');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await act(async () => {
|
|
283
|
+
result.current[1]();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(result.current[0]).toBe('value-10');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Change external value and rerender
|
|
291
|
+
externalValue = 20;
|
|
292
|
+
rerender();
|
|
293
|
+
|
|
294
|
+
await act(async () => {
|
|
295
|
+
result.current[1]();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await waitFor(() => {
|
|
299
|
+
expect(result.current[0]).toBe('value-20');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('clears error on successful subsequent call', async () => {
|
|
304
|
+
const error = new Error('Test error');
|
|
305
|
+
let shouldError = true;
|
|
306
|
+
|
|
307
|
+
const mockFn = vi.fn(async () => {
|
|
308
|
+
if (shouldError) {
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
return 'success';
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const { result } = renderHook(() => useFetch(mockFn, 'default'));
|
|
315
|
+
|
|
316
|
+
// First call errors
|
|
317
|
+
await act(async () => {
|
|
318
|
+
result.current[1]();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await waitFor(() => {
|
|
322
|
+
expect(result.current[3]).toBe(error);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Second call succeeds
|
|
326
|
+
shouldError = false;
|
|
327
|
+
await act(async () => {
|
|
328
|
+
result.current[1]();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await waitFor(() => {
|
|
332
|
+
expect(result.current[0]).toBe('success');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Error is cleared automatically
|
|
336
|
+
expect(result.current[3]).toBe(undefined);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('handles callback that returns a function with old value', async () => {
|
|
340
|
+
const mockFn = vi.fn((increment: number) => (oldValue: number) => oldValue + increment);
|
|
341
|
+
const { result } = renderHook(() => useFetch(mockFn, 0));
|
|
342
|
+
|
|
343
|
+
// First call - should call the function with oldValue (0) and get 0 + 5 = 5
|
|
344
|
+
await act(async () => {
|
|
345
|
+
result.current[1](5);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await waitFor(() => {
|
|
349
|
+
expect(result.current[0]).toBe(5);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Second call - should call the function with oldValue (5) and get 5 + 10 = 15
|
|
353
|
+
await act(async () => {
|
|
354
|
+
result.current[1](10);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await waitFor(() => {
|
|
358
|
+
expect(result.current[0]).toBe(15);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(mockFn).toHaveBeenCalledTimes(2);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('handles multiple calls with old value pattern', async () => {
|
|
365
|
+
const mockFn = vi.fn(
|
|
366
|
+
(operation: string, value: number) => (oldValue: string) => `${oldValue}-${operation}${value}`,
|
|
367
|
+
);
|
|
368
|
+
const { result } = renderHook(() => useFetch(mockFn, 'initial'));
|
|
369
|
+
|
|
370
|
+
// First call
|
|
371
|
+
await act(async () => {
|
|
372
|
+
result.current[1]('add', 1);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await waitFor(() => {
|
|
376
|
+
expect(result.current[0]).toBe('initial-add1');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Second call - should use the updated value
|
|
380
|
+
await act(async () => {
|
|
381
|
+
result.current[1]('multiply', 2);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await waitFor(() => {
|
|
385
|
+
expect(result.current[0]).toBe('initial-add1-multiply2');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(mockFn).toHaveBeenCalledTimes(2);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('handles counter pattern with old value', async () => {
|
|
392
|
+
const increment = vi.fn(() => (prev: number) => prev + 1);
|
|
393
|
+
const { result } = renderHook(() => useFetch(increment, 0));
|
|
394
|
+
|
|
395
|
+
// First increment: 0 + 1 = 1
|
|
396
|
+
await act(async () => {
|
|
397
|
+
result.current[1]();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await waitFor(() => {
|
|
401
|
+
expect(result.current[0]).toBe(1);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Second increment: 1 + 1 = 2
|
|
405
|
+
await act(async () => {
|
|
406
|
+
result.current[1]();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await waitFor(() => {
|
|
410
|
+
expect(result.current[0]).toBe(2);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Third increment: 2 + 1 = 3
|
|
414
|
+
await act(async () => {
|
|
415
|
+
result.current[1]();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await waitFor(() => {
|
|
419
|
+
expect(result.current[0]).toBe(3);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
expect(increment).toHaveBeenCalledTimes(3);
|
|
423
|
+
});
|
|
424
|
+
});
|
package/src/useFetch.ts
CHANGED
|
@@ -1,29 +1,109 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback, useState, useTransition } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useTransition } from 'react';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const unmountError = 'Component is unmounted after fetch completed';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TODO useFetch https://use-http.com
|
|
9
|
+
* TODO SWR?
|
|
10
|
+
* TODO Tanstack Query?
|
|
11
|
+
*
|
|
12
|
+
* Uses same return array patterns as useActionState https://react.dev/reference/react/useActionState + error,
|
|
13
|
+
* reason: simple var rename
|
|
14
|
+
*
|
|
15
|
+
* Function can be async, then it will be awaited and result set to state.
|
|
16
|
+
*
|
|
17
|
+
* Function can be sync, then it will be called args, and it should return another function,which will be called with
|
|
18
|
+
* old data and result set to state. This is useful for pagination and merging data.
|
|
19
|
+
*
|
|
20
|
+
* @param {(...args: any[]) => Promise<R> | R | ((oldData: R) => Promise<R> | R))} fn
|
|
21
|
+
* @param {any} defaultValue
|
|
22
|
+
* @param fetchOnMount
|
|
23
|
+
* @param throwAfterUnmount - throw if component is unmounted after fetch completed
|
|
24
|
+
* @returns {[R, (...args: any[]) => Promise<R>, boolean, Error | undefined]} //, ReturnType<typeof useState>, ReturnType<typeof useState>
|
|
25
|
+
*/
|
|
8
26
|
export function useFetch<R>(
|
|
9
|
-
fn: (...args: any[]) => Promise<R
|
|
10
|
-
defaultValue: R
|
|
11
|
-
|
|
27
|
+
fn: (...args: any[]) => Promise<R> | R | ((oldData: R) => Promise<R> | R),
|
|
28
|
+
defaultValue: R,
|
|
29
|
+
{
|
|
30
|
+
fetchOnMount = false,
|
|
31
|
+
throwAfterUnmount = false,
|
|
32
|
+
}: {
|
|
33
|
+
fetchOnMount?: boolean;
|
|
34
|
+
throwAfterUnmount?: boolean;
|
|
35
|
+
} = {},
|
|
36
|
+
): [
|
|
37
|
+
R,
|
|
38
|
+
(...args: Parameters<typeof fn>) => Promise<R>,
|
|
39
|
+
boolean,
|
|
40
|
+
Error | undefined,
|
|
41
|
+
// ReturnType<typeof useState<R>>[1],
|
|
42
|
+
// ReturnType<typeof useState<Error>>[1],
|
|
43
|
+
] {
|
|
12
44
|
// An async function was passed to useActionState, but it was dispatched outside of an action context.
|
|
13
45
|
// This is likely not what you intended. Either pass the dispatch function to an `action` prop, or dispatch manually inside `startTransition`
|
|
14
46
|
const [isPending, startTransition] = useTransition();
|
|
15
|
-
const [data, setData] = useState<R
|
|
47
|
+
const [data, setData] = useState<R>(defaultValue);
|
|
16
48
|
const [error, setError] = useState<Error>();
|
|
49
|
+
const [loading, setLoading] = useState(fetchOnMount);
|
|
50
|
+
const isMounted = useRef(false);
|
|
51
|
+
const oldData = useRef(data);
|
|
52
|
+
const throwAfterUnmountRef = useRef(throwAfterUnmount);
|
|
17
53
|
|
|
18
54
|
const actionFn = useCallback(
|
|
19
55
|
(...args: Parameters<typeof fn>) => {
|
|
20
|
-
const
|
|
56
|
+
const res = fn(...args);
|
|
57
|
+
|
|
58
|
+
const promise: Promise<R> = typeof res === 'function' ? (res as any)(oldData.current) : (res as Promise<R>);
|
|
59
|
+
|
|
21
60
|
// https://react.dev/reference/react/useTransition#react-doesnt-treat-my-state-update-after-await-as-a-transition
|
|
22
|
-
startTransition(() =>
|
|
61
|
+
startTransition(async () => {
|
|
62
|
+
try {
|
|
63
|
+
const newData = await promise;
|
|
64
|
+
if (!isMounted.current) {
|
|
65
|
+
if (throwAfterUnmountRef.current) throw new Error(unmountError);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
oldData.current = newData;
|
|
69
|
+
setData(newData);
|
|
70
|
+
setError(undefined);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (!isMounted.current) {
|
|
73
|
+
if (throwAfterUnmountRef.current) {
|
|
74
|
+
if (e.message !== unmountError) throw new Error('Component is unmounted', { cause: e });
|
|
75
|
+
else throw e;
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setError(e);
|
|
80
|
+
} finally {
|
|
81
|
+
if (isMounted.current) {
|
|
82
|
+
setLoading(false);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
23
87
|
return promise;
|
|
24
88
|
},
|
|
25
89
|
[fn],
|
|
26
90
|
);
|
|
27
91
|
|
|
28
|
-
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!fetchOnMount) return;
|
|
94
|
+
actionFn().catch((e) => console.error('Fetch on mount failed', e)); // catch actually will never happen
|
|
95
|
+
}, [fetchOnMount, fn, actionFn]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
throwAfterUnmountRef.current = throwAfterUnmount;
|
|
99
|
+
}, [throwAfterUnmount]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
isMounted.current = true;
|
|
103
|
+
return () => {
|
|
104
|
+
isMounted.current = false;
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return [data, actionFn, isPending || loading, error]; // , setData, setError
|
|
29
109
|
}
|