>,
30 | params?: P | Ref
31 | ): AsyncFunctionReturn {
32 | // always wrap arguments
33 | const wrapPromiseFn = isRef>(promiseFn)
34 | ? promiseFn
35 | : ref>(promiseFn);
36 | const wrapParams: Ref = isRef
(params) ? params : ref(params);
37 |
38 | // create empty return values
39 | const isLoading = ref(false);
40 | const error = ref();
41 | const data = ref();
42 |
43 | // abort controller
44 | let controller: AbortController | undefined;
45 |
46 | function abort() {
47 | isLoading.value = false;
48 | if (controller !== undefined) {
49 | controller.abort();
50 | controller = undefined;
51 | }
52 | }
53 |
54 | function retry() {
55 | // unwrap the original promise as it is optionally wrapped
56 | const origPromiseFn = wrapPromiseFn.value;
57 | // create a new promise and trigger watch
58 | wrapPromiseFn.value = async (params, signal) =>
59 | origPromiseFn(params, signal);
60 | }
61 |
62 | // watch for change in arguments, which triggers immediately initially
63 | const watched: [typeof wrapPromiseFn, typeof wrapParams] = [
64 | wrapPromiseFn,
65 | wrapParams,
66 | ];
67 | watch(watched, async ([newPromiseFn, newParams]) => {
68 | try {
69 | abort();
70 | isLoading.value = true;
71 | controller = new AbortController();
72 | const result = await newPromiseFn(newParams, controller.signal);
73 | error.value = undefined;
74 | data.value = result;
75 | } catch (e) {
76 | error.value = e;
77 | data.value = undefined;
78 | } finally {
79 | isLoading.value = false;
80 | }
81 | });
82 |
83 | onBeforeUnmount(abort);
84 |
85 | return {
86 | isLoading,
87 | error,
88 | data,
89 | abort,
90 | retry,
91 | };
92 | }
93 |
94 | /**
95 | * Fetch helper function that accepts the same arguments as `fetch` and returns the same values as `useAsync`.
96 | * If the `Accept` header is set to `application/json` in the `requestInit` object, the response will be parsed as JSON,
97 | * else text.
98 | *
99 | * @param requestInfo (optionally ref to) URL or request object.
100 | * @param requestInit (optionally ref to) init parameters for the request.
101 | * @returns Object literal containing same return values as `useAsync`.
102 | */
103 | export function useFetch(
104 | requestInfo: RequestInfo | Ref,
105 | requestInit: RequestInit | Ref = {}
106 | ): AsyncFunctionReturn {
107 | // always wrap arguments
108 | const wrapReqInfo = isRef(requestInfo)
109 | ? requestInfo
110 | : ref(requestInfo);
111 | const wrapReqInit = isRef(requestInit)
112 | ? requestInit
113 | : ref(requestInit);
114 |
115 | async function doFetch(params: undefined, signal: AbortSignal) {
116 | const requestInit = wrapReqInit.value;
117 | const res = await fetch(wrapReqInfo.value, {
118 | ...requestInit,
119 | signal,
120 | });
121 | if (!res.ok) {
122 | throw res;
123 | }
124 |
125 | // TODO figure out how to use typed headers
126 | const headers: any = requestInit.headers;
127 | if (headers && headers.Accept === "application/json") {
128 | return res.json();
129 | }
130 | return res.text();
131 | }
132 |
133 | // wrap original fetch function in value
134 | const wrapPromiseFn = ref>(doFetch);
135 |
136 | // watch for change in arguments, which triggers immediately initially
137 | watch([wrapReqInfo, wrapReqInit], async () => {
138 | // create a new promise and trigger watch
139 | wrapPromiseFn.value = async (params, signal) => doFetch(params, signal);
140 | });
141 |
142 | return useAsync(wrapPromiseFn);
143 | }
144 |
--------------------------------------------------------------------------------
/lib/tests/useAsync.spec.js:
--------------------------------------------------------------------------------
1 | import { useAsync } from "../src";
2 | import { shallowMount } from "@vue/test-utils";
3 | import flushPromises from "flush-promises";
4 |
5 | // setup vue
6 | import Vue from "vue";
7 | import VueCompositionApi, { ref } from "@vue/composition-api";
8 | Vue.use(VueCompositionApi);
9 |
10 | // component helper
11 | function createComponentWithUseAsync(promiseFn, params) {
12 | return {
13 | setup() {
14 | return useAsync(promiseFn, params);
15 | },
16 | render: (h) => h(),
17 | };
18 | }
19 |
20 | describe("useAsync", () => {
21 | it("returns initial values", () => {
22 | const promiseFn = async () => {};
23 | const Component = createComponentWithUseAsync(promiseFn);
24 |
25 | const wrapper = shallowMount(Component);
26 |
27 | expect(wrapper.vm.isLoading).toBe(true);
28 | expect(wrapper.vm.error).toBeUndefined();
29 | expect(wrapper.vm.data).toBeUndefined();
30 | expect(wrapper.vm.retry).toBeDefined();
31 | expect(wrapper.vm.abort).toBeDefined();
32 | });
33 |
34 | it("updates reactive values when promise resolves", async () => {
35 | const promiseFn = () => Promise.resolve("done");
36 | const Component = createComponentWithUseAsync(promiseFn);
37 |
38 | const wrapper = shallowMount(Component);
39 | await wrapper.vm.$nextTick();
40 |
41 | expect(wrapper.vm.isLoading).toBe(false);
42 | expect(wrapper.vm.error).toBeUndefined();
43 | expect(wrapper.vm.data).toBe("done");
44 | });
45 |
46 | it("updates reactive values when promise rejects", async () => {
47 | const promiseFn = () => Promise.reject("error");
48 | const Component = createComponentWithUseAsync(promiseFn);
49 |
50 | const wrapper = shallowMount(Component);
51 | await wrapper.vm.$nextTick();
52 |
53 | expect(wrapper.vm.isLoading).toBe(false);
54 | expect(wrapper.vm.error).toBe("error");
55 | expect(wrapper.vm.data).toBeUndefined();
56 | });
57 |
58 | it("retries original promise when retry is called", async () => {
59 | let fail = true;
60 | const promiseFn = jest.fn(() =>
61 | fail ? Promise.reject("error") : Promise.resolve("done")
62 | );
63 | const Component = createComponentWithUseAsync(promiseFn);
64 | const wrapper = shallowMount(Component);
65 | await wrapper.vm.$nextTick();
66 | expect(wrapper.vm.isLoading).toBe(false);
67 | expect(wrapper.vm.error).toBe("error");
68 | expect(wrapper.vm.data).toBeUndefined();
69 | expect(promiseFn).toBeCalledTimes(1);
70 |
71 | fail = false;
72 | wrapper.vm.retry();
73 | await wrapper.vm.$nextTick();
74 |
75 | expect(wrapper.vm.isLoading).toBe(true);
76 | expect(wrapper.vm.error).toBe("error");
77 | expect(wrapper.vm.data).toBeUndefined();
78 | expect(promiseFn).toBeCalledTimes(2);
79 |
80 | await flushPromises();
81 | expect(wrapper.vm.isLoading).toBe(false);
82 | expect(wrapper.vm.error).toBeUndefined();
83 | expect(wrapper.vm.data).toBe("done");
84 | });
85 |
86 | it("sends abort signal to promise when abort is called", () => {
87 | let aborted = false;
88 | const promiseFn = async (params, signal) => {
89 | signal.addEventListener("abort", () => {
90 | aborted = true;
91 | });
92 | };
93 | const Component = createComponentWithUseAsync(promiseFn);
94 | const wrapper = shallowMount(Component);
95 | expect(wrapper.vm.isLoading).toBe(true);
96 |
97 | wrapper.vm.abort();
98 |
99 | expect(wrapper.vm.isLoading).toBe(false);
100 | expect(wrapper.vm.error).toBeUndefined();
101 | expect(wrapper.vm.data).toBeUndefined();
102 | expect(aborted).toBe(true);
103 | });
104 |
105 | it("aborts promise when component is destroyed", async () => {
106 | let aborted = false;
107 | const promiseFn = async (params, signal) => {
108 | signal.addEventListener("abort", () => {
109 | aborted = true;
110 | });
111 | };
112 | const Component = createComponentWithUseAsync(promiseFn);
113 | const wrapper = shallowMount(Component);
114 |
115 | wrapper.destroy();
116 |
117 | expect(aborted).toBe(true);
118 | });
119 |
120 | it("calls promiseFn with provided params argument", () => {
121 | const promiseFn = jest.fn(async () => {});
122 | const params = {};
123 | const Component = createComponentWithUseAsync(promiseFn, params);
124 |
125 | shallowMount(Component);
126 |
127 | expect(promiseFn).toBeCalledWith(params, expect.any(Object));
128 | });
129 |
130 | it("accepts value wrapped arguments", async () => {
131 | const promiseFn = ref(async ({ msg }) => msg);
132 | const params = ref({ msg: "done" });
133 | const Component = createComponentWithUseAsync(promiseFn, params);
134 |
135 | const wrapper = shallowMount(Component);
136 | await wrapper.vm.$nextTick();
137 |
138 | expect(wrapper.vm.isLoading).toBe(false);
139 | expect(wrapper.vm.error).toBeUndefined();
140 | expect(wrapper.vm.data).toBe("done");
141 | });
142 |
143 | it("retries original promise when value wrapped promiseFn is changed", async () => {
144 | const promiseFn = async () => "done";
145 | const wrapPromiseFn = ref(promiseFn);
146 | const Component = createComponentWithUseAsync(wrapPromiseFn);
147 | const wrapper = shallowMount(Component);
148 | await wrapper.vm.$nextTick();
149 | expect(wrapper.vm.isLoading).toBe(false);
150 | expect(wrapper.vm.error).toBeUndefined();
151 | expect(wrapper.vm.data).toBe("done");
152 |
153 | let resolvePromise = () => {};
154 | const newPromiseFn = () =>
155 | new Promise((resolve) => {
156 | resolvePromise = () => resolve("done again");
157 | });
158 |
159 | wrapPromiseFn.value = newPromiseFn;
160 | await wrapper.vm.$nextTick();
161 |
162 | expect(wrapper.vm.isLoading).toBe(true);
163 | expect(wrapper.vm.error).toBeUndefined();
164 | expect(wrapper.vm.data).toBe("done");
165 |
166 | resolvePromise();
167 | await wrapper.vm.$nextTick();
168 | expect(wrapper.vm.isLoading).toBe(false);
169 | expect(wrapper.vm.error).toBeUndefined();
170 | expect(wrapper.vm.data).toBe("done again");
171 | });
172 |
173 | it("retries original promise within wrapped value when retry is called", async () => {
174 | const promiseFn = jest.fn(async () => "done");
175 | const wrapPromiseFn = jest.fn(promiseFn);
176 | const Component = createComponentWithUseAsync(wrapPromiseFn);
177 | const wrapper = shallowMount(Component);
178 | expect(promiseFn).toBeCalledTimes(1);
179 |
180 | wrapper.vm.retry();
181 | await wrapper.vm.$nextTick();
182 |
183 | expect(promiseFn).toBeCalledTimes(2);
184 | });
185 |
186 | it("resets error state when resolve directly follows reject", async () => {
187 | let failReject, successResolve;
188 | const failPromiseFn = () =>
189 | new Promise((resolve, reject) => {
190 | failReject = () => reject("error");
191 | });
192 | const successPromiseFn = () =>
193 | new Promise((resolve) => {
194 | successResolve = () => resolve("success");
195 | });
196 | const wrapPromiseFn = ref(failPromiseFn);
197 | const Component = createComponentWithUseAsync(wrapPromiseFn);
198 |
199 | const wrapper = shallowMount(Component);
200 | wrapPromiseFn.value = successPromiseFn;
201 | await wrapper.vm.$nextTick();
202 | failReject();
203 | successResolve();
204 |
205 | await wrapper.vm.$nextTick();
206 | expect(wrapper.vm.isLoading).toBe(false);
207 | expect(wrapper.vm.error).toBeUndefined();
208 | expect(wrapper.vm.data).toBe("success");
209 | });
210 |
211 | it("sets mutually exclusive data or error", async () => {
212 | const promiseFn = () => Promise.resolve("done");
213 | const wrapPromiseFn = ref(promiseFn);
214 | const Component = createComponentWithUseAsync(wrapPromiseFn);
215 |
216 | const wrapper = shallowMount(Component);
217 | await wrapper.vm.$nextTick();
218 |
219 | expect(wrapper.vm.isLoading).toBe(false);
220 | expect(wrapper.vm.error).toBeUndefined();
221 | expect(wrapper.vm.data).toBe("done");
222 |
223 | wrapPromiseFn.value = () => Promise.reject("error");
224 | await flushPromises();
225 |
226 | expect(wrapper.vm.isLoading).toBe(false);
227 | expect(wrapper.vm.error).toBe("error");
228 | expect(wrapper.vm.data).toBeUndefined();
229 |
230 | wrapPromiseFn.value = () => Promise.resolve("done");
231 | await flushPromises();
232 |
233 | expect(wrapper.vm.isLoading).toBe(false);
234 | expect(wrapper.vm.error).toBeUndefined();
235 | expect(wrapper.vm.data).toBe("done");
236 | });
237 | });
238 |
--------------------------------------------------------------------------------
/lib/tests/useFetch.spec.js:
--------------------------------------------------------------------------------
1 | import { useFetch } from "../src";
2 | import { shallowMount } from "@vue/test-utils";
3 | import flushPromises from "flush-promises";
4 |
5 | // setup vue
6 | import Vue from "vue";
7 | import VueCompositionApi, { ref } from "@vue/composition-api";
8 | Vue.use(VueCompositionApi);
9 |
10 | // component helper
11 | function createComponentWithUseFetch(requestInfo, requestInit) {
12 | return {
13 | setup() {
14 | return useFetch(requestInfo, requestInit);
15 | },
16 | render: (h) => h(),
17 | };
18 | }
19 |
20 | describe("useFetch", () => {
21 | const jsonResult = { success: true };
22 | const textResult = "success";
23 | const response = {
24 | ok: true,
25 | json: jest.fn(async () => jsonResult),
26 | text: jest.fn(async () => textResult),
27 | };
28 | const mockFetch = jest.fn(async () => response);
29 |
30 | beforeEach(() => {
31 | global.fetch = mockFetch;
32 | });
33 |
34 | afterEach(() => {
35 | global.fetch.mockClear();
36 | delete global.fetch;
37 | });
38 |
39 | it("calls fetch with requestInfo and requestInit arguments including signal, returns values", async () => {
40 | const requestInfo = "http://some-url.local";
41 | const requestInit = { headers: { Accept: "application/json" } };
42 | const Component = createComponentWithUseFetch(requestInfo, requestInit);
43 |
44 | const wrapper = shallowMount(Component);
45 | await wrapper.vm.$nextTick();
46 |
47 | expect(wrapper.vm.isLoading).toBe(true);
48 | expect(wrapper.vm.error).toBeUndefined();
49 | expect(wrapper.vm.data).toBeUndefined();
50 | expect(wrapper.vm.retry).toBeDefined();
51 | expect(wrapper.vm.abort).toBeDefined();
52 | expect(fetch).toBeCalledWith(
53 | requestInfo,
54 | expect.objectContaining(requestInit)
55 | );
56 | expect(fetch.mock.calls[0][1].signal).toBeDefined();
57 |
58 | await flushPromises();
59 | expect(wrapper.vm.isLoading).toBe(false);
60 | expect(wrapper.vm.error).toBeUndefined();
61 | expect(wrapper.vm.data).toBe(jsonResult);
62 | });
63 |
64 | it("resolves to text response when no json header is set", async () => {
65 | const Component = createComponentWithUseFetch("");
66 |
67 | const wrapper = shallowMount(Component);
68 | await wrapper.vm.$nextTick();
69 | expect(wrapper.vm.isLoading).toBe(true);
70 |
71 | await flushPromises();
72 | expect(wrapper.vm.isLoading).toBe(false);
73 | expect(wrapper.vm.error).toBeUndefined();
74 | expect(wrapper.vm.data).toBe(textResult);
75 | });
76 |
77 | it("rejects with bad response when response is not ok", async () => {
78 | const Component = createComponentWithUseFetch("");
79 | const failedResponse = {
80 | ok: false,
81 | };
82 | mockFetch.mockResolvedValueOnce(failedResponse);
83 |
84 | const wrapper = shallowMount(Component);
85 | expect(wrapper.vm.isLoading).toBe(true);
86 |
87 | await flushPromises();
88 | expect(wrapper.vm.isLoading).toBe(false);
89 | expect(wrapper.vm.error).toEqual(failedResponse);
90 | expect(wrapper.vm.data).toBeUndefined();
91 | });
92 |
93 | it("accepts value wrapped arguments", async () => {
94 | const requestInfo = "http://some-url.local";
95 | const requestInit = { headers: { Accept: "application/json" } };
96 | const Component = createComponentWithUseFetch(
97 | ref(requestInfo),
98 | ref(requestInit)
99 | );
100 |
101 | const wrapper = shallowMount(Component);
102 | await wrapper.vm.$nextTick();
103 |
104 | expect(fetch).toBeCalledWith(
105 | requestInfo,
106 | expect.objectContaining(requestInit)
107 | );
108 | });
109 |
110 | it("retries original promise when requestInfo argument changes", async () => {
111 | const requestInfo = "http://some-url.local";
112 | const wrapRequestInfo = ref(requestInfo);
113 | const Component = createComponentWithUseFetch(wrapRequestInfo);
114 | const wrapper = shallowMount(Component);
115 | await flushPromises();
116 | expect(wrapper.vm.isLoading).toBe(false);
117 | expect(wrapper.vm.error).toBeUndefined();
118 | expect(wrapper.vm.data).toBe(textResult);
119 | expect(fetch).toBeCalledWith(requestInfo, expect.anything());
120 |
121 | const newTextResult = "success 2";
122 | response.text.mockResolvedValueOnce(newTextResult);
123 | const newRequestInfo = "http://some-other-url.local";
124 | wrapRequestInfo.value = newRequestInfo;
125 | await wrapper.vm.$nextTick();
126 | await wrapper.vm.$nextTick();
127 | expect(wrapper.vm.isLoading).toBe(true);
128 | expect(wrapper.vm.error).toBeUndefined();
129 | expect(wrapper.vm.data).toBe(textResult);
130 | expect(fetch).toBeCalledWith(newRequestInfo, expect.anything());
131 |
132 | await flushPromises();
133 | expect(wrapper.vm.isLoading).toBe(false);
134 | expect(wrapper.vm.error).toBeUndefined();
135 | expect(wrapper.vm.data).toBe(newTextResult);
136 | });
137 |
138 | it("retries original promise when requestInit argument changes", async () => {
139 | const requestInfo = "http://some-url.local";
140 | const requestInit = { headers: { Accept: "application/json" } };
141 | const wrapRequestInit = ref(requestInit);
142 | const Component = createComponentWithUseFetch(requestInfo, wrapRequestInit);
143 | const wrapper = shallowMount(Component);
144 | await flushPromises();
145 | expect(wrapper.vm.isLoading).toBe(false);
146 | expect(wrapper.vm.error).toBeUndefined();
147 | expect(wrapper.vm.data).toBe(jsonResult);
148 |
149 | const newRequestInit = { headers: { Accept: "text/plain" } };
150 | wrapRequestInit.value = newRequestInit;
151 | await wrapper.vm.$nextTick();
152 | await wrapper.vm.$nextTick();
153 | expect(wrapper.vm.isLoading).toBe(true);
154 | expect(wrapper.vm.error).toBeUndefined();
155 | expect(wrapper.vm.data).toBe(jsonResult);
156 | expect(fetch).toBeCalledWith(
157 | requestInfo,
158 | expect.objectContaining(requestInit)
159 | );
160 |
161 | await flushPromises();
162 | expect(wrapper.vm.isLoading).toBe(false);
163 | expect(wrapper.vm.error).toBeUndefined();
164 | expect(wrapper.vm.data).toBe(textResult);
165 | expect(fetch).toBeCalledWith(requestInfo, expect.anything());
166 | });
167 | });
168 |
--------------------------------------------------------------------------------
/lib/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "commonjs",
5 | "declaration": true,
6 | "strict": true,
7 | "allowSyntheticDefaultImports": true,
8 | "esModuleInterop": true,
9 | "outDir": "dist"
10 | },
11 | "include": ["src/**/*.ts", "tests/**/*.ts"],
12 | "exclude": ["node_modules"]
13 | }
14 |
--------------------------------------------------------------------------------