Skip to content

Commit 43b5e81

Browse files
committed
feat: Add useFetch()
1 parent 3839627 commit 43b5e81

File tree

5 files changed

+281
-7
lines changed

5 files changed

+281
-7
lines changed

packages/vue/README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@ Schema driven. Zero updater functions.
2828

2929
</div>
3030

31-
## Status
32-
33-
🚧 **Under Development** - This package is currently being developed and is not yet ready for production use.
34-
3531
## Installation
3632

3733
```bash
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { mount } from '@vue/test-utils';
2+
import nock from 'nock';
3+
import { defineComponent, h, nextTick } from 'vue';
4+
5+
// Reuse the same endpoints/fixtures used by the React tests
6+
import {
7+
CoolerArticleResource,
8+
StaticArticleResource,
9+
} from '../../../../__tests__/new';
10+
// Minimal shared fixture (copied from React test fixtures)
11+
const payload = {
12+
id: 5,
13+
title: 'hi ho',
14+
content: 'whatever',
15+
tags: ['a', 'best', 'react'],
16+
};
17+
import useFetch from '../consumers/useFetch';
18+
import { provideDataClient } from '../providers/provideDataClient';
19+
20+
async function flush() {
21+
await Promise.resolve();
22+
await nextTick();
23+
await new Promise(resolve => setTimeout(resolve, 0));
24+
}
25+
26+
async function flushUntil(wrapper: any, predicate: () => boolean, tries = 100) {
27+
for (let i = 0; i < tries; i++) {
28+
if (predicate()) return;
29+
await flush();
30+
}
31+
}
32+
33+
describe('vue useFetch()', () => {
34+
let mynock: nock.Scope;
35+
beforeAll(() => {
36+
nock(/.*/)
37+
.persist()
38+
.defaultReplyHeaders({
39+
'Access-Control-Allow-Origin': '*',
40+
'Access-Control-Allow-Headers': 'Access-Token',
41+
'Content-Type': 'application/json',
42+
})
43+
.options(/.*/)
44+
.reply(200);
45+
});
46+
beforeEach(() => {
47+
mynock = nock(/.*/).defaultReplyHeaders({
48+
'Access-Control-Allow-Origin': '*',
49+
'Access-Control-Allow-Headers': 'Access-Token',
50+
'Content-Type': 'application/json',
51+
});
52+
});
53+
54+
afterEach(() => {
55+
nock.cleanAll();
56+
});
57+
58+
const ProvideWrapper = defineComponent({
59+
name: 'ProvideWrapper',
60+
setup(_props, { slots, expose }) {
61+
const { controller } = provideDataClient();
62+
expose({ controller });
63+
return () => (slots.default ? slots.default() : h('div'));
64+
},
65+
});
66+
67+
it('should dispatch singles', async () => {
68+
const fetchMock = jest.fn(() => payload);
69+
mynock.get(`/article-cooler/${payload.id}`).reply(200, fetchMock);
70+
71+
const Comp = defineComponent({
72+
name: 'FetchTester',
73+
setup() {
74+
const p = useFetch(CoolerArticleResource.get, { id: payload.id });
75+
return () => h('div', { class: 'root' }, String(!!p));
76+
},
77+
});
78+
79+
const wrapper = mount(ProvideWrapper, {
80+
slots: { default: () => h(Comp) },
81+
});
82+
83+
// Wait for the fetch to happen
84+
await flushUntil(wrapper, () => fetchMock.mock.calls.length > 0);
85+
expect(fetchMock).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('should not dispatch with null params, then dispatch after set', async () => {
89+
const fetchMock = jest.fn(() => payload);
90+
mynock.get(`/article-cooler/${payload.id}`).reply(200, fetchMock);
91+
92+
let params: any = null;
93+
const Comp = defineComponent({
94+
name: 'FetchTesterNull',
95+
setup() {
96+
// reactive params via closure re-render with slot remount
97+
useFetch(CoolerArticleResource.get as any, params);
98+
return () => h('div');
99+
},
100+
});
101+
102+
const wrapper = mount(ProvideWrapper, {
103+
slots: { default: () => h(Comp) },
104+
});
105+
await flush();
106+
expect(fetchMock).toHaveBeenCalledTimes(0);
107+
108+
// change params and remount child to re-run setup
109+
params = { id: payload.id };
110+
wrapper.unmount();
111+
const wrapper2 = mount(ProvideWrapper, {
112+
slots: { default: () => h(Comp) },
113+
});
114+
await flushUntil(wrapper2, () => fetchMock.mock.calls.length > 0);
115+
expect(fetchMock).toHaveBeenCalledTimes(1);
116+
});
117+
118+
it('should respect expiry and not refetch when fresh', async () => {
119+
const fetchMock = jest.fn(() => payload);
120+
mynock.get(`/article-cooler/${payload.id}`).reply(200, fetchMock);
121+
122+
const Child = defineComponent({
123+
name: 'FetchChild',
124+
setup() {
125+
useFetch(CoolerArticleResource.get, { id: payload.id });
126+
return () => h('div');
127+
},
128+
});
129+
130+
const Parent = defineComponent({
131+
name: 'Parent',
132+
setup(_props, { expose }) {
133+
const { controller } = provideDataClient();
134+
let idx = 0;
135+
const remount = () => {
136+
idx++;
137+
};
138+
expose({ controller, remount });
139+
return () => h('div', [h(Child, { key: idx })]);
140+
},
141+
});
142+
143+
const wrapper = mount(Parent);
144+
await flushUntil(wrapper, () => fetchMock.mock.calls.length > 0);
145+
expect(fetchMock).toHaveBeenCalledTimes(1);
146+
147+
// Remount child inside same provider should not refetch while data is fresh
148+
(wrapper.vm as any).remount();
149+
await flush();
150+
expect(fetchMock).toHaveBeenCalledTimes(1);
151+
});
152+
153+
it('should dispatch with resource and endpoint expiry overrides', async () => {
154+
const mock1 = jest.fn(() => payload);
155+
const mock2 = jest.fn(() => payload);
156+
const mock3 = jest.fn(() => payload);
157+
158+
mynock
159+
.get(`/article-static/${payload.id}`)
160+
.reply(200, mock1)
161+
.get(`/article-static/${payload.id}`)
162+
.reply(200, mock2)
163+
.get(`/article-static/${payload.id}`)
164+
.reply(200, mock3);
165+
166+
const Comp1 = defineComponent({
167+
name: 'FetchStaticGet',
168+
setup() {
169+
useFetch(StaticArticleResource.get, { id: payload.id });
170+
return () => h('div');
171+
},
172+
});
173+
const Comp2 = defineComponent({
174+
name: 'FetchStaticLong',
175+
setup() {
176+
useFetch(StaticArticleResource.longLiving, { id: payload.id });
177+
return () => h('div');
178+
},
179+
});
180+
const Comp3 = defineComponent({
181+
name: 'FetchStaticNeverRetry',
182+
setup() {
183+
useFetch(StaticArticleResource.neverRetryOnError, { id: payload.id });
184+
return () => h('div');
185+
},
186+
});
187+
188+
const w1 = mount(ProvideWrapper, { slots: { default: () => h(Comp1) } });
189+
await flushUntil(w1, () => mock1.mock.calls.length > 0);
190+
expect(mock1).toHaveBeenCalled();
191+
192+
const w2 = mount(ProvideWrapper, { slots: { default: () => h(Comp2) } });
193+
await flushUntil(w2, () => mock2.mock.calls.length > 0);
194+
expect(mock2).toHaveBeenCalled();
195+
196+
const w3 = mount(ProvideWrapper, { slots: { default: () => h(Comp3) } });
197+
await flushUntil(w3, () => mock3.mock.calls.length > 0);
198+
expect(mock3).toHaveBeenCalled();
199+
});
200+
});

packages/vue/src/consumers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { default as useQuery } from './useQuery.js';
44
export { default as useLive } from './useLive.js';
55
export { default as useLoading } from './useLoading.js';
66
export { default as useDebounce } from './useDebounce.js';
7+
export { default as useFetch } from './useFetch.js';
78
export { useController as useController } from '../context.js';
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { ExpiryStatus } from '@data-client/core';
2+
import type {
3+
EndpointInterface,
4+
Denormalize,
5+
Schema,
6+
FetchFunction,
7+
DenormalizeNullable,
8+
} from '@data-client/core';
9+
import { computed, watch } from 'vue';
10+
11+
import { useController, injectState } from '../context.js';
12+
13+
/**
14+
* Fetch an Endpoint if it is not in cache or stale.
15+
* Non-suspense; returns the fetch Promise when a request is issued, otherwise undefined.
16+
* Mirrors React useFetch semantics.
17+
* @see https://dataclient.io/docs/api/useFetch
18+
*/
19+
export default function useFetch<
20+
E extends EndpointInterface<
21+
FetchFunction,
22+
Schema | undefined,
23+
undefined | false
24+
>,
25+
>(
26+
endpoint: E,
27+
...args: readonly [...Parameters<E>]
28+
): E['schema'] extends undefined | null ? ReturnType<E>
29+
: Promise<Denormalize<E['schema']>>;
30+
31+
export default function useFetch<
32+
E extends EndpointInterface<
33+
FetchFunction,
34+
Schema | undefined,
35+
undefined | false
36+
>,
37+
>(
38+
endpoint: E,
39+
...args: readonly [...Parameters<E>] | readonly [null]
40+
): E['schema'] extends undefined | null ? ReturnType<E> | undefined
41+
: Promise<DenormalizeNullable<E['schema']>> | undefined;
42+
43+
export default function useFetch(endpoint: any, ...args: any[]): any {
44+
const stateRef = injectState();
45+
const controller = useController();
46+
47+
const key: string = args[0] !== null ? endpoint.key(...args) : '';
48+
49+
// Compute response meta reactively so we can respond to store updates
50+
const responseMeta = computed(() =>
51+
key ? controller.getResponseMeta(endpoint, ...args, stateRef.value) : null,
52+
);
53+
54+
let lastPromise: Promise<any> | undefined = undefined;
55+
56+
const maybeFetch = () => {
57+
if (!key) return;
58+
const meta = responseMeta.value;
59+
if (!meta) return;
60+
const forceFetch = meta.expiryStatus === ExpiryStatus.Invalid;
61+
if (Date.now() <= meta.expiresAt && !forceFetch) return;
62+
lastPromise = controller.fetch(endpoint, ...(args as any));
63+
};
64+
65+
// Trigger on initial call
66+
maybeFetch();
67+
68+
// Also watch for store changes that might require refetch (e.g., invalidation)
69+
watch(
70+
() => {
71+
const m = responseMeta.value;
72+
return m ? [m.expiresAt, m.expiryStatus, stateRef.value.lastReset] : key;
73+
},
74+
() => {
75+
maybeFetch();
76+
},
77+
);
78+
79+
return lastPromise;
80+
}

packages/vue/src/index.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// Vue Data Client - Coming Soon
2-
// This package will provide Vue 3 composables for @data-client/core
3-
41
export { Controller, ExpiryStatus, actionTypes } from '@data-client/core';
52
export type {
63
EndpointExtraOptions,

0 commit comments

Comments
 (0)