diff --git a/.agents/skills/data-client-vue-testing/SKILL.md b/.agents/skills/data-client-vue-testing/SKILL.md index d986ccbb7020..2fb77d8ce34c 100644 --- a/.agents/skills/data-client-vue-testing/SKILL.md +++ b/.agents/skills/data-client-vue-testing/SKILL.md @@ -362,13 +362,14 @@ expect(result.current?.value?.title).toBe('hi ho'); - **useSuspense returns Promise → ComputedRef** - Await once, then access `.value` - **Test both empty and populated states** - Verify undefined behavior - **Test reactive prop changes** - Use `reactive()` and verify updates -- **Don't test with async setup + prop changes** - Async setup only runs once; use non-async patterns or useFetch + watchEffect instead +- **Pass prop-derived args as getters** - Async setup runs once, so `useSuspense(Resource.get, () => ({ id: props.id }))` follows prop changes; a plain `{ id: props.id }` is read once ## References For detailed API documentation, see the [references](references/) directory: - [Fixtures](references/Fixtures.md) - Fixture format reference +- [vue-test-utilities](references/vue-test-utilities.md) - `renderDataCompose()` and `mountDataClient()` guide - [nock-http-mocking](references/nock-http-mocking.md) - Full nock setup, dynamic server state, request spying, errors, pitfalls - [polling-subscriptions](references/polling-subscriptions.md) - Fake-timer patterns for `useLive`/`useSubscription`/`pollFrequency`, unsubscribe verification, polling via nock diff --git a/.agents/skills/data-client-vue-testing/references/vue-test-utilities.md b/.agents/skills/data-client-vue-testing/references/vue-test-utilities.md new file mode 100644 index 000000000000..b9a9cd50af35 --- /dev/null +++ b/.agents/skills/data-client-vue-testing/references/vue-test-utilities.md @@ -0,0 +1,307 @@ +# Vue Data Client Testing Utilities + +This package provides testing utilities for Vue applications using `@data-client/vue`, similar to `@data-client/test` for React. + +## Installation + +```bash +npm install @data-client/vue +``` + +## Usage + +### Basic Component Testing + +```typescript +import { mountDataClient } from '@data-client/vue/test'; +import { defineComponent, h } from 'vue'; +import { MyResource } from './resources'; + +const TestComponent = defineComponent({ + setup() { + return () => h('div', 'Hello World'); + }, +}); + +const { wrapper, controller, cleanup } = mountDataClient(TestComponent, { + initialFixtures: [ + { + endpoint: MyResource.get, + args: [{ id: 1 }], + response: { id: 1, name: 'Test' }, + }, + ], +}); + +// Your tests here +expect(wrapper.text()).toBe('Hello World'); + +// Clean up after test +cleanup(); +``` + +### Testing with Suspense (Automatic) + +Suspense is automatically integrated into `mountDataClient`. Your components will automatically suspend and show a fallback while data is loading: + +```typescript +import { mountDataClient } from '@data-client/vue/test'; +import { defineComponent, h } from 'vue'; +import { MyResource } from './resources'; + +const AsyncComponent = defineComponent({ + async setup() { + const data = await useSuspense(MyResource.get, { id: 1 }); + return () => h('div', data.value.name); + }, +}); + +const { wrapper, controller, cleanup } = mountDataClient(AsyncComponent); + +// Initially shows suspense fallback +expect(wrapper.find('[data-testid="suspense-fallback"]').exists()).toBe(true); + +// Wait for data to load +await flushUntil(wrapper, () => wrapper.find('div').exists()); + +// Now shows the actual content +expect(wrapper.text()).toBe('Test'); + +cleanup(); +``` + +### Composable Testing + +```typescript +import { renderDataCompose } from '@data-client/vue/test'; +import { reactive } from 'vue'; + +const useMyComposable = (props: { id: number }) => { + const data = useSuspense(MyResource.get, { id: props.id }); + return { data, isLoading: false }; +}; + +const props = reactive({ id: 1 }); +const { result, controller, cleanup, waitForNextUpdate } = renderDataCompose(useMyComposable, { + props, + initialFixtures: [ + { + endpoint: MyResource.get, + args: [{ id: 1 }], + response: { id: 1, name: 'Test' }, + }, + ], +}); + +// Initially suspended +expect(result.current).toBeUndefined(); + +// Wait for the composable to resolve +await waitForNextUpdate(); + +// Now should have the actual data (useSuspense returns a Promise that resolves to ComputedRef) +expect(result.current).toBeDefined(); +expect(result.current).toBeInstanceOf(Promise); + +// Access the actual data +const dataRef = await result.current; +expect(dataRef.value).toBeDefined(); + +// Update props reactively +props.id = 2; + +cleanup(); +``` + +### Testing with Reactive Props + +Components can receive reactive props that can be updated during tests: + +```typescript +import { mountDataClient } from '@data-client/vue/test'; +import { defineComponent, h, reactive } from 'vue'; +import { MyResource } from './resources'; + +const ArticleComponent = defineComponent({ + props: { + id: { + type: Number, + required: true, + }, + }, + async setup(props) { + // a getter so the fetch follows props.id + const article = await useSuspense(MyResource.get, () => ({ id: props.id })); + return () => h('div', article.value.title); + }, +}); + +// Create a reactive props ref +const props = reactive({ id: 1 }); + +const { wrapper, cleanup } = mountDataClient(ArticleComponent, { + props, + resolverFixtures: [ + { + endpoint: MyResource.get, + response: (request) => ({ + id: request.args[0].id, + title: `Article ${request.args[0].id}`, + }), + }, + ], +}); + +// Wait for initial render +await flushUntil(wrapper, () => wrapper.find('div').exists()); +expect(wrapper.text()).toBe('Article 1'); + +// Update props reactively - component will re-render with new data +props.id = 2; +await nextTick(); + +// Wait for new data to load +await flushUntil(wrapper, () => wrapper.text() === 'Article 2'); +expect(wrapper.text()).toBe('Article 2'); + +cleanup(); +``` + +### Using Resolver Fixtures + +```typescript +const { wrapper, controller, cleanup } = mountDataClient(TestComponent, { + resolverFixtures: [ + { + endpoint: MyResource.get, + response: (request) => { + return { + id: request.args[0].id, + name: `Dynamic ${request.args[0].id}`, + }; + }, + }, + ], +}); + +// Test dynamic responses +const result = await controller.fetch(MyResource.get, { id: 123 }); +expect(result.name).toBe('Dynamic 123'); +``` + +## API Reference + +### `mountDataClient(component, options)` + +Renders a Vue component with DataClient provider for testing. + +**Parameters:** +- `component`: Vue component to render +- `options`: Configuration options + +**Returns:** +- `wrapper`: Vue Test Utils wrapper +- `controller`: DataClient controller instance +- `app`: Vue app instance +- `cleanup`: Function to clean up resources +- `allSettled`: Function to wait for all pending promises + +### `renderDataCompose(composable, options)` + +Renders a Vue composable with DataClient provider for testing. + +**Parameters:** +- `composable`: Vue composable function +- `options`: Configuration options + +**Returns:** +- `result`: Object with `current` property containing the composable's return value (`undefined` when suspended, Promise when resolved) +- `wrapper`: Vue Test Utils wrapper +- `controller`: DataClient controller instance +- `cleanup`: Function to clean up resources +- `allSettled`: Function to wait for all pending promises +- `waitForNextUpdate`: Function to wait for the composable to resolve from suspended state + +### Options + +```typescript +interface RenderDataClientOptions

{ + props?: Reactive

; // Reactive props ref to pass to component + initialFixtures?: readonly Fixture[]; // Initial data fixtures + resolverFixtures?: readonly (Fixture | Interceptor)[]; // Dynamic response fixtures + getInitialInterceptorData?: () => any; // Initial data for interceptors + managers?: Manager[]; // Custom managers + initialState?: State; // Custom initial state + gcPolicy?: GCInterface; // Custom garbage collection policy + wrapper?: any; // Custom wrapper component +} +``` + +### Fixture Types + +```typescript +interface Fixture { + endpoint: FixtureEndpoint; + args: any[]; + response: any; + error?: boolean; +} + +interface Interceptor { + endpoint: FixtureEndpoint; + response: (request: { + body?: any; + headers?: Record; + url: string; + method: string; + args: any[]; + }) => any | Promise; + error?: boolean; +} +``` + +## Best Practices + +1. **Always call cleanup()** after your tests to prevent memory leaks +2. **Use fixtures** instead of mocking network requests when possible +3. **Test both success and error cases** using the `error` property in fixtures +4. **Use resolver fixtures** for dynamic responses based on request parameters +5. **Leverage the controller** for testing mutations and state updates +6. **Suspense is automatic** - no need to manually wrap components in Suspense +7. **Use reactive props** - Pass a `reactive` in the `props` option and set its members to change component props +8. **Vue Suspense behavior** - Vue's `useSuspense` returns a Promise that suspends when data is missing, then resolves to a ComputedRef +9. **Reactive props with async setup** - Async setup runs once per component instance, so pass prop-derived arguments as a getter (`() => ({ id: props.id })`); a plain object is read once and won't follow prop changes. + +## Migration from Manual Setup + +If you were previously setting up DataClient manually in tests: + +```typescript +// Before +const wrapper = mount(MyComponent, { + global: { + plugins: [ + [ + DataClientPlugin, + { + managers: [new NetworkManager()], + initialState: mockState, + }, + ], + ], + }, +}); + +// After +const { wrapper, controller, cleanup } = mountDataClient(MyComponent, { + initialFixtures: [ + { + endpoint: MyResource.get, + args: [{ id: 1 }], + response: { id: 1, name: 'Test' }, + }, + ], +}); +``` + +This approach is more declarative, easier to maintain, and provides better test isolation. diff --git a/.agents/skills/data-client-vue/SKILL.md b/.agents/skills/data-client-vue/SKILL.md new file mode 100644 index 000000000000..1ddcf0360a45 --- /dev/null +++ b/.agents/skills/data-client-vue/SKILL.md @@ -0,0 +1,191 @@ +--- +name: data-client-vue +description: Use @data-client/vue composables for data fetching, mutations, and rendering - useSuspense, useFetch, useQuery, useCache, useLive, useDLE, useSubscription, useController, useLoading, useDebounce, DataClientPlugin, Suspense, onErrorCaptured. Use when reading/rendering remote data, triggering mutations, doing optimistic updates, real-time subscriptions, or wiring Suspense and error handling in Vue 3. +license: Apache 2.0 +--- +## Setup + +Install `DataClientPlugin` once with `app.use(DataClientPlugin)` ([installation](references/installation.md); +apply the skill "data-client-setup" to set it up). Composables only work inside ` + + +``` diff --git a/.agents/skills/data-client-vue/references/_VoteDemo.md b/.agents/skills/data-client-vue/references/_VoteDemo.md new file mode 100644 index 000000000000..cf888d755a76 --- /dev/null +++ b/.agents/skills/data-client-vue/references/_VoteDemo.md @@ -0,0 +1,124 @@ + + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` diff --git a/.agents/skills/data-client-vue/references/_pagination.md b/.agents/skills/data-client-vue/references/_pagination.md new file mode 100644 index 000000000000..f258e6b2e6bc --- /dev/null +++ b/.agents/skills/data-client-vue/references/_pagination.md @@ -0,0 +1,108 @@ + + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} +import { Entity, resource, Collection } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +```html title="PostItem.vue" + + + +``` + +```html title="LoadMore.vue" + + + +``` + +```html title="PostList.vue" + + + +``` diff --git a/.agents/skills/data-client-vue/references/_useLive.md b/.agents/skills/data-client-vue/references/_useLive.md new file mode 100644 index 000000000000..74e89472eafc --- /dev/null +++ b/.agents/skills/data-client-vue/references/_useLive.md @@ -0,0 +1,57 @@ + + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```html title="AssetPrice.vue" {6} + + + +``` diff --git a/.agents/skills/data-client-vue/references/_useLoading.md b/.agents/skills/data-client-vue/references/_useLoading.md new file mode 100644 index 000000000000..aea12d095d10 --- /dev/null +++ b/.agents/skills/data-client-vue/references/_useLoading.md @@ -0,0 +1,123 @@ + + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```html title="PostDetail.vue" + + + +``` + +```html title="PostForm.vue" + + + +``` + +```html title="PostCreate.vue" {9-14} + + + +``` + +```html title="Navigation.vue" + + + +``` diff --git a/.agents/skills/data-client-vue/references/_vueArgs.md b/.agents/skills/data-client-vue/references/_vueArgs.md new file mode 100644 index 000000000000..b3e3e6504d6b --- /dev/null +++ b/.agents/skills/data-client-vue/references/_vueArgs.md @@ -0,0 +1,5 @@ + + +Arguments can be plain values, [refs](https://vuejs.org/api/reactivity-core.html#ref) (including [computed](https://vuejs.org/api/reactivity-core.html#computed)), or getter +functions like `() => ({ id: props.id })`. A plain object like `{ id: props.id }` is read once and won't +follow prop or route changes, so use a getter or `computed` when an argument can change. diff --git a/.agents/skills/data-client-vue/references/data-dependency.md b/.agents/skills/data-client-vue/references/data-dependency.md new file mode 100644 index 000000000000..bf42ed3c9ea1 --- /dev/null +++ b/.agents/skills/data-client-vue/references/data-dependency.md @@ -0,0 +1,364 @@ + + +# Rendering Asynchronous Data + +Make your components reusable by binding the data where you **use** it with the one-line [useSuspense()](./useSuspense.md), +which guarantees data with [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'page', +}); +``` + +```html title="PostDetail.vue" {7} + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="PostList.vue" {7} + + + +``` + +```html title="LoadMore.vue" + + + +``` + +```html title="Navigation.vue" + + + +``` + +[](https://react.dev/learn/passing-data-deeply-with-context) + +Do not [prop drill](https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props). Instead, [useSuspense()](./useSuspense.md) in the components that render the data from it. This is +known as _data co-location_. + +Do not hide data binding hooks inside custom hooks. Instead, put tightly coupled data transformations +in [Query](https://dataclient.io/rest/api/Query) — data logic belongs with the data model, where it stays visible, reusable, +and free to change independently of the view. + +Instead of writing complex update functions or invalidations cascades, Reactive Data Client automatically updates +bound components immediately upon [data change](./mutations.md). This is known as _reactive programming_. + +## Loading and Error {#async-fallbacks} + +You might have noticed the return type shows the value is always there. [useSuspense()](./useSuspense.md) operates very much +with [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). This enables +us to make error/loading disjoint from data usage. + +### Async Boundaries {#boundaries} + +Instead we place Vue's built-in [\](https://vuejs.org/guide/built-ins/suspense.html) along with [onErrorCaptured()](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured) to handling loading and error conditions at or above navigational boundaries like **pages, +routes, or [modals](https://www.appcues.com/blog/modal-dialog-windows)**. + +```html title="Dashboard.vue" {13-20} + + + +``` + +Centralizing fallbacks this way eliminates redundant loading indicators while keeping components reusable. +The loading fallback is customized with the `#fallback` slot of [\](https://vuejs.org/guide/built-ins/suspense.html#loading-state), +and the error fallback by rendering what you choose from [onErrorCaptured()](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). + +### Stateful + +You may find cases where it's still useful to use a stateful approach to fallbacks. +For these cases, or compatibility with some component libraries, [useDLE()](./useDLE.md) - \[D]ata \[L]oading \[E]rror - is provided. + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```html title="ProfileList.vue" {5} + + + +``` + +Since [useDLE](./useDLE.md) does not [useSuspense](./useSuspense.md), you won't be able to easily centrally +orchestrate loading and error code. + +## Conditional + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = await useSuspense( +> TodoResource.get, +> computed(() => (id.value ? { id: id.value } : null)), +> ); +> ``` + +## Subscriptions + +When data is likely to change due to external factor; [useSubscription()](./useSubscription.md) +ensures continual updates while a component is mounted. [useLive()](./useLive.md) calls both +[useSubscription()](./useSubscription.md) and [useSuspense()](./useSuspense.md), making it quite +easy to use fresh data. + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```html title="AssetPrice.vue" {6} + + + +``` + +Subscriptions are orchestrated by [Managers](https://dataclient.io/vue/api/Manager). Out of the box, +polling based subscriptions can be used by adding [pollFrequency](https://dataclient.io/rest/api/Endpoint#pollfrequency) to an Endpoint or Resource. +For pushed based networking protocols like SSE and websockets, see the [example stream manager](https://dataclient.io/vue/concepts/managers#data-stream). + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + pollFrequency: 2000, +}); +``` diff --git a/.agents/skills/data-client-vue/references/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md new file mode 100644 index 000000000000..a6551fccbb53 --- /dev/null +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -0,0 +1,366 @@ +# Debugging Data Client with Chrome DevTools MCP + +Debug `@data-client/react` or `@data-client/vue` state and actions programmatically via Chrome DevTools MCP `evaluate_script`. The app's `DevToolsManager` exposes the Controller on `globalThis.__DC_CONTROLLERS__` (a `Map` keyed by `devtoolsName`) in dev mode. + +## Prerequisites + +1. Dev server running with `NODE_ENV !== 'production'` +2. Chrome DevTools MCP connected and page loaded +3. `DevToolsManager` included in `DataProvider` (React) or `DataClientPlugin` (Vue) managers (default in dev mode) + +## Step 1: Access the Controller + +`DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` keyed by +`devtoolsName` — defaults to `"Data Client: "`. + +### Discover available controllers + +```js +// evaluate_script +() => { + const m = globalThis.__DC_CONTROLLERS__; + if (!m || m.size === 0) return 'no controllers registered'; + return [...m.keys()]; +} +``` + +### Get a controller by key + +Use the key from discovery. Always use `.get(devtoolsName)` with the actual key — not `.values().next().value` — so you target the correct store when multiple `DataProvider`s or Vue apps exist. + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App'); + if (!ctrl) return 'not found'; + return { ok: true, stateKeys: Object.keys(ctrl.getState()) }; +} +``` + +## Step 2: Install the Debug Shim + +Run this **once** after the page loads. It wraps dispatch to capture all actions in a circular buffer. + +```js +// evaluate_script +(() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + if (!ctrl) return { error: 'No controller found' }; + + globalThis.__DC_ACTION_LOG__ = []; + const MAX_LOG = 200; + + const origDispatch = ctrl._dispatch.bind(ctrl); + ctrl._dispatch = (action) => { + const entry = { + type: action.type, + key: action.key, + ts: Date.now(), + }; + + if (action.endpoint) entry.endpoint = action.endpoint.name || action.endpoint.key; + if (action.args) entry.args = JSON.parse(JSON.stringify(action.args)); + if (action.meta?.date) entry.date = action.meta.date; + if (action.error) entry.error = true; + + globalThis.__DC_ACTION_LOG__.push(entry); + if (globalThis.__DC_ACTION_LOG__.length > MAX_LOG) { + globalThis.__DC_ACTION_LOG__ = globalThis.__DC_ACTION_LOG__.slice(-MAX_LOG / 2); + } + + return origDispatch(action); + }; + + return { ok: true, stateKeys: Object.keys(ctrl.getState()) }; +})() +``` + +## Step 3: Read State + +### High-level queries (denormalized, schema-aware) + +Controller provides `getResponse`, `getError`, and `get` that denormalize through schemas — pass `ctrl.getState()` as the last argument. + +```js +// evaluate_script — get denormalized response for an endpoint +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const { data, expiryStatus, expiresAt } = ctrl.getResponse( + mod.TodoResource.getList, + {}, + state, + ); + return { data, expiryStatus, expiresAt }; +} +``` + +```js +// evaluate_script — check if an endpoint has an error +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const error = ctrl.getError( + mod.TodoResource.get, + { id: '5' }, + state, + ); + return { error: error?.message ?? null }; +} +``` + +```js +// evaluate_script — query a Queryable schema (Entity, Collection, Query) +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const result = ctrl.get(mod.Todo, { id: '5' }, state); + return result; +} +``` + +### Raw normalized state inspection + +Use these when you need to see the raw cache structure without denormalization. + +```js +// evaluate_script — state overview +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + return { + entityTypes: Object.keys(state.entities), + endpointCount: Object.keys(state.endpoints).length, + metaCount: Object.keys(state.meta).length, + optimisticCount: state.optimistic.length, + lastReset: state.lastReset, + }; +} +``` + +```js +// evaluate_script — inspect specific entity type by key +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const entities = ctrl.getState().entities['Todo']; + if (!entities) return { error: 'Entity not found' }; + const pks = Object.keys(entities); + return { + count: pks.length, + samplePKs: pks.slice(0, 10), + sample: pks.length > 0 ? entities[pks[0]] : null, + }; +} +``` + +```js +// evaluate_script — find endpoint cache entries by path substring +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const keys = Object.keys(state.endpoints).filter(k => k.includes('/todos')); + return keys.map(k => ({ + key: k, + value: state.endpoints[k], + meta: state.meta[k], + })); +} +``` + +### Inspect a specific entity by pk + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + return ctrl.getState().entities?.['Todo']?.['5']; +} +``` + +### Check endpoint metadata (expiry, errors, invalidation) + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + const state = ctrl.getState(); + const key = Object.keys(state.meta).find(k => k.includes('/todos')); + return key ? { key, ...state.meta[key] } : 'no meta found'; +} +``` + +## Step 4: Track Actions + +After installing the debug shim (Step 2): + +### Read recent actions + +```js +// evaluate_script +() => globalThis.__DC_ACTION_LOG__?.slice(-20) ?? [] +``` + +### Filter by action type + +```js +// evaluate_script — track only fetches +() => (globalThis.__DC_ACTION_LOG__ ?? []) + .filter(a => a.type === 'rdc/fetch' || a.type === 'rdc/setresponse') + .slice(-20) +``` + +### Filter errors + +```js +// evaluate_script +() => (globalThis.__DC_ACTION_LOG__ ?? []).filter(a => a.error) +``` + +### Clear action log + +```js +// evaluate_script +() => { globalThis.__DC_ACTION_LOG__ = []; return { cleared: true }; } +``` + +## Step 5: Mutate State via Controller + +Use Controller methods — **never** dispatch raw actions. + +### Invalidate an endpoint (force refetch) + +```js +// evaluate_script — triggers refetch for subscribed components +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + await ctrl.invalidate(mod.TodoResource.get, { id: '5' }); + return { invalidated: true }; +} +``` + +### Invalidate endpoints matching a pattern + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + ctrl.invalidateAll({ testKey: key => key.includes('/todos') }); + return 'todo endpoints invalidated'; +} +``` + +### Expire endpoints (mark stale, refetch on next use) + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + ctrl.expireAll({ testKey: key => key.includes('/todos') }); + return 'todo endpoints expired'; +} +``` + +### Set a value directly + +```js +// evaluate_script — use setResponse to inject mock data +async () => { + const mod = await import('/src/resources/Todo.ts'); + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + await ctrl.setResponse( + mod.TodoResource.getList, + {}, + [{ id: 1, title: 'Mock Todo', completed: false }], + ); + return { set: true }; +} +``` + +### Reset entire store + +```js +// evaluate_script +() => { + const ctrl = globalThis.__DC_CONTROLLERS__?.values().next().value; + ctrl.resetEntireStore(); + return { reset: true }; +} +``` + +## Correlating with Network Requests + +Use `list_network_requests` with `resourceTypes: ["fetch", "xhr"]` to see API calls, +then cross-reference with endpoint keys in state. + +## Action Types Reference + +| Type | Controller Method | Description | +|---|---|---| +| `rdc/fetch` | `fetch()` | Network request initiated | +| `rdc/setresponse` | `setResponse()` | Response written to cache | +| `rdc/set` | `set()` | Direct entity value set | +| `rdc/optimistic` | (automatic) | Optimistic update applied | +| `rdc/invalidate` | `invalidate()` | Single endpoint invalidated | +| `rdc/invalidateall` | `invalidateAll()` | Bulk invalidation by key test | +| `rdc/expireall` | `expireAll()` | Bulk mark-stale by key test | +| `rdc/reset` | `resetEntireStore()` | Full store reset | +| `rdc/subscribe` | `subscribe()` | Subscription registered | +| `rdc/unsubscribe` | `unsubscribe()` | Subscription removed | +| `rdc/gc` | (automatic) | Garbage collection | + +## Controller State Readers + +All take `state` (from `ctrl.getState()`) as the **last** argument. + +| Method | Signature | Returns | +|---|---|---| +| `getResponse` | `(endpoint, ...args, state)` | `{ data, expiryStatus, expiresAt }` — denormalized through schema | +| `getError` | `(endpoint, ...args, state)` | `ErrorTypes \| undefined` | +| `get` | `(schema, ...args, state)` | `Denormalized \| undefined` for any Queryable schema | +| `getQueryMeta` | `(schema, ...args, state)` | `{ data, countRef }` | + +`expiryStatus` values: `1` = Invalid, `2` = InvalidIfStale, `3` = Valid. + +## State Shape Reference + +```ts +State = { + entities: { [entityKey: string]: { [pk: string]: EntityInstance } }, + endpoints: { [cacheKey: string]: PK | PK[] | unknown }, + indexes: { [entityKey: string]: { [indexName: string]: { [lookupValue: string]: PK } } }, + meta: { + [key: string]: { + date, fetchedAt, expiresAt, + prevExpiresAt?, error?, invalidated?, errorPolicy?: 'hard' | 'soft' + } + }, + entitiesMeta: { [entityKey: string]: { [pk: string]: { date, expiresAt, fetchedAt } } }, + optimistic: (SetResponseAction | OptimisticAction)[], + lastReset: number, +} +``` + +## Polling Pattern + +For monitoring ongoing activity, poll with short intervals: + +1. Install shim (Step 2) +2. Trigger the user action or navigation +3. Wait 2–3 seconds +4. Read actions (Step 4) — check for `rdc/fetch` then `rdc/setresponse` pairs +5. If needed, read entity state (Step 3) to verify cache contents +6. Repeat if watching for subscription updates + +## Debugging Checklist + +1. **Verify controller exists**: Check `__DC_CONTROLLERS__` map size +2. **Inspect state shape**: Get entity types and endpoint count +3. **Check specific data**: Look up entities by type and pk +4. **Review endpoint metadata**: Check expiry, errors, invalidation status +5. **Track actions**: Read the action log for recent dispatches +6. **Correlate network**: Compare `list_network_requests` with endpoint keys +7. **Force refresh**: Use `invalidateAll` or `expireAll` to trigger refetches diff --git a/.agents/skills/data-client-vue/references/getDefaultManagers.md b/.agents/skills/data-client-vue/references/getDefaultManagers.md new file mode 100644 index 000000000000..79cc0d6e9040 --- /dev/null +++ b/.agents/skills/data-client-vue/references/getDefaultManagers.md @@ -0,0 +1,112 @@ + + +# getDefaultManagers() + +`getDefaultManagers` returns an Array of [Managers](https://dataclient.io/vue/api/Manager) to be sent to [DataClientPlugin](./installation.md#add-provider-at-top-level-component). + +This makes it simple to configure and add custom [Managers](https://dataclient.io/vue/api/Manager), while remaining robust against +any potential changes to the default managers. + +Currently returns \[[DevToolsManager](https://dataclient.io/vue/api/DevToolsManager)\*, [NetworkManager](https://dataclient.io/vue/api/NetworkManager), [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager)]. + +\*(`DevToolsManager` is excluded in production builds.) + +## Usage + +```ts title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin, getDefaultManagers } from '@data-client/vue'; +import App from './App.vue'; + +const managers = getDefaultManagers({ + // set fallback expiry time to an hour + networkManager: { dataExpiryLength: 1000 * 60 * 60 }, +}); + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +When `managers` is omitted, `DataClientPlugin` uses `getDefaultManagers()` with no arguments. +See [installation](./installation.md#add-provider-at-top-level-component) for the +other `DataClientPlugin` options. + +## Arguments + +Each argument represents a configuration of the manager. It can be of three possible types: + +- Any plain object is used as options to be sent to the manager's constructor. +- An instance of the manager to be used directly. +- `null`. When sent will exclude the manager. + +```ts +getDefaultManagers({ + devToolsManager: { trace: true }, + networkManager: new NetworkManager({ errorExpiryLength: 1 }), + subscriptionManager: null, +}); +``` + +### networkManager + +> **Note** +> +> `null` is not allowed here since NetworkManager is required + +`dataExpiryLength` is used as a fallback when an Endpoint does not have [dataExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointdataexpirylength) defined. + +`errorExpiryLength` is used as a fallback when an Endpoint does not have [errorExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointerrorexpirylength) defined. + +### devToolsManager + +[Arguments](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md) +to send to redux devtools. + +### subscriptionManager + +A class that implements `SubscriptionConstructable` like [PollingSubscription](https://dataclient.io/vue/api/PollingSubscription) + +## Examples + +### Tracing actions + +For example, we can enable the [trace](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#trace) option to help track down where actions are dispatched from. This has a large performance impact, so it is normally disabled. + +```ts +const managers = getDefaultManagers({ + devToolsManager: { trace: true }, +}); +``` + +### Manager inheritance + +Sending manager instances allows us to customize managers using inheritance. + +```ts +import { NetworkManager, type FetchAction } from '@data-client/vue'; + +class LoggingNetworkManager extends NetworkManager { + protected handleFetch(action: FetchAction) { + console.log('fetching', action.key); + return super.handleFetch(action); + } +} + +const managers = getDefaultManagers({ + networkManager: new LoggingNetworkManager(), +}); +``` + +### Disabling + +Using `null` will remove managers completely. [NetworkManager](https://dataclient.io/vue/api/NetworkManager) cannot be removed this way. + +```ts +const managers = getDefaultManagers({ + devToolsManager: null, + subscriptionManager: null, +}); +``` + +Here we disable every manager except [NetworkManager](https://dataclient.io/vue/api/NetworkManager). diff --git a/.agents/skills/data-client-vue/references/installation.md b/.agents/skills/data-client-vue/references/installation.md new file mode 100644 index 000000000000..bc39ca65a263 --- /dev/null +++ b/.agents/skills/data-client-vue/references/installation.md @@ -0,0 +1,79 @@ + + +# Getting Started with Reactive Data Client + +> **Tip: Use Agent Skills** +> +> Prefer to scaffold via your AI agent? See [Agent Skills](https://dataclient.io/vue/getting-started/agent-skills) and run `/data-client-setup`. + +## Install the plugin {#add-provider-at-top-level-component} + +Install the [Vue plugin](https://vuejs.org/guide/reusability/plugins.html) when creating your app. + +```bash +npm install @data-client/vue @data-client/rest +``` + +```tsx title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin } from '@data-client/vue'; + +const app = createApp(App); + +app.use(DataClientPlugin, { + // optional overrides + // managers: getDefaultManagers(), + // initialState, + // Controller, + // gcPolicy, +}); + +app.mount('#app'); +``` + +[Next: Define Data »](https://dataclient.io/vue/getting-started/resource) + +## Example + +Example app: [vue-todo-app](https://github.com/reactive/data-client/tree/master/examples/vue-todo-app) ([`src/main.ts`](https://github.com/reactive/data-client/blob/master/examples/vue-todo-app/src/main.ts), [`src/pages/UserTodos.vue`](https://github.com/reactive/data-client/blob/master/examples/vue-todo-app/src/pages/UserTodos.vue)) + +## Supported Tools + +

+ +TypeScript 4.0+ + +TypeScript is optional, but requires at least version [4.0](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#variadic-tuple-types) and [strictNullChecks](https://www.typescriptlang.org/tsconfig#strictNullChecks) for full type enforcement. + +
+ +
+ +Older browser support + +If your application targets older browsers (a few years or more), be sure to load polyfills. +Typically this is done with [@babel/preset-env useBuiltIns: 'entry'](https://babeljs.io/docs/en/babel-preset-env#usebuiltins), +coupled with importing [core-js](https://www.npmjs.com/package/core-js) at the entrypoint of your application. + +This ensures only the needed polyfills for your browser support targets are included in your application bundle. + +For instance `TypeError: Object.hasOwn is not a function` + +
+ +
+ +Internet Explorer support + +If you see `Uncaught TypeError: Class constructor Resource cannot be invoked without 'new'`, +follow the instructions to [add legacy browser support to packages](https://dataclient.io/vue/guides/legacy-browser) + +
+ +
+ +Vue 3 + +`@data-client/vue` supports Vue 3 and is built on the [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html). + +
diff --git a/.agents/skills/data-client-vue/references/mutations.md b/.agents/skills/data-client-vue/references/mutations.md new file mode 100644 index 000000000000..edc5f7b3b1b5 --- /dev/null +++ b/.agents/skills/data-client-vue/references/mutations.md @@ -0,0 +1,376 @@ + + +# Data mutations + +Using our [Create, Update, and Delete](https://dataclient.io/vue/concepts/atomic-mutations) endpoints with +[Controller.fetch()](./Controller.md#fetch) reactively updates _all_ appropriate components atomically (at the same time). + +[useController()](./useController.md) gives components access to this global supercharged [setState()](https://react.dev/reference/react/useState#setstate). + +[//]: # "TODO: Add create, and delete examples as well (in tabs)" + +```ts title="TodoResource" +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = 0; + userId = 0; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```html title="TodoItem.vue" {8-12,14-16} + + + +``` + +```html title="CreateTodo.vue" {10-13} + + + +``` + +```html title="TodoList.vue" + + + +``` + +Rather than triggering invalidation cascades or using manually written update functions, +Data Client reactively updates appropriate components using the fetch response. + +## Optimistic mutations based on previous state {#optimistic-updates} + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +[getOptimisticResponse](https://dataclient.io/rest/guides/optimistic-updates) is just like [setState with an updater function](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state). [Snapshot](https://dataclient.io/vue/api/Snapshot) provides typesafe access to the previous store value, +which we use to return the _expected_ fetch response. + +Reactive Data Client ensures [data integrity against any possible networking failure or race condition](https://dataclient.io/rest/guides/optimistic-updates#optimistic-transforms), so don't +worry about network failures, multiple mutation calls editing the same data, or other common +problems in asynchronous programming. + +## Tracking mutation loading + +[useLoading()](./useLoading.md) enhances async functions by tracking their loading and error states. + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```html title="PostDetail.vue" + + + +``` + +```html title="PostForm.vue" + + + +``` + +```html title="PostCreate.vue" {9-14} + + + +``` + +```html title="Navigation.vue" + + + +``` diff --git a/.agents/skills/data-client-vue/references/useCache.md b/.agents/skills/data-client-vue/references/useCache.md new file mode 100644 index 000000000000..28a29d8000a4 --- /dev/null +++ b/.agents/skills/data-client-vue/references/useCache.md @@ -0,0 +1,130 @@ + + +# useCache() + +Data rendering without the fetch. + +Access any [Endpoint](https://dataclient.io/rest/api/Endpoint)'s response. If the response does not exist, returns +`undefined`. This can be used to check for an `Endpoint's` existance like for authentication. + +`useCache()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. + +## Usage + +```ts title="UserResource" +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; + isAdmin = false; + + static key = 'User'; +} +export const UserResource = resource({ + path: '/users/:id', + schema: User, +}).extend('current', { + path: '/user', + schema: User, +}); +``` + +```html title="Unauthed.vue" + + + +``` + +```html title="Authorized.vue" + + + +``` + +```html title="AuthorizedPage.vue" + + + +``` + +See [truthiness narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#truthiness-narrowing) for +more information about type handling + +## Behavior + +`useCache()` returns a [ComputedRef](https://vuejs.org/api/reactivity-core.html#computed). The table +below describes its `.value`. + +| Expiry Status | Returns | Conditions | +| ------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Invalid | `undefined` | not in store, [deletion](https://dataclient.io/rest/api/resource#delete), [invalidation](./Controller.md#invalidate), [invalidIfStale](https://dataclient.io/vue/concepts/expiry-policy#endpointinvalidifstale) | +| Stale | denormalized | (first-render, arg change) & [expiry < now](https://dataclient.io/vue/concepts/expiry-policy) | +| Valid | denormalized | fetch completion | +| | `undefined` | `null` used as second argument | + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useCache( +> TodoResource.get, +> computed(() => (id.value ? { id: id.value } : null)), +> ); +> ``` + +## Types + +```typescript +function useCache( + endpoint: ReadEndpoint, + ...args: MaybeRefsOrGetters> | [null] +): ComputedRef>; +``` + +Arguments can be plain values, [refs](https://vuejs.org/api/reactivity-core.html#ref) (including [computed](https://vuejs.org/api/reactivity-core.html#computed)), or getter +functions like `() => ({ id: props.id })`. A plain object like `{ id: props.id }` is read once and won't +follow prop or route changes, so use a getter or `computed` when an argument can change. + +The result updates when the arguments change. diff --git a/.agents/skills/data-client-vue/references/useController.md b/.agents/skills/data-client-vue/references/useController.md new file mode 100644 index 000000000000..6ad0b73f00d9 --- /dev/null +++ b/.agents/skills/data-client-vue/references/useController.md @@ -0,0 +1,170 @@ + + +# useController() + +[Controller](./Controller.md) provides type-safe methods to access and dispatch actions to the store. + +For instance [fetch](./Controller.md#fetch), [invalidate](./Controller.md#invalidate), +and [setResponse](./Controller.md#setResponse) + +```html + +``` + +`useController()` must be called inside ` + + +``` + +### Direct entity update + +Use [set](./Controller.md#set) for immediate updates without network requests. Supports functional updates to avoid race conditions. + +```html title="VoteButton.vue" + + + +``` + +### Invalidate after mutation + +Force refetch of related data using [invalidate](./Controller.md#invalidate) or [expireAll](./Controller.md#expireAll). + +```html title="ClearUserCache.vue" + + + +``` + +> **Tip** +> +> For better performance and consistency, prefer [including side effect updates in mutation responses](https://dataclient.io/rest/guides/side-effects). + +### Prefetching + +Use [fetchIfStale](./Controller.md#fetchIfStale) to prefetch without overfetching fresh data. + +```html title="ArticleLink.vue" + + + +``` + +### Websocket updates + +Populate cache with external data via [set](./Controller.md#set). + +```ts title="useWebsocket.ts" +import { onMounted, onUnmounted } from 'vue'; +import { useController } from '@data-client/vue'; + +export function useWebsocket(url: string) { + const ctrl = useController(); + let ws: WebSocket; + + onMounted(() => { + ws = new WebSocket(url); + ws.onmessage = event => { + const { entity, args, data } = JSON.parse(event.data); + ctrl.set(EntityMap[entity], args, data); + }; + }); + onUnmounted(() => ws?.close()); +} +``` + +> **Warning** +> +> For production use, implement a [Manager for data streams](https://dataclient.io/vue/concepts/managers#data-stream) rather than component-level lifecycle hooks. Managers handle connection lifecycle globally and work with SSR. + +### Todo App + +Example app: [vue-todo-app](https://github.com/reactive/data-client/tree/master/examples/vue-todo-app) ([`src/resources/TodoResource.ts`](https://github.com/reactive/data-client/blob/master/examples/vue-todo-app/src/resources/TodoResource.ts), [`src/components/TodoItem.vue`](https://github.com/reactive/data-client/blob/master/examples/vue-todo-app/src/components/TodoItem.vue)) diff --git a/.agents/skills/data-client-vue/references/useDLE.md b/.agents/skills/data-client-vue/references/useDLE.md new file mode 100644 index 000000000000..ad20c87ccdf8 --- /dev/null +++ b/.agents/skills/data-client-vue/references/useDLE.md @@ -0,0 +1,270 @@ + + +# useDLE() - \[D]ata \[L]oading \[E]rror + +High performance async data rendering without overfetching. With fetch meta data. + +In case you cannot use [suspense](./data-dependency.md#async-fallbacks), useDLE() is just like [useSuspense()](./useSuspense.md) but returns \[D]ata \[L]oading \[E]rror values. + +`useDLE()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. + +## Usage + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```html title="ProfileList.vue" + + + +``` + +## Behavior + +`data`, `loading` and `error` are each a [ComputedRef](https://vuejs.org/api/reactivity-core.html#computed). +Destructure them at the top level of ` + + +``` + +### Conditional + +`null` will avoid binding and fetching data + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + userId = 0; + title = ''; + body = ''; + + static key = 'Post'; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); +``` + +```html title="PostWithAuthor.vue" {15-21} + + + +``` + +### Embedded data + +When entities are stored in [nested structures](https://dataclient.io/rest/guides/relational-data#nesting), that structure will remain. + +```typescript title="api/Post" +export class PaginatedPost extends Entity { + id = ''; + title = ''; + content = ''; + + static key = 'PaginatedPost'; +} + +export const getPosts = new RestEndpoint({ + path: '/post', + searchParams: { page: '' }, + schema: { + results: new Collection([PaginatedPost]), + nextPage: '', + lastPage: '', + }, +}); +``` + +```html title="ArticleList.vue" {14} + + + +``` diff --git a/.agents/skills/data-client-vue/references/useDebounce.md b/.agents/skills/data-client-vue/references/useDebounce.md new file mode 100644 index 000000000000..bcfb7660941c --- /dev/null +++ b/.agents/skills/data-client-vue/references/useDebounce.md @@ -0,0 +1,125 @@ + + +# useDebounce() + +Delays updating the parameters by [debouncing](https://css-tricks.com/debouncing-throttling-explained-examples/). + +Useful to avoid spamming network requests when parameters might change quickly (like a typeahead field). + +> **Tip** +> +> `useDebounce()` returns [refs](https://vuejs.org/api/reactivity-core.html#ref), so the debounced +> value can be passed directly to other composables or components. `isPending` is true from the moment +> the input changes until the debounced value is updated. + +## Usage + +```ts title="IssueQuery" +import { RestEndpoint, Entity, Collection } from '@data-client/rest'; + +export class Issue extends Entity { + number = 0; + repository_url = ''; + labels_url = ''; + html_url = ''; + body = ''; + title = ''; + state: 'open' | 'closed' = 'open'; + locked = false; + comments = 0; + created_at = Temporal.Instant.fromEpochMilliseconds(0); + updated_at = Temporal.Instant.fromEpochMilliseconds(0); + closed_at: Temporal.Instant | null = null; + authorAssociation = 'NONE'; + pullRequest: Record | null = null; + declare draft?: boolean; + + static schema = { + created_at: Temporal.Instant.from, + updated_at: Temporal.Instant.from, + closed_at: Temporal.Instant.from, + }; + + pk() { + return [this.repository_url, this.number].join(','); + } +} + +export const issueQuery = new RestEndpoint({ + urlPrefix: 'https://api.github.com', + path: '/search/issues', + searchParams: {} as { q: string }, + paginationField: 'page', + schema: { + incomplete_results: false, + items: new Collection([Issue]), + total_count: 0, + }, +}); +``` + +```html title="IssueList.vue" + + + +``` + +```html title="SearchIssues.vue" + + + +``` + +## Types + +```typescript +function useDebounce( + value: T | Ref, + delay: number, + updatable?: boolean | Ref, +): [Ref, Ref]; +``` + +`value` and `updatable` can be plain values or [refs](https://vuejs.org/api/reactivity-core.html#ref). +Returns a tuple of `[debouncedValue, isPending]` refs. When `updatable` is `false`, the debounced +value stops updating and `isPending` resets to `false`. diff --git a/.agents/skills/data-client-vue/references/useFetch.md b/.agents/skills/data-client-vue/references/useFetch.md new file mode 100644 index 000000000000..b19d1ba8e090 --- /dev/null +++ b/.agents/skills/data-client-vue/references/useFetch.md @@ -0,0 +1,165 @@ + + +# useFetch() + +Fetch an Endpoint if it is not in cache or stale. Returns a [Ref](https://vuejs.org/api/reactivity-core.html#ref) +holding the fetch promise (with a `resolved` flag). A new fetch is triggered when the arguments change or +the data is [invalidated](./Controller.md#invalidate). Use it to start fetches early, then read the data +with [useSuspense()](./useSuspense.md), [useCache()](./useCache.md) or [useDLE()](./useDLE.md). + +## Usage + +### Parallel data loading + +`await useSuspense()` runs sequentially in ` + + +``` + +### Prefetching + +`useFetch()` can also be used standalone to ensure resources are available early in a render tree before they are needed. + +> **Tip** +> +> Use in combination with a data-binding hook ([useCache()](./useCache.md), [useSuspense()](./useSuspense.md), [useDLE()](./useDLE.md), [useLive()](./useLive.md)) +> in another component. + +```html title="MasterPost.vue" + +``` + +## Behavior + +| Expiry Status | Fetch | `.value` | `resolved` | Conditions | +| ------------- | --------------- | ---------------- | ---------- | -------------------------------------------------------------------------------------------------------------------- | +| Invalid | yes1 | pending promise | `false` | not in store, [deletion](https://dataclient.io/rest/api/resource#delete), [invalidation](./Controller.md#invalidate) | +| Stale | yes1 | pending promise | `false` | (first-render, arg change) & [expiry < now](https://dataclient.io/vue/concepts/expiry-policy) | +| Valid | no | resolved promise | `true` | fetch completion | +| Error | no | rejected promise | `true` | fetch failed | +| | no | `undefined` | | `null` used as second argument | + +The returned `Ref` is updated with a new promise whenever a fetch is triggered: on argument change, +[invalidation](./Controller.md#invalidate), or [reset](./Controller.md#resetEntireStore). + +> **Note** +> +> 1. Identical fetches are automatically deduplicated + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useFetch( +> TodoResource.get, +> computed(() => (id.value ? { id: id.value } : null)), +> ); +> ``` + +## Types + +```typescript +function useFetch( + endpoint: ReadEndpoint, + ...args: MaybeRefsOrGetters> | [null] +): Readonly< + Ref< + | (Promise> & { + resolved: boolean; + }) + | undefined + > +>; +``` + +Arguments can be plain values, [refs](https://vuejs.org/api/reactivity-core.html#ref) (including [computed](https://vuejs.org/api/reactivity-core.html#computed)), or getter +functions like `() => ({ id: props.id })`. A plain object like `{ id: props.id }` is read once and won't +follow prop or route changes, so use a getter or `computed` when an argument can change. + +A new fetch is triggered when the arguments change. + +## Examples + +### Checking fetch status + +Use `promise.resolved` to check whether data is still loading: + +```html title="MasterPost.vue" + +``` diff --git a/.agents/skills/data-client-vue/references/useLive.md b/.agents/skills/data-client-vue/references/useLive.md new file mode 100644 index 000000000000..1ea1a4682de2 --- /dev/null +++ b/.agents/skills/data-client-vue/references/useLive.md @@ -0,0 +1,113 @@ + + +# useLive() + +Async rendering of remotely triggered data mutations. + +[useSuspense()](./useSuspense.md) + [useSubscription()](./useSubscription.md) in one composable. + +`useLive()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. + +## Usage + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```html title="AssetPrice.vue" + + + +``` + +Like [useSuspense()](./useSuspense.md), `useLive()` returns a Promise, so it is used with `await` in +` + + +``` + +```html title="PostForm.vue" + + + +``` + +```html title="PostCreate.vue" + + + +``` + +```html title="Navigation.vue" + + + +``` + +Returns the wrapped function along with `loading` and `error` [refs](https://vuejs.org/api/reactivity-core.html#ref). +The wrapped function is stable, so no dependency list is needed: any refs or props it reads are +read at call time. + +## Types + +```typescript +export default function useLoading< + F extends (...args: any) => Promise, +>(func: F): [F, Ref, Ref]; +``` + +`loading` is `true` while the returned promise is pending. If `func` rejects, the rejection is +caught and stored in `error`; it is cleared again on the next call. + +## Examples + +### Todo creation + +Example app: [vue-todo-app](https://github.com/reactive/data-client/tree/master/examples/vue-todo-app) ([`src/components/TodoList.vue`](https://github.com/reactive/data-client/blob/master/examples/vue-todo-app/src/components/TodoList.vue), [`src/resources/TodoResource.ts`](https://github.com/reactive/data-client/blob/master/examples/vue-todo-app/src/resources/TodoResource.ts)) diff --git a/.agents/skills/data-client-vue/references/useQuery.md b/.agents/skills/data-client-vue/references/useQuery.md new file mode 100644 index 000000000000..f75097c8cd6e --- /dev/null +++ b/.agents/skills/data-client-vue/references/useQuery.md @@ -0,0 +1,303 @@ + + +# useQuery() + +Data rendering without the fetch. + +Access any [Queryable Schema](https://dataclient.io/rest/api/schema#queryable)'s store value; like [Entity](https://dataclient.io/rest/api/Entity), [All](https://dataclient.io/rest/api/All), [Collection](https://dataclient.io/rest/api/Collection), [Query](https://dataclient.io/rest/api/Query), +[Union](https://dataclient.io/rest/api/Union), and [Scalar](https://dataclient.io/rest/api/Scalar). [Lazy](https://dataclient.io/rest/api/Lazy) fields also work via their [`.query`](https://dataclient.io/rest/api/Lazy#query) accessor. +If the value does not exist, returns `undefined`. + +`useQuery()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. Returns `undefined` +when data is [Invalid](https://dataclient.io/vue/concepts/expiry-policy#invalid). + +> **Tip** +> +> [Queries](https://dataclient.io/rest/api/Query) are a great companion to efficiently render aggregate computations like those that use [groupBy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/groupBy#browser_compatibility), +> [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map), [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce), and [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter). + +## Usage + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +See [truthiness narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#truthiness-narrowing) for +more information about type handling + +## Types + +```typescript +function useQuery( + schema: S, + ...args: MaybeRefsOrGetters> +): ComputedRef | undefined>; +``` + +Arguments can be plain values, [refs](https://vuejs.org/api/reactivity-core.html#ref) (including [computed](https://vuejs.org/api/reactivity-core.html#computed)), or getter +functions like `() => ({ id: props.id })`. A plain object like `{ id: props.id }` is read once and won't +follow prop or route changes, so use a getter or `computed` when an argument can change. + +The result updates when the arguments change. + +### Queryable + +[Queryable](https://dataclient.io/rest/api/schema#queryable) schemas require an `queryKey()` method that returns something. These include +[Entity](https://dataclient.io/rest/api/Entity), [All](https://dataclient.io/rest/api/All), [Collection](https://dataclient.io/rest/api/Collection), [Query](https://dataclient.io/rest/api/Query), +[Union](https://dataclient.io/rest/api/Union), and [Scalar](https://dataclient.io/rest/api/Scalar). [Lazy](https://dataclient.io/rest/api/Lazy) fields produce a Queryable via their [`.query`](https://dataclient.io/rest/api/Lazy#query) accessor. + +```ts +interface Queryable { + queryKey( + args: readonly any[], + queryKey: (...args: any) => any, + getEntity: GetEntity, + getIndex: GetIndex, + // Must be non-void + ): {}; +} +``` + +## Examples + +### Sorting & Filtering + +[Query](https://dataclient.io/rest/api/Query) provides programmatic access to the Reactive Data Client store. + +```ts title="UserResource" +export class User extends Entity { + id = ''; + name = ''; + isAdmin = false; + + static key = 'User'; +} +export const UserResource = resource({ + path: '/users/:id', + schema: User, +}); +``` + +```html title="UsersPage.vue" {22} + + + +``` + +### Lazy relationships + +[Lazy](https://dataclient.io/rest/api/Lazy) fields keep raw IDs during parent denormalization. Use [`.query`](https://dataclient.io/rest/api/Lazy#query) with `useQuery` to resolve them on demand, +isolating re-renders to only the components that need the related data. + +```ts title="Resources" +export class Building extends Entity { + id = ''; + name = ''; + + static key = 'Building'; +} + +export class Department extends Entity { + id = ''; + name = ''; + buildings: string[] = []; + + static schema = { + buildings: new Lazy([Building]), + }; + static key = 'Department'; +} + +export const DepartmentResource = resource({ + path: '/departments/:id', + schema: Department, +}); +``` + +```html title="BuildingList.vue" {8-11} + + + +``` + +```html title="DepartmentsPage.vue" + + + +``` diff --git a/.agents/skills/data-client-vue/references/useSubscription.md b/.agents/skills/data-client-vue/references/useSubscription.md new file mode 100644 index 000000000000..9ddb3aad9f0f --- /dev/null +++ b/.agents/skills/data-client-vue/references/useSubscription.md @@ -0,0 +1,111 @@ + + +# useSubscription() + +Great for keeping resources up-to-date with frequent changes. + +When using the default [polling subscriptions](https://dataclient.io/vue/api/PollingSubscription), frequency must be set in +[Endpoint](https://dataclient.io/rest/api/Endpoint), otherwise will have no effect. + +> **Tip** +> +> [useLive()](./useLive.md) is a terser way to use in combination with [useSuspense()](./useSuspense.md), + +## Usage + +```typescript title="api/Price" +import { RestEndpoint, Entity } from '@data-client/rest'; + +export class Price extends Entity { + symbol = ''; + price = '0.0'; + // ... + + pk() { + return this.symbol; + } +} + +export const getPrice = new RestEndpoint({ + urlPrefix: 'http://test.com', + path: '/price/:symbol', + schema: Price, + pollFrequency: 5000, +}); +``` + +```html title="MasterPrice.vue" + +``` + +## Behavior + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useSubscription( +> TodoResource.get, +> computed(() => (id.value ? { id: id.value } : null)), +> ); +> ``` + +The subscription is created when the component is set up and removed when it unmounts. When +an argument passed as a [ref](https://vuejs.org/api/reactivity-core.html#ref) changes, the previous +subscription is removed and a new one is created for the new arguments. + +## Types + +```typescript +function useSubscription( + endpoint: ReadEndpoint, + ...args: MaybeRefsOrGetters> | [null] +): void; +``` + +Arguments can be plain values, [refs](https://vuejs.org/api/reactivity-core.html#ref) (including [computed](https://vuejs.org/api/reactivity-core.html#computed)), or getter +functions like `() => ({ id: props.id })`. A plain object like `{ id: props.id }` is read once and won't +follow prop or route changes, so use a getter or `computed` when an argument can change. + +## Examples + +### Only subscribe while element is visible + +```html title="MasterPrice.vue" + + + +``` + +When `null` is sent as the second argument, the subscription is deactivated. Of course, +if other components are still subscribed the data updates will still be active. + +[useElementVisibility()](https://vueuse.org/core/useElementVisibility/) from VueUse uses [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API), which is very performant. [Template refs](https://vuejs.org/guide/essentials/template-refs.html) allow +us to access the [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model). diff --git a/.agents/skills/data-client-vue/references/useSuspense.md b/.agents/skills/data-client-vue/references/useSuspense.md new file mode 100644 index 000000000000..bdbf0e3e4995 --- /dev/null +++ b/.agents/skills/data-client-vue/references/useSuspense.md @@ -0,0 +1,418 @@ + + +# useSuspense() + +High performance async data rendering without overfetching. + +[await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await) `useSuspense()` in Vue components. This means the remainder of the component only runs after the data has loaded, avoiding the complexity of handling loading and error conditions. Instead, fallback handling is +[centralized](./data-dependency.md#boundaries) with Vue's built-in [Suspense](https://vuejs.org/guide/built-ins/suspense.html). + +`useSuspense()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. + +## Usage + +**Rest** + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```html title="ProfileDetail.vue" + + + +``` + +**Promise** + +```typescript title="Profile" +import { Endpoint } from '@data-client/endpoint'; + +export const getProfile = new Endpoint( + (id: number) => + Promise.resolve({ + id, + fullName: 'Jing Chen', + bio: 'Creator of Flux Architecture', + avatar: 'https://avatars.githubusercontent.com/u/5050204?v=4', + }), + { + key(id) { + return `getProfile${id}`; + }, + }, +); +``` + +```html title="ProfileDetail.vue" + + + +``` + +## Behavior + +Cache policy is [Stale-While-Revalidate](https://tools.ietf.org/html/rfc5861) by default but also [configurable](https://dataclient.io/vue/concepts/expiry-policy). + +| Expiry Status | Fetch | Suspend | Error | Conditions | +| ------------- | --------------- | ------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Invalid | yes1 | yes | no | not in store, [deletion](https://dataclient.io/rest/api/resource#delete), [invalidation](./Controller.md#invalidate), [invalidIfStale](https://dataclient.io/vue/concepts/expiry-policy#endpointinvalidifstale) | +| Stale | yes1 | no | no | (first-render, arg change) & [expiry < now](https://dataclient.io/vue/concepts/expiry-policy) | +| Valid | no | no | maybe2 | fetch completion | +| | no | no | no | `null` used as second argument | + +> **Note** +> +> 1. Identical fetches are automatically deduplicated +> 2. [Hard errors](https://dataclient.io/vue/concepts/error-policy#hard) to be [caught](./data-dependency.md#async-fallbacks) by [onErrorCaptured()](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured) + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = await useSuspense( +> TodoResource.get, +> computed(() => (id.value ? { id: id.value } : null)), +> ); +> ``` + +## Types + +```typescript +function useSuspense( + endpoint: ReadEndpoint, + ...args: MaybeRefsOrGetters> | [null] +): Promise>>>; +``` + +Arguments can be plain values, [refs](https://vuejs.org/api/reactivity-core.html#ref) (including [computed](https://vuejs.org/api/reactivity-core.html#computed)), or getter +functions like `() => ({ id: props.id })`. A plain object like `{ id: props.id }` is read once and won't +follow prop or route changes, so use a getter or `computed` when an argument can change. + +The result updates when the arguments change. +While data for new arguments loads, the result keeps the previous data instead of becoming `undefined`. +If that fetch fails, reading the result throws the error (per its [error policy](https://dataclient.io/vue/concepts/error-policy)), so it reaches +[onErrorCaptured()](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). + +## Examples + +### List + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```html title="ProfileList.vue" + + + +``` + +### Pagination + +Reactive [pagination](https://dataclient.io/rest/guides/pagination) is achieved with [mutable schemas](https://dataclient.io/rest/api/Collection) + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} +import { Entity, resource, Collection } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +```html title="PostItem.vue" + + + +``` + +```html title="LoadMore.vue" + + + +``` + +```html title="PostList.vue" + + + +``` + +### Sequential + +When fetch parameters depend on data from another resource. + +```html + +``` + +### Conditional + +`null` will avoid binding and fetching data + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + userId = 0; + title = ''; + body = ''; + + static key = 'Post'; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); +``` + +```html title="PostWithAuthor.vue" {10-16} + + + +``` + +### Embedded data + +When entities are stored in [nested structures](https://dataclient.io/rest/guides/relational-data#nesting), that structure will remain. + +```typescript title="api/Post" {12-16} +export class PaginatedPost extends Entity { + id = ''; + title = ''; + content = ''; + + static key = 'PaginatedPost'; +} + +export const getPosts = new RestEndpoint({ + path: '/post', + searchParams: { page: '' }, + schema: { + posts: new Collection([PaginatedPost]), + nextPage: '', + lastPage: '', + }, +}); +``` + +```html title="ArticleList.vue" + + + +``` diff --git a/docs/core/getting-started/agent-skills.md b/docs/core/getting-started/agent-skills.md index 9898241ef5b1..2b720b87f76e 100644 --- a/docs/core/getting-started/agent-skills.md +++ b/docs/core/getting-started/agent-skills.md @@ -11,7 +11,17 @@ The quickest way to get started is to let an [AI Agent](https://agentskills.io) ## Install - +:::react + + + +::: + +:::vue + + + +::: Then run skill `/data-client-setup` to install and wire up the provider for your project. It will automatically detect your framework (NextJS, Expo, React Native, Vue, @@ -31,13 +41,25 @@ endpoints are found. and related schemas. - [**`/data-client-rest`**](https://skills.sh/reactive/data-client/data-client-rest) — defines REST APIs with `resource()`, `RestEndpoint`, CRUD methods, and response parsing. +- [**`/data-client-manager`**](https://skills.sh/reactive/data-client/data-client-manager) — implements custom `Manager`s for websockets, SSE, + polling, subscriptions, logging, and middleware. + +:::react + - [**`/data-client-react`**](https://skills.sh/reactive/data-client/data-client-react) — uses `useSuspense`, `useFetch`, `useQuery`, `useLive`, and mutation hooks. - [**`/data-client-react-testing`**](https://skills.sh/reactive/data-client/data-client-react-testing) — writes React tests with `renderDataHook`, fixtures, interceptors, and `nock`. + +::: + +:::vue + +- [**`/data-client-vue`**](https://skills.sh/reactive/data-client/data-client-vue) — uses `useSuspense`, `useFetch`, `useQuery`, `useLive`, + and mutation composables with `DataClientPlugin`. - [**`/data-client-vue-testing`**](https://skills.sh/reactive/data-client/data-client-vue-testing) — writes Vue tests with `renderDataCompose`, `mountDataClient`, fixtures, and `nock`. -- [**`/data-client-manager`**](https://skills.sh/reactive/data-client/data-client-manager) — implements custom `Manager`s for websockets, SSE, - polling, subscriptions, logging, and middleware. + +::: Browse the full catalog at [skills.sh/reactive/data-client](https://skills.sh/reactive/data-client). diff --git a/docs/core/getting-started/debugging.md b/docs/core/getting-started/debugging.md index 0ac12e1fd082..2f2ab4996cbd 100644 --- a/docs/core/getting-started/debugging.md +++ b/docs/core/getting-started/debugging.md @@ -13,17 +13,9 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; For many debugging tasks, the fastest path is to use an agent that already knows the :react[`@data-client/react`]:vue[`@data-client/vue`] debugging workflow. -Install the [`data-client-react` skill](https://skills.sh/reactive/data-client/data-client-react) +Install the :react[[`data-client-react` skill](https://skills.sh/reactive/data-client/data-client-react)]:vue[[`data-client-vue` skill](https://skills.sh/reactive/data-client/data-client-vue)] in your coding agent, then ask it to inspect the current page or app state. -:::vue - -The skill is named for React, but its `devtools-debugging` workflow only uses the -[Controller](../api/Controller.md), so it works the same with `@data-client/vue`. Ask your agent -to follow that reference. - -::: - ### How agent debugging works In dev mode, [DevToolsManager](../api/DevToolsManager.md) exposes live `Controller` instances so an agent can inspect diff --git a/packages/vue/src/test/README.md b/packages/vue/src/test/README.md index ad7975965d1a..b9a9cd50af35 100644 --- a/packages/vue/src/test/README.md +++ b/packages/vue/src/test/README.md @@ -130,7 +130,8 @@ const ArticleComponent = defineComponent({ }, }, async setup(props) { - const article = await useSuspense(MyResource.get, { id: props.id }); + // a getter so the fetch follows props.id + const article = await useSuspense(MyResource.get, () => ({ id: props.id })); return () => h('div', article.value.title); }, }); @@ -269,7 +270,7 @@ interface Interceptor { 6. **Suspense is automatic** - no need to manually wrap components in Suspense 7. **Use reactive props** - Pass a `reactive` in the `props` option and set its members to change component props 8. **Vue Suspense behavior** - Vue's `useSuspense` returns a Promise that suspends when data is missing, then resolves to a ComputedRef -9. **Reactive props with async setup** - Components using async setup with `useSuspense` that depend on props should use `useFetch` + `watchEffect` for reactive behavior, or rely on non-async setup patterns. Async setup only runs once per component instance. +9. **Reactive props with async setup** - Async setup runs once per component instance, so pass prop-derived arguments as a getter (`() => ({ id: props.id })`); a plain object is read once and won't follow prop changes. ## Migration from Manual Setup diff --git a/website/src/components/SkillTabs.tsx b/website/src/components/SkillTabs.tsx index 8c875822737d..85b406df7070 100644 --- a/website/src/components/SkillTabs.tsx +++ b/website/src/components/SkillTabs.tsx @@ -5,17 +5,31 @@ import React from 'react'; interface Props { repo?: string; + /** Directory of the skills within repo; openskills installs one skill per path */ + skillsDir?: string; skill?: string; skills?: string[]; + /** Skills for the OpenSkills tab when it should differ from `skills` (it has no picker groups) */ + openSkills?: string[]; } export default function SkillTabs({ repo = 'reactive/data-client', + skillsDir = '.agents/skills', skill, skills, + openSkills, }: Props) { const allSkills = skills ?? (skill ? [skill] : []); const skillFlag = allSkills.map(s => ` --skill ${s}`).join(''); + // openskills has no --skill flag; it installs a single skill from its path + const openSkillList = openSkills ?? allSkills; + const openSkillsCommand = + openSkillList.length ? + openSkillList + .map(s => `npx openskills install ${repo}/${skillsDir}/${s}`) + .join('\n') + : `npx openskills install ${repo}`; return ( - - npx openskills install {repo} - {skillFlag} - + {openSkillsCommand} );