Skip to content

Commit a64456d

Browse files
committed
feat: Add useQuery
1 parent a7b3be8 commit a64456d

File tree

10 files changed

+277
-91
lines changed

10 files changed

+277
-91
lines changed

packages/vue/README.md

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
The scalable way to build applications with [dynamic data](https://dataclient.io/docs/getting-started/mutations).
1010

1111
[Declarative resouce definitons](https://dataclient.io/docs/getting-started/resource) for [REST](https://dataclient.io/rest), [GraphQL](https://dataclient.io/graphql), [Websockets+SSE](https://dataclient.io/docs/concepts/managers#data-stream) and [more](https://dataclient.io/rest/api/Endpoint)
12-
<br/>[Performant rendering](https://dataclient.io/docs/getting-started/data-dependency) in [Vue 3](https://vuejs.org/), [Nuxt](https://nuxt.com/)
12+
<br/>[Performant rendering](https://dataclient.io/docs/getting-started/data-dependency) in [Vue 3](https://vuejs.org/)
1313

1414
Schema driven. Zero updater functions.
1515

@@ -113,7 +113,7 @@ provideDataClient({
113113
114114
<script setup lang="ts">
115115
const props = defineProps<{ id: string }>();
116-
const article = useSuspense(ArticleResource.get, { id: props.id });
116+
const article = await useSuspense(ArticleResource.get, { id: props.id });
117117
</script>
118118
```
119119

@@ -275,25 +275,23 @@ For the small price of 9kb gziped. &nbsp;&nbsp; [🏁Get started now](https://da
275275
## Planned Features
276276

277277
- [x] ![TS](./typescript.svg?sanitize=true) Strong [Typescript](https://www.typescriptlang.org/) inference
278-
- [ ] 🔄 Vue 3 [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html) composables
279-
- [ ] 🏪 [Pinia](https://pinia.vuejs.org/) store integration
280-
- [ ] 💦 [Server Side Rendering](https://nuxt.com/) with Nuxt
281-
- [ ] 🎣 [Declarative API](https://dataclient.io/docs/getting-started/data-dependency)
282-
- [ ] 📝 Composition over configuration
283-
- [ ] 💰 [Normalized](https://dataclient.io/docs/concepts/normalization) caching
284-
- [ ] 💥 Tiny bundle footprint
285-
- [ ] 🛑 Automatic overfetching elimination
286-
- [ ] ✨ Fast [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates)
287-
- [ ] 🧘 [Flexible](https://dataclient.io/docs/getting-started/resource) to fit any API design (one size fits all)
288-
- [ ] 🔧 [Debugging and inspection](https://dataclient.io/docs/getting-started/debugging) via browser extension
289-
- [ ] 🌳 Tree-shakable (only use what you need)
290-
- [ ] 🔁 [Subscriptions](https://dataclient.io/docs/api/useSubscription)
291-
- [ ] 📙 [Storybook mocking](https://dataclient.io/docs/guides/storybook)
292-
- [ ] 🚯 [Declarative cache lifetime policy](https://dataclient.io/docs/concepts/expiry-policy)
293-
- [ ] 🧅 [Composable middlewares](https://dataclient.io/docs/api/Manager)
294-
- [ ] 💽 Global data consistency guarantees
295-
- [ ] 🏇 Automatic race condition elimination
296-
- [ ] 👯 Global referential equality guarantees
278+
- [x] 🔄 Vue 3 [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html) composables
279+
- [x] 🎣 [Declarative API](https://dataclient.io/docs/getting-started/data-dependency)
280+
- [x] 📝 Composition over configuration
281+
- [x] 💰 [Normalized](https://dataclient.io/docs/concepts/normalization) caching
282+
- [x] 💥 Tiny bundle footprint
283+
- [x] 🛑 Automatic overfetching elimination
284+
- [x] ✨ Fast [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates)
285+
- [x] 🧘 [Flexible](https://dataclient.io/docs/getting-started/resource) to fit any API design (one size fits all)
286+
- [x] 🔧 [Debugging and inspection](https://dataclient.io/docs/getting-started/debugging) via browser extension
287+
- [x] 🌳 Tree-shakable (only use what you need)
288+
- [x] 🔁 [Subscriptions](https://dataclient.io/docs/api/useSubscription)
289+
- [x] 📙 [Storybook mocking](https://dataclient.io/docs/guides/storybook)
290+
- [x] 🚯 [Declarative cache lifetime policy](https://dataclient.io/docs/concepts/expiry-policy)
291+
- [x] 🧅 [Composable middlewares](https://dataclient.io/docs/api/Manager)
292+
- [x] 💽 Global data consistency guarantees
293+
- [x] 🏇 Automatic race condition elimination
294+
- [x] 👯 Global referential equality guarantees
297295

298296
## Planned API
299297

packages/vue/package.json

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@data-client/vue",
3-
"version": "0.14.25",
3+
"version": "0.0.2",
44
"description": "Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch",
55
"homepage": "https://dataclient.io",
66
"repository": {
@@ -113,15 +113,11 @@
113113
},
114114
"peerDependencies": {
115115
"@types/vue": "^3.0.0",
116-
"pinia": "^2.0.0",
117116
"vue": "^3.0.0"
118117
},
119118
"peerDependenciesMeta": {
120119
"@types/vue": {
121120
"optional": true
122-
},
123-
"pinia": {
124-
"optional": false
125121
}
126122
},
127123
"devDependencies": {
@@ -136,7 +132,6 @@
136132
"jest-environment-jsdom": "^30.0.0",
137133
"jest-mock": "^30.0.0",
138134
"nock": "13.3.1",
139-
"pinia": "^2.2.0",
140135
"rollup-plugins": "workspace:*",
141136
"vue": "^3.4.0"
142137
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { schema } from '@data-client/endpoint';
2+
import { resource } from '@data-client/rest';
3+
import { mount } from '@vue/test-utils';
4+
import { defineComponent, h, nextTick } from 'vue';
5+
6+
import {
7+
ArticleWithSlug,
8+
ArticleSlugResource,
9+
ArticleResource,
10+
IDEntity,
11+
} from '../../../../__tests__/new';
12+
import useQuery from '../consumers/useQuery';
13+
import { provideDataClient } from '../providers/provideDataClient';
14+
15+
// Inline fixtures (duplicated from React tests to avoid cross-project imports)
16+
const payloadSlug = {
17+
id: 5,
18+
title: 'hi ho',
19+
slug: 'hi-ho',
20+
content: 'whatever',
21+
tags: ['a', 'best', 'react'],
22+
};
23+
const nested = [
24+
{
25+
id: 5,
26+
title: 'hi ho',
27+
content: 'whatever',
28+
tags: ['a', 'best', 'react'],
29+
author: {
30+
id: 23,
31+
username: 'bob',
32+
},
33+
},
34+
{
35+
id: 3,
36+
title: 'the next time',
37+
content: 'whatever',
38+
author: {
39+
id: 23,
40+
username: 'charles',
41+
42+
},
43+
},
44+
];
45+
46+
describe('vue useQuery()', () => {
47+
async function flush() {
48+
await Promise.resolve();
49+
await nextTick();
50+
}
51+
52+
const ProvideWrapper = defineComponent({
53+
name: 'ProvideWrapper',
54+
setup(_props, { slots, expose }) {
55+
const { controller } = provideDataClient();
56+
expose({ controller });
57+
return () => (slots.default ? slots.default() : null);
58+
},
59+
});
60+
61+
it('returns undefined with empty state', async () => {
62+
const Inner = defineComponent({
63+
setup() {
64+
const val = useQuery(ArticleWithSlug, { id: payloadSlug.id });
65+
return () => h('div', (val.value as any)?.title || '');
66+
},
67+
});
68+
69+
const wrapper = mount(ProvideWrapper, {
70+
slots: { default: () => h(Inner) },
71+
});
72+
await flush();
73+
expect(wrapper.text()).toBe('');
74+
});
75+
76+
it('finds Entity by pk/slug after setResponse', async () => {
77+
const Inner = defineComponent({
78+
setup() {
79+
const byId = useQuery(ArticleWithSlug, { id: payloadSlug.id });
80+
const bySlug = useQuery(ArticleWithSlug, { slug: payloadSlug.slug });
81+
return () =>
82+
h(
83+
'div',
84+
`${(byId.value as any)?.title || ''}|${
85+
(bySlug.value as any)?.title || ''
86+
}`,
87+
);
88+
},
89+
});
90+
91+
const wrapper = mount(ProvideWrapper, {
92+
slots: { default: () => h(Inner) },
93+
});
94+
const { controller }: any = wrapper.vm as any;
95+
96+
// seed data via controller
97+
controller.setResponse(
98+
ArticleSlugResource.get,
99+
{ id: payloadSlug.id },
100+
payloadSlug,
101+
);
102+
await flush();
103+
104+
expect(wrapper.text()).toContain(payloadSlug.title);
105+
});
106+
107+
it('selects Collections and updates when pushed', async () => {
108+
const ListComp = defineComponent({
109+
setup() {
110+
const list = useQuery(ArticleResource.getList.schema);
111+
return () =>
112+
h('div', (list.value || []).map((a: any) => a.id).join(','));
113+
},
114+
});
115+
116+
const wrapper = mount(ProvideWrapper, {
117+
slots: { default: () => h(ListComp) },
118+
});
119+
const { controller }: any = wrapper.vm as any;
120+
121+
controller.setResponse(ArticleResource.getList, {}, nested);
122+
await flush();
123+
expect(wrapper.text().split(',').filter(Boolean).length).toBe(
124+
nested.length,
125+
);
126+
127+
// Simulate push by setting new list value
128+
const appended = nested.concat({
129+
id: 50,
130+
title: 'new',
131+
content: 'x',
132+
} as any);
133+
controller.setResponse(ArticleResource.getList, {}, appended);
134+
await flush();
135+
expect(wrapper.text().split(',').filter(Boolean).length).toBe(
136+
nested.length + 1,
137+
);
138+
});
139+
140+
it('retrieves a nested collection (Collection of Array)', async () => {
141+
class Todo extends IDEntity {
142+
userId = 0;
143+
title = '';
144+
completed = false;
145+
static key = 'Todo';
146+
}
147+
148+
class User extends IDEntity {
149+
name = '';
150+
username = '';
151+
email = '';
152+
todos: Todo[] = [];
153+
static key = 'User';
154+
static schema = {
155+
todos: new schema.Collection(new schema.Array(Todo), {
156+
nestKey: (parent: any) => ({ userId: parent.id }),
157+
}),
158+
};
159+
}
160+
161+
const userTodos = new schema.Collection(new schema.Array(Todo), {
162+
argsKey: ({ userId }: { userId: string }) => ({ userId }),
163+
});
164+
165+
const UserResource = resource({ schema: User, path: '/users/:id' });
166+
167+
const Inner = defineComponent({
168+
setup() {
169+
const todos = useQuery(userTodos, { userId: '1' });
170+
return () => h('div', (todos.value || []).length.toString());
171+
},
172+
});
173+
174+
const wrapper = mount(ProvideWrapper, {
175+
slots: { default: () => h(Inner) },
176+
});
177+
const { controller }: any = wrapper.vm as any;
178+
179+
controller.setResponse(
180+
UserResource.get,
181+
{ id: '1' },
182+
{
183+
id: '1',
184+
todos: [{ id: '5', title: 'finish collections', userId: '1' }],
185+
username: 'bob',
186+
},
187+
);
188+
await flush();
189+
190+
expect(wrapper.text()).toBe('1');
191+
});
192+
193+
it('works with unions collections (sanity)', async () => {
194+
// Keep this light: verify we can call useQuery on a list schema and get an array
195+
const list = useQuery(ArticleResource.getList.schema);
196+
const Comp = defineComponent({
197+
setup: () => () => h('div', (list.value || []).length),
198+
});
199+
const wrapper = mount(ProvideWrapper, {
200+
slots: { default: () => h(Comp) },
201+
});
202+
const { controller }: any = wrapper.vm as any;
203+
controller.setResponse(ArticleResource.getList, {}, []);
204+
await flush();
205+
expect(wrapper.text()).toBe('0');
206+
});
207+
});
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
export { default as useSuspense } from './useSuspense.js';
22
export { default as useSubscription } from './useSubscription.js';
3-
export {
4-
injectController as useController,
5-
injectState as useStateRef,
6-
} from '../context.js';
3+
export { default as useQuery } from './useQuery.js';
4+
export { useController as useController } from '../context.js';

packages/vue/src/consumers/useInjects.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type {
2+
DenormalizeNullable,
3+
NI,
4+
Queryable,
5+
SchemaArgs,
6+
} from '@data-client/core';
7+
import { computed, watch, type ComputedRef } from 'vue';
8+
9+
import { useController, injectState } from '../context.js';
10+
11+
/**
12+
* Query the store (non-suspense).
13+
*
14+
* Returns a readonly computed ref of the query result. The value is undefined when
15+
* the result is not found or invalid.
16+
* Mirrors React's useQuery semantics using Vue reactivity.
17+
* @see https://dataclient.io/docs/api/useQuery
18+
*/
19+
export default function useQuery<S extends Queryable>(
20+
schema: S,
21+
...args: NI<SchemaArgs<S>>
22+
): ComputedRef<DenormalizeNullable<S> | undefined> {
23+
const stateRef = injectState();
24+
const controller = useController();
25+
26+
// Compute query meta based on state and args. This mirrors React's memoization
27+
// that keys off state.entities/indexes and args.
28+
const queryMeta = computed(() =>
29+
controller.getQueryMeta(schema, ...args, stateRef.value as any),
30+
);
31+
32+
// Maintain GC refcounts on data mount/changes
33+
watch(
34+
() => queryMeta.value.data,
35+
(_newVal, _oldVal, onCleanup) => {
36+
const decrement = queryMeta.value.countRef();
37+
onCleanup(() => decrement());
38+
},
39+
{ immediate: true },
40+
);
41+
42+
return computed(() => queryMeta.value.data);
43+
}

packages/vue/src/consumers/useSubscription.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
} from '@data-client/core';
66
import { computed, unref, watch } from 'vue';
77

8-
import { injectController } from '../context.js';
8+
import { useController } from '../context.js';
99

1010
/**
1111
* Keeps a resource fresh by subscribing to updates.
@@ -19,7 +19,7 @@ export default function useSubscription<
1919
undefined | false
2020
>,
2121
>(endpoint: E, ...args: readonly [...Parameters<E>] | readonly [null]) {
22-
const controller = injectController();
22+
const controller = useController();
2323

2424
// Track top-level reactive args (Refs are unwrapped). This allows props/refs to trigger resubscribe.
2525
const resolvedArgs = computed(() => args.map(a => unref(a as any)) as any);

0 commit comments

Comments
 (0)