diff --git a/.agents/skills/data-client-manager/SKILL.md b/.agents/skills/data-client-manager/SKILL.md index 69d0dc36e490..c00fd0cf8a98 100644 --- a/.agents/skills/data-client-manager/SKILL.md +++ b/.agents/skills/data-client-manager/SKILL.md @@ -54,7 +54,7 @@ ctrl.fetch(), ctrl.fetchIfStale(), ctrl.expireAll(), ctrl.invalidate(), ctrl.inv ctrl.setError(), ctrl.resetEntireStore(), ctrl.subscribe(), ctrl.unsubscribe(). ```ts -import type { Manager, Middleware } from '@data-client/core'; +import type { Manager, Middleware } from '@data-client/react'; // or '@data-client/vue' import CurrentTime from './CurrentTime'; export default class TimeManager implements Manager { diff --git a/.agents/skills/data-client-manager/references/Controller.md b/.agents/skills/data-client-manager/references/Controller.md index b9d65318e068..ad76019cc0ad 100644 --- a/.agents/skills/data-client-manager/references/Controller.md +++ b/.agents/skills/data-client-manager/references/Controller.md @@ -653,8 +653,11 @@ function useCache( ``` ```tsx title="MyManager.ts" -import type { Manager, Middleware, actionTypes } from '@data-client/core'; -import type { EndpointInterface } from '@data-client/endpoint'; +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; export default class MyManager implements Manager { middleware: Middleware = controller => { diff --git a/.agents/skills/data-client-manager/references/Controller.vue.md b/.agents/skills/data-client-manager/references/Controller.vue.md index 7398c5dfb2af..cd8485af44f7 100644 --- a/.agents/skills/data-client-manager/references/Controller.vue.md +++ b/.agents/skills/data-client-manager/references/Controller.vue.md @@ -587,8 +587,11 @@ In event handlers, pass [getState()](#getState) to read the latest store, as in [getState() example](#getState). ```tsx title="MyManager.ts" -import type { Manager, Middleware, actionTypes } from '@data-client/core'; -import type { EndpointInterface } from '@data-client/endpoint'; +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/vue'; export default class MyManager implements Manager { middleware: Middleware = controller => { diff --git a/.agents/skills/data-client-manager/references/Manager.md b/.agents/skills/data-client-manager/references/Manager.md index 33147ea5ed06..1aab576a92ac 100644 --- a/.agents/skills/data-client-manager/references/Manager.md +++ b/.agents/skills/data-client-manager/references/Manager.md @@ -181,7 +181,7 @@ The job of `middleware` is to dispatch actions, respond to [actions](./Actions.m [Controller](./Controller.md) provides type-safe action dispatchers. ```ts title="CurrentTime" -import { Entity } from '@data-client/endpoint'; +import { Entity } from '@data-client/rest'; export default class CurrentTime extends Entity { id = 0; @@ -190,11 +190,11 @@ export default class CurrentTime extends Entity { ``` ```ts title="TimeManager" -import type { Manager, Middleware } from '@data-client/core'; +import type { Manager, Middleware } from '@data-client/react'; import CurrentTime from './CurrentTime'; export default class TimeManager implements Manager { - protected declare intervalID?: ReturnType; + declare protected intervalID?: ReturnType; middleware: Middleware = controller => { this.intervalID = setInterval(() => { @@ -253,20 +253,26 @@ encouraging safe access to its members. In case we want to 'handle' a certain [action](./Actions.md), we can 'consume' it by not calling next. ```ts title="isEntity" -import type { Schema, EntityInterface } from '@data-client/core'; +import type { Schema, EntityInterface } from '@data-client/react'; -export default function isEntity(schema: Schema): schema is EntityInterface { +export default function isEntity( + schema: Schema, +): schema is EntityInterface { return schema !== null && (schema as any).pk !== undefined; } ``` ```ts title="SubsManager" -import type { Manager, Middleware, EntityInterface } from '@data-client/react'; +import type { + Manager, + Middleware, + EntityInterface, +} from '@data-client/react'; import { actionTypes } from '@data-client/react'; import isEntity from './isEntity'; export default class CustomSubsManager implements Manager { - protected declare entities: Record; + declare protected entities: Record; middleware: Middleware = controller => next => async action => { switch (action.type) { diff --git a/.agents/skills/data-client-manager/references/Manager.vue.md b/.agents/skills/data-client-manager/references/Manager.vue.md index 585d5e88ef4c..712242bfe6f5 100644 --- a/.agents/skills/data-client-manager/references/Manager.vue.md +++ b/.agents/skills/data-client-manager/references/Manager.vue.md @@ -80,7 +80,7 @@ The job of `middleware` is to dispatch actions, respond to [actions](./Actions.v [Controller](./Controller.vue.md) provides type-safe action dispatchers. ```ts title="CurrentTime" -import { Entity } from '@data-client/endpoint'; +import { Entity } from '@data-client/rest'; export default class CurrentTime extends Entity { id = 0; @@ -89,11 +89,11 @@ export default class CurrentTime extends Entity { ``` ```ts title="TimeManager" -import type { Manager, Middleware } from '@data-client/core'; +import type { Manager, Middleware } from '@data-client/vue'; import CurrentTime from './CurrentTime'; export default class TimeManager implements Manager { - protected declare intervalID?: ReturnType; + declare protected intervalID?: ReturnType; middleware: Middleware = controller => { this.intervalID = setInterval(() => { @@ -114,8 +114,8 @@ export default class TimeManager implements Manager { `actionTypes` includes all constants to distinguish between different [actions](./Actions.vue.md). ```ts -import type { Manager, Middleware } from '@data-client/react'; -import { actionTypes } from '@data-client/react'; +import type { Manager, Middleware } from '@data-client/vue'; +import { actionTypes } from '@data-client/vue'; export default class LoggingManager implements Manager { middleware: Middleware = controller => next => async action => { @@ -152,20 +152,26 @@ encouraging safe access to its members. In case we want to 'handle' a certain [action](./Actions.vue.md), we can 'consume' it by not calling next. ```ts title="isEntity" -import type { Schema, EntityInterface } from '@data-client/core'; +import type { Schema, EntityInterface } from '@data-client/vue'; -export default function isEntity(schema: Schema): schema is EntityInterface { +export default function isEntity( + schema: Schema, +): schema is EntityInterface { return schema !== null && (schema as any).pk !== undefined; } ``` ```ts title="SubsManager" -import type { Manager, Middleware, EntityInterface } from '@data-client/react'; -import { actionTypes } from '@data-client/react'; +import type { + Manager, + Middleware, + EntityInterface, +} from '@data-client/vue'; +import { actionTypes } from '@data-client/vue'; import isEntity from './isEntity'; export default class CustomSubsManager implements Manager { - protected declare entities: Record; + declare protected entities: Record; middleware: Middleware = controller => next => async action => { switch (action.type) { diff --git a/.agents/skills/data-client-manager/references/managers.md b/.agents/skills/data-client-manager/references/managers.md index 2ae33635b9b3..aff04f77cf0e 100644 --- a/.agents/skills/data-client-manager/references/managers.md +++ b/.agents/skills/data-client-manager/references/managers.md @@ -35,7 +35,7 @@ its [Controller](./Controller.md) ### Middleware logging ```typescript -import type { Manager, Middleware } from '@data-client/core'; +import type { Manager, Middleware } from '@data-client/react'; export default class LoggingManager implements Manager { middleware: Middleware = controller => next => async action => { @@ -230,9 +230,14 @@ export default class PersistManager implements Manager { } ``` -```tsx +```tsx title="index.tsx" +import { DataProvider, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; import { get } from 'idb-keyval'; +import App from './App'; +import PersistManager from './PersistManager'; +const managers = [...getDefaultManagers(), new PersistManager()]; const initialState = await get('data-client'); createRoot(document.body).render( @@ -250,14 +255,18 @@ we can maintain fresh data when the data updates are independent of user action. price, or a real-time collaborative editor. ```typescript -import { type Manager, type Middleware, Controller } from '@data-client/react'; -import type { Entity } from '@data-client/rest'; +import type { + Manager, + Middleware, + Controller, + EntityInterface, +} from '@data-client/react'; export default class StreamManager implements Manager { declare protected controller: Controller; declare protected evtSource: WebSocket; // | EventSource; declare protected createEventSource: () => WebSocket | EventSource; - declare protected entities: Record; + declare protected entities: Record; constructor( createEventSource: () => WebSocket | EventSource, @@ -270,7 +279,7 @@ export default class StreamManager implements Manager { middleware: Middleware = controller => { this.controller = controller; return next => async action => next(action); - } + }; connect() { this.evtSource = this.createEventSource(); @@ -278,7 +287,11 @@ export default class StreamManager implements Manager { try { const msg = JSON.parse(event.data); if (msg.type in this.entities) - this.controller.set(this.entities[msg.type], ...msg.args, msg.data); + this.controller.set( + this.entities[msg.type], + ...msg.args, + msg.data, + ); } catch (e) { console.error('Failed to handle message'); console.error(e); @@ -417,10 +430,9 @@ import { Ticker } from './Ticker'; export default function getManagers() { return [ - new StreamManager( - () => new WebSocket('wss://ws-feed.example.com'), - { ticker: Ticker }, - ), + new StreamManager(() => new WebSocket('wss://ws-feed.example.com'), { + ticker: Ticker, + }), ...getDefaultManagers({ devToolsManager: { // Increase latency buffer for high-frequency updates diff --git a/.agents/skills/data-client-manager/references/managers.vue.md b/.agents/skills/data-client-manager/references/managers.vue.md index 31da8beecf23..1f3094f34b13 100644 --- a/.agents/skills/data-client-manager/references/managers.vue.md +++ b/.agents/skills/data-client-manager/references/managers.vue.md @@ -5,7 +5,7 @@ Reactive Data Client uses the [flux store](https://facebookarchive.github.io/flux/docs/in-depth-overview/) pattern, which is characterized by an easy to [understand and debug](https://dataclient.io/vue/getting-started/debugging) the store's [undirectional data flow](https://en.wikipedia.org/wiki/Unidirectional_Data_Flow_\(computer_science\)). State updates are performed by a [reducer function](https://github.com/reactive/data-client/blob/master/packages/core/src/state/reducer/createReducer.ts#L19). -In flux architectures, it is critical all functions in the flux loop are [pure](https://react.dev/learn/keeping-components-pure). +In flux architectures, it is critical all functions in the flux loop are [pure](https://en.wikipedia.org/wiki/Pure_function). Managers provide centralized orchestration of side effects. In other words, they are the means to interface with the world outside Data Client. @@ -35,7 +35,7 @@ its [Controller](./Controller.vue.md) ### Middleware logging ```typescript -import type { Manager, Middleware } from '@data-client/core'; +import type { Manager, Middleware } from '@data-client/vue'; export default class LoggingManager implements Manager { middleware: Middleware = controller => next => async action => { @@ -58,8 +58,8 @@ import { type Manager, type Middleware, actionTypes, -} from '@data-client/react'; -import { captureException } from '@sentry/react'; +} from '@data-client/vue'; +import { captureException } from '@sentry/vue'; export default class ErrorReportManager implements Manager { middleware: Middleware = controller => next => async action => { @@ -84,7 +84,7 @@ import { type Manager, type Middleware, actionTypes, -} from '@data-client/react'; +} from '@data-client/vue'; import { trackTiming } from './analytics'; export default class MetricsManager implements Manager { @@ -111,7 +111,7 @@ import { type Manager, type Middleware, actionTypes, -} from '@data-client/react'; +} from '@data-client/vue'; import { toast } from './toast'; export default class ToastManager implements Manager { @@ -137,7 +137,7 @@ triggering refetch of any _actively rendered_ data without suspending ([stale-wh [init()](./Manager.vue.md#init) and [cleanup()](./Manager.vue.md#cleanup) manage the event listeners. ```typescript -import type { Manager, Middleware, Controller } from '@data-client/react'; +import type { Manager, Middleware, Controller } from '@data-client/vue'; export default class RefreshManager implements Manager { declare protected controller: Controller; @@ -171,7 +171,7 @@ import { type Manager, type Middleware, actionTypes, -} from '@data-client/react'; +} from '@data-client/vue'; export default class TabSyncManager implements Manager { protected channel = new BroadcastChannel('data-client'); @@ -200,14 +200,14 @@ export default class TabSyncManager implements Manager { Persist the store with [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) (here via [idb-keyval](https://www.npmjs.com/package/idb-keyval)); restore it with -DataClientPlugin's `initialState` option. IndexedDB writes are +[DataClientPlugin's `initialState` option](https://dataclient.io/vue/getting-started/installation#add-provider-at-top-level-component). IndexedDB writes are asynchronous and use [structured clone](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) instead of blocking the main thread with JSON serialization like `localStorage` would. Debouncing writes keeps rapid action bursts cheap. Consider [expiry times](https://dataclient.io/vue/concepts/expiry-policy) when restoring. ```typescript -import type { Manager, Middleware } from '@data-client/react'; +import type { Manager, Middleware } from '@data-client/vue'; import { set } from 'idb-keyval'; export default class PersistManager implements Manager { @@ -230,16 +230,19 @@ export default class PersistManager implements Manager { } ``` -```tsx +```ts title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin, getDefaultManagers } from '@data-client/vue'; import { get } from 'idb-keyval'; +import App from './App.vue'; +import PersistManager from './PersistManager'; +const managers = [...getDefaultManagers(), new PersistManager()]; const initialState = await get('data-client'); -createRoot(document.body).render( - - - , -); +const app = createApp(App); +app.use(DataClientPlugin, { initialState, managers }); +app.mount('#app'); ``` ### Middleware data stream (push-based) {#data-stream} @@ -250,14 +253,18 @@ we can maintain fresh data when the data updates are independent of user action. price, or a real-time collaborative editor. ```typescript -import { type Manager, type Middleware, Controller } from '@data-client/react'; -import type { Entity } from '@data-client/rest'; +import type { + Manager, + Middleware, + Controller, + EntityInterface, +} from '@data-client/vue'; export default class StreamManager implements Manager { declare protected controller: Controller; declare protected evtSource: WebSocket; // | EventSource; declare protected createEventSource: () => WebSocket | EventSource; - declare protected entities: Record; + declare protected entities: Record; constructor( createEventSource: () => WebSocket | EventSource, @@ -270,7 +277,7 @@ export default class StreamManager implements Manager { middleware: Middleware = controller => { this.controller = controller; return next => async action => next(action); - } + }; connect() { this.evtSource = this.createEventSource(); @@ -278,7 +285,11 @@ export default class StreamManager implements Manager { try { const msg = JSON.parse(event.data); if (msg.type in this.entities) - this.controller.set(this.entities[msg.type], ...msg.args, msg.data); + this.controller.set( + this.entities[msg.type], + ...msg.args, + msg.data, + ); } catch (e) { console.error('Failed to handle message'); console.error(e); @@ -350,16 +361,15 @@ certain high-frequency actions to [DevToolsManager](https://dataclient.io/vue/ap overwhelming the browser extension. ```typescript -import { getDefaultManagers, actionTypes } from '@data-client/react'; +import { getDefaultManagers, actionTypes } from '@data-client/vue'; import StreamManager from './StreamManager'; import { Ticker } from './Ticker'; export default function getManagers() { return [ - new StreamManager( - () => new WebSocket('wss://ws-feed.example.com'), - { ticker: Ticker }, - ), + new StreamManager(() => new WebSocket('wss://ws-feed.example.com'), { + ticker: Ticker, + }), ...getDefaultManagers({ devToolsManager: { // Increase latency buffer for high-frequency updates @@ -374,7 +384,3 @@ export default function getManagers() { ]; } ``` - -### Coin App - -Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/getManagers.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/getManagers.ts), [`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts), [`src/pages/AssetDetail/AssetPrice.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/AssetDetail/AssetPrice.tsx), [`src/resources/StreamManager.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/StreamManager.ts)) diff --git a/.agents/skills/data-client-react/references/Controller.md b/.agents/skills/data-client-react/references/Controller.md index 0b9ed15855c7..f0b692ed2d60 100644 --- a/.agents/skills/data-client-react/references/Controller.md +++ b/.agents/skills/data-client-react/references/Controller.md @@ -653,8 +653,11 @@ function useCache( ``` ```tsx title="MyManager.ts" -import type { Manager, Middleware, actionTypes } from '@data-client/core'; -import type { EndpointInterface } from '@data-client/endpoint'; +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; export default class MyManager implements Manager { middleware: Middleware = controller => { diff --git a/.agents/skills/data-client-vue/references/Controller.md b/.agents/skills/data-client-vue/references/Controller.md index 11a4e6f3f303..7dc7e5a27f62 100644 --- a/.agents/skills/data-client-vue/references/Controller.md +++ b/.agents/skills/data-client-vue/references/Controller.md @@ -587,8 +587,11 @@ In event handlers, pass [getState()](#getState) to read the latest store, as in [getState() example](#getState). ```tsx title="MyManager.ts" -import type { Manager, Middleware, actionTypes } from '@data-client/core'; -import type { EndpointInterface } from '@data-client/endpoint'; +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/vue'; export default class MyManager implements Manager { middleware: Middleware = controller => { diff --git a/.agents/skills/packages-documentation/SKILL.md b/.agents/skills/packages-documentation/SKILL.md index 65c2b3b2f88c..017aa2c9ac07 100644 --- a/.agents/skills/packages-documentation/SKILL.md +++ b/.agents/skills/packages-documentation/SKILL.md @@ -31,6 +31,9 @@ Wrap framework-specific content instead, so shared prose and resources stay in o - `:react[...]` / `:vue[...]` for inline words or links - `` instead of ``, with shared resource code blocks directly inside and component code blocks in `:::react` / `:::vue` +- `framework-imports` on a code fence for framework-agnostic code (managers, middleware): write + `@data-client/react` imports and Vue pages show `@data-client/vue`. Examples never import + `@data-client/core`; apps only install the react or vue package - `vue_title:`, `vue_description:` (any `vue_:`) front matter overrides - `frameworks: [react]` front matter for pages with no Vue equivalent - `foo.vue.md` replaces `foo.md` for Vue; only for Vue-only pages or when nothing is shareable diff --git a/docs/core/README.md b/docs/core/README.md index 0fb7697d35fa..a403069fd5ed 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -663,16 +663,14 @@ which can be used to [initiate data updates](./concepts/managers.md#data-stream)
StreamManager -:::react - -```typescript +```typescript framework-imports import type { Manager, Middleware, ActionTypes } from '@data-client/react'; import { Controller, actionTypes } from '@data-client/react'; import type { EntityInterface } from '@data-client/rest'; export default class StreamManager implements Manager { declare protected evtSource: WebSocket | EventSource; - declare protected entities: Record; + declare protected entities: Record; constructor( evtSource: WebSocket | EventSource, @@ -702,49 +700,6 @@ export default class StreamManager implements Manager { } ``` -::: - -:::vue - -```typescript -import type { Manager, Middleware, ActionTypes } from '@data-client/vue'; -import { Controller, actionTypes } from '@data-client/vue'; -import type { EntityInterface } from '@data-client/rest'; - -export default class StreamManager implements Manager { - declare protected evtSource: WebSocket | EventSource; - declare protected entities: Record; - - constructor( - evtSource: WebSocket | EventSource, - entities: Record, - ) { - this.evtSource = evtSource; - this.entities = entities; - } - - middleware: Middleware = controller => { - this.evtSource.onmessage = event => { - try { - const msg = JSON.parse(event.data); - if (msg.type in this.endpoints) - controller.set(this.entities[msg.type], ...msg.args, msg.data); - } catch (e) { - console.error('Failed to handle message'); - console.error(e); - } - }; - return next => async action => next(action); - }; - - cleanup() { - this.evtSource.close(); - } -} -``` - -::: -
If we don't want the full data stream, we can [useSubscription()](./api/useSubscription.md) or [useLive()](./api/useLive.md) diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index 38cce733aec3..54620b5f21f2 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -982,9 +982,12 @@ In event handlers, pass [getState()](#getState) to read the latest store, as in ::: -```tsx title="MyManager.ts" -import type { Manager, Middleware, actionTypes } from '@data-client/core'; -import type { EndpointInterface } from '@data-client/endpoint'; +```tsx title="MyManager.ts" framework-imports +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; export default class MyManager implements Manager { middleware: Middleware = controller => { diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 03dc47cf3586..cfb8a583a91b 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -131,9 +131,7 @@ When using [WebSockets](../concepts/managers.md#data-stream) or other real-time high-frequency updates can overwhelm the DevTools extension. Use the `predicate` option to filter out specific action types or schemas: -:::react - -```tsx title="index.tsx" +```ts title="managers.ts" framework-imports import { getDefaultManagers, actionTypes } from '@data-client/react'; import { Ticker } from './resources/Ticker'; @@ -152,31 +150,6 @@ const managers = getDefaultManagers({ }); ``` -::: - -:::vue - -```ts title="managers.ts" -import { getDefaultManagers, actionTypes } from '@data-client/vue'; -import { Ticker } from './resources/Ticker'; - -const managers = getDefaultManagers({ - devToolsManager: { - // Increase latency buffer for high-frequency updates - latency: 1000, - // Skip WebSocket SET actions for Ticker to reduce log spam - // (including batched set([Ticker], rows) writes) - // highlight-start - predicate: (state, action) => - action.type !== actionTypes.SET || - (action.schema !== Ticker && action.schema[0] !== Ticker), - // highlight-end - }, -}); -``` - -::: - ## Programmatic store access {#controllers} In development mode, `DevToolsManager` registers each [Controller](/docs/api/Controller) on diff --git a/docs/core/api/Manager.md b/docs/core/api/Manager.md index 20134982db74..ef3bb712969f 100644 --- a/docs/core/api/Manager.md +++ b/docs/core/api/Manager.md @@ -273,7 +273,7 @@ The job of `middleware` is to dispatch actions, respond to [actions](./Actions.m ```ts title="CurrentTime" collapsed -import { Entity } from '@data-client/endpoint'; +import { Entity } from '@data-client/rest'; export default class CurrentTime extends Entity { id = 0; @@ -281,12 +281,12 @@ export default class CurrentTime extends Entity { } ``` -```ts title="TimeManager" -import type { Manager, Middleware } from '@data-client/core'; +```ts title="TimeManager" framework-imports +import type { Manager, Middleware } from '@data-client/react'; import CurrentTime from './CurrentTime'; export default class TimeManager implements Manager { - protected declare intervalID?: ReturnType; + declare protected intervalID?: ReturnType; middleware: Middleware = controller => { this.intervalID = setInterval(() => { @@ -310,7 +310,7 @@ export default class TimeManager implements Manager { -```ts +```ts framework-imports import type { Manager, Middleware } from '@data-client/react'; import { actionTypes } from '@data-client/react'; @@ -352,22 +352,27 @@ In case we want to 'handle' a certain [action](./Actions.md), we can 'consume' i -```ts title="isEntity" collapsed -import type { Schema, EntityInterface } from '@data-client/core'; +```ts title="isEntity" collapsed framework-imports +import type { Schema, EntityInterface } from '@data-client/react'; -export default function isEntity(schema: Schema): schema is EntityInterface { +export default function isEntity( + schema: Schema, +): schema is EntityInterface { return schema !== null && (schema as any).pk !== undefined; } ``` - -```ts title="SubsManager" -import type { Manager, Middleware, EntityInterface } from '@data-client/react'; +```ts title="SubsManager" framework-imports +import type { + Manager, + Middleware, + EntityInterface, +} from '@data-client/react'; import { actionTypes } from '@data-client/react'; import isEntity from './isEntity'; export default class CustomSubsManager implements Manager { - protected declare entities: Record; + declare protected entities: Record; middleware: Middleware = controller => next => async action => { switch (action.type) { diff --git a/docs/core/concepts/managers.md b/docs/core/concepts/managers.md index 219a16ac3c60..0cc5f6aebb73 100644 --- a/docs/core/concepts/managers.md +++ b/docs/core/concepts/managers.md @@ -1,5 +1,6 @@ --- title: Centralized side-effect orchestration with React +vue_title: Centralized side-effect orchestration with Vue sidebar_label: Managers and Middleware description: Safe programmatic access to the global store. Enables fully extensible and scalable side-effects. image: /img/social/managers-card.png @@ -31,7 +32,7 @@ sources={{ }} /> -In flux architectures, it is critical all functions in the flux loop are [pure](https://react.dev/learn/keeping-components-pure). +In flux architectures, it is critical all functions in the flux loop are :react[[pure](https://react.dev/learn/keeping-components-pure)]:vue[[pure](https://en.wikipedia.org/wiki/Pure_function)]. Managers provide centralized orchestration of side effects. In other words, they are the means to interface with the world outside Data Client. @@ -60,8 +61,8 @@ its [Controller](../api/Controller.md) ### Middleware logging -```typescript -import type { Manager, Middleware } from '@data-client/core'; +```typescript framework-imports +import type { Manager, Middleware } from '@data-client/react'; export default class LoggingManager implements Manager { middleware: Middleware = controller => next => async action => { @@ -79,6 +80,8 @@ export default class LoggingManager implements Manager { Report failed fetches to monitoring services like [Sentry](https://sentry.io) by inspecting [SET_RESPONSE](../api/Actions.md#set_response) actions with `error` set. +:::react + ```typescript import { type Manager, @@ -100,12 +103,39 @@ export default class ErrorReportManager implements Manager { } ``` +::: + +:::vue + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/vue'; +import { captureException } from '@sentry/vue'; + +export default class ErrorReportManager implements Manager { + middleware: Middleware = controller => next => async action => { + if (action.type === actionTypes.SET_RESPONSE && action.error) + captureException(action.response, { + extra: { endpoint: action.endpoint.name, args: action.args }, + }); + return next(action); + }; + + cleanup() {} +} +``` + +::: + ### Metrics {#metrics} Track fetch timing by observing [FETCH](../api/Actions.md#fetch) actions. `action.meta.promise` resolves when the fetch completes. -```typescript +```typescript framework-imports import { type Manager, type Middleware, @@ -132,7 +162,7 @@ export default class MetricsManager implements Manager { Show a toast when any [mutation](/rest/guides/side-effects) succeeds or fails. -```typescript +```typescript framework-imports import { type Manager, type Middleware, @@ -162,7 +192,7 @@ export default class ToastManager implements Manager { triggering refetch of any _actively rendered_ data without suspending ([stale-while-revalidate](./expiry-policy.md)). [init()](../api/Manager.md#init) and [cleanup()](../api/Manager.md#cleanup) manage the event listeners. -```typescript +```typescript framework-imports import type { Manager, Middleware, Controller } from '@data-client/react'; export default class RefreshManager implements Manager { @@ -192,7 +222,7 @@ export default class RefreshManager implements Manager { When a mutation succeeds in one tab, mark data stale in all other tabs using [BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). -```typescript +```typescript framework-imports import { type Manager, type Middleware, @@ -226,13 +256,13 @@ export default class TabSyncManager implements Manager { Persist the store with [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) (here via [idb-keyval](https://www.npmjs.com/package/idb-keyval)); restore it with -:react[[DataProvider's initialState](../api/DataProvider.md#initialState)]:vue[DataClientPlugin's `initialState` option]. IndexedDB writes are +:react[[DataProvider's initialState](../api/DataProvider.md#initialState)]:vue[[DataClientPlugin's `initialState` option](../getting-started/installation.md#add-provider-at-top-level-component)]. IndexedDB writes are asynchronous and use [structured clone](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) instead of blocking the main thread with JSON serialization like `localStorage` would. Debouncing writes keeps rapid action bursts cheap. Consider [expiry times](./expiry-policy.md) when restoring. -```typescript +```typescript framework-imports import type { Manager, Middleware } from '@data-client/react'; import { set } from 'idb-keyval'; @@ -256,9 +286,16 @@ export default class PersistManager implements Manager { } ``` -```tsx +:::react + +```tsx title="index.tsx" +import { DataProvider, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; import { get } from 'idb-keyval'; +import App from './App'; +import PersistManager from './PersistManager'; +const managers = [...getDefaultManagers(), new PersistManager()]; const initialState = await get('data-client'); createRoot(document.body).render( @@ -268,6 +305,27 @@ createRoot(document.body).render( ); ``` +::: + +:::vue + +```ts title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin, getDefaultManagers } from '@data-client/vue'; +import { get } from 'idb-keyval'; +import App from './App.vue'; +import PersistManager from './PersistManager'; + +const managers = [...getDefaultManagers(), new PersistManager()]; +const initialState = await get('data-client'); + +const app = createApp(App); +app.use(DataClientPlugin, { initialState, managers }); +app.mount('#app'); +``` + +::: + ### Middleware data stream (push-based) {#data-stream} Adding a manager to process data pushed from the server by [websockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) @@ -275,15 +333,19 @@ or [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server- we can maintain fresh data when the data updates are independent of user action. For example, a trading app's price, or a real-time collaborative editor. -```typescript -import { type Manager, type Middleware, Controller } from '@data-client/react'; -import type { Entity } from '@data-client/rest'; +```typescript framework-imports +import type { + Manager, + Middleware, + Controller, + EntityInterface, +} from '@data-client/react'; export default class StreamManager implements Manager { declare protected controller: Controller; declare protected evtSource: WebSocket; // | EventSource; declare protected createEventSource: () => WebSocket | EventSource; - declare protected entities: Record; + declare protected entities: Record; constructor( createEventSource: () => WebSocket | EventSource, @@ -296,7 +358,7 @@ export default class StreamManager implements Manager { middleware: Middleware = controller => { this.controller = controller; return next => async action => next(action); - } + }; connect() { this.evtSource = this.createEventSource(); @@ -305,7 +367,11 @@ export default class StreamManager implements Manager { try { const msg = JSON.parse(event.data); if (msg.type in this.entities) - this.controller.set(this.entities[msg.type], ...msg.args, msg.data); + this.controller.set( + this.entities[msg.type], + ...msg.args, + msg.data, + ); } catch (e) { console.error('Failed to handle message'); console.error(e); @@ -385,17 +451,16 @@ When using WebSockets or other real-time data sources, you may want to skip logg certain high-frequency actions to [DevToolsManager](../api/DevToolsManager.md) to avoid overwhelming the browser extension. -```typescript +```typescript framework-imports import { getDefaultManagers, actionTypes } from '@data-client/react'; import StreamManager from './StreamManager'; import { Ticker } from './Ticker'; export default function getManagers() { return [ - new StreamManager( - () => new WebSocket('wss://ws-feed.example.com'), - { ticker: Ticker }, - ), + new StreamManager(() => new WebSocket('wss://ws-feed.example.com'), { + ticker: Ticker, + }), ...getDefaultManagers({ devToolsManager: { // Increase latency buffer for high-frequency updates @@ -411,6 +476,10 @@ export default function getManagers() { } ``` +:::react + ### Coin App + +::: diff --git a/docs/core/getting-started/resource.md b/docs/core/getting-started/resource.md index 44ccb329fcbd..723031c08df6 100644 --- a/docs/core/getting-started/resource.md +++ b/docs/core/getting-started/resource.md @@ -253,8 +253,11 @@ export const TodoResource = { ```ts -import type { Manager, Middleware } from '@data-client/core'; -import type { EndpointInterface } from '@data-client/endpoint'; +import type { + Manager, + Middleware, + EndpointInterface, +} from '@data-client/react'; export default class StreamManager implements Manager { protected declare middleware: Middleware; diff --git a/docs/core/guides/render-as-you-fetch.md b/docs/core/guides/render-as-you-fetch.md index c054251cd4e7..d41b64324e4a 100644 --- a/docs/core/guides/render-as-you-fetch.md +++ b/docs/core/guides/render-as-you-fetch.md @@ -39,7 +39,7 @@ Use [Controller.fetchIfStale](../api/Controller#fetchIfStale) in the route event ```ts -import { Controller } from '@data-client/core'; +import { Controller } from '@data-client/react'; import { lazy, Route } from '@anansi/router'; import { getImage } from '@data-client/img'; diff --git a/scripts/copywebsitetypes.sh b/scripts/copywebsitetypes.sh index 2e8f4947a2fa..e8d3316663b0 100755 --- a/scripts/copywebsitetypes.sh +++ b/scripts/copywebsitetypes.sh @@ -4,6 +4,7 @@ cp ./packages/graphql/index.d.ts ./website/src/components/Playground/editor-type cp ./packages/normalizr/index.d.ts ./website/src/components/Playground/editor-types/@data-client/normalizr.d.ts cp ./packages/react/index.d.ts ./website/src/components/Playground/editor-types/@data-client/react.d.ts cp ./packages/rest/index.d.ts ./website/src/components/Playground/editor-types/@data-client/rest.d.ts +cp ./packages/vue/index.d.ts ./website/src/components/Playground/editor-types/@data-client/vue.d.ts mkdir -p ./website/src/components/Playground/editor-types/@data-client/rest mkdir -p ./website/src/components/Playground/editor-types/@data-client/core mkdir -p ./website/src/components/Playground/editor-types/@data-client/react diff --git a/website/framework-docs/README.md b/website/framework-docs/README.md index 1b366560d90d..112a52f5ca37 100644 --- a/website/framework-docs/README.md +++ b/website/framework-docs/README.md @@ -55,12 +55,17 @@ Errors are caught by :react[[Error Boundaries](./AsyncBoundary.md)]:vue[`onError | -------------------------------------- | --------------------------------------------- | | Different block of content | `:::react` / `:::vue` | | Different word or link inline | `:react[...]` / `:vue[...]` | +| Same code, framework's package | ` ```ts framework-imports ` (see below) | | Different front matter value | `vue_:` overrides `:` | | Different sidebar category value | `"vue_"` overrides `""` | | Different heading text | `## :react[...]:vue[...] {#stable-id}` | | Page has no Vue equivalent | `frameworks: [react]` in front matter | | Vue-only page, or nothing is shareable | `foo.vue.md` next to (or instead of) `foo.md` | +Framework-agnostic code (managers, middleware, types) imports from `@data-client/react` and adds +`framework-imports` to the fence; Vue pages show `@data-client/vue` instead. Never import +`@data-client/core` in examples: apps only install `@data-client/react` or `@data-client/vue`. + Nest inside an admonition by giving the outer one more colons (`::::tip` ... `::::`). Sidebars come from `website/sidebars.json` for both frameworks; entries for docs that don't exist diff --git a/website/framework-docs/remarkFramework.js b/website/framework-docs/remarkFramework.js index ef1588d5c919..8087eadbcd38 100644 --- a/website/framework-docs/remarkFramework.js +++ b/website/framework-docs/remarkFramework.js @@ -4,6 +4,8 @@ * * Block: :::react ... ::: or :::vue ... ::: * Inline: :react[Suspense boundary] / :vue[] + * Code: ```ts framework-imports (imports of '@data-client/react' become + * '@data-client/vue' on Vue pages) * * Matching blocks are unwrapped, the rest are removed. Runs per docs instance, * so the same source file renders once for React and once for Vue. Imports @@ -37,6 +39,21 @@ function filterChildren(node, framework) { }); } +const FRAMEWORK_IMPORTS = /(^|\s)framework-imports(?=\s|$)/; +const REACT_PACKAGE = /(['"])@data-client\/react\1/g; + +/** Point framework-agnostic code (managers, middleware) at this framework's package */ +function rewriteImports(node, framework) { + if (node.type === 'code' && FRAMEWORK_IMPORTS.test(node.meta ?? '')) { + node.meta = node.meta.replace(FRAMEWORK_IMPORTS, '$1').trim() || null; + node.value = node.value.replace( + REACT_PACKAGE, + `$1@data-client/${framework}$1`, + ); + } + node.children?.forEach(child => rewriteImports(child, framework)); +} + /** Names a tree may reference (over-approximated: any identifier counts) */ function collectReferences(node, refs = new Set()) { if (node.type === 'ImportDeclaration') return refs; @@ -93,6 +110,7 @@ module.exports = function remarkFramework({ }) { return tree => { filterChildren(tree, framework); + rewriteImports(tree, framework); pruneImports(tree); if (routeBasePath) rewriteLinks(tree, routeBasePath, docIds); }; diff --git a/website/src/components/Playground/editor-types/@data-client/vue.d.ts b/website/src/components/Playground/editor-types/@data-client/vue.d.ts new file mode 100644 index 000000000000..bd1e1f3238ed --- /dev/null +++ b/website/src/components/Playground/editor-types/@data-client/vue.d.ts @@ -0,0 +1,181 @@ +import { EndpointInterface, FetchFunction, Schema, ResolveType, Denormalize, DenormalizeNullable, Queryable, SchemaArgs, ErrorTypes, Controller, DevToolsManager, DevToolsConfig, NetworkManager, SubscriptionManager, Manager, State, GCInterface } from '@data-client/core'; +export { AbstractInstanceType, ActionTypes, Controller, CreateCountRef, DataClientDispatch, DefaultConnectionListener, Denormalize, DenormalizeNullable, DevToolsManager, Dispatch, EndpointExtraOptions, EndpointInterface, EntityInterface, ErrorTypes, ExpiryStatus, FetchAction, FetchFunction, GCInterface, GCOptions, GenericDispatch, InvalidateAction, LogoutManager, Manager, Middleware, MiddlewareAPI, NetworkError, NetworkManager, Normalize, NormalizeNullable, PK, PollingSubscription, Queryable, ResetAction, ResolveType, Schema, SchemaArgs, SchemaClass, SetAction, SetResponseAction, State, SubscribeAction, SubscriptionManager, UnknownError, UnsubscribeAction, UpdateFunction, actionTypes } from '@data-client/core'; +import { MaybeRefOrGetter, DeepReadonly, ComputedRef, Ref, App, ShallowRef } from 'vue'; + +/** Maps each parameter to accept raw value, Ref, ComputedRef, or getter */ +type MaybeRefsOrGetters = { + readonly [K in keyof T]: MaybeRefOrGetter; +}; +/** Maps each parameter to accept raw value, Ref, ComputedRef, or getter, with nullable support */ +type MaybeRefsOrGettersNullable = { + readonly [K in keyof T]: MaybeRefOrGetter; +}; + +/** + * Ensure an endpoint is available. + * Suspends until it is. + * + * @see https://dataclient.io/docs/api/useSuspense + * @throws {Promise} If data is not yet available. + * @throws {NetworkError} If fetch fails. + */ +declare function useSuspense>(endpoint: E, ...args: MaybeRefsOrGetters>): Promise : Denormalize>>>; +declare function useSuspense>(endpoint: E, ...args: MaybeRefsOrGettersNullable> | readonly [null]): Promise | undefined : DenormalizeNullable>>>; + +/** + * Keeps a resource fresh by subscribing to updates. + * Mirrors React hook API. Pass `null` as first arg to unsubscribe. + * @see https://dataclient.io/docs/api/useSubscription + */ +declare function useSubscription>(endpoint: E, ...args: MaybeRefsOrGettersNullable> | readonly [null]): void; + +/** + * Query the store. + * + * `useQuery` results are globally memoized. + * @see https://dataclient.io/docs/api/useQuery + */ +declare function useQuery(schema: S, ...args: MaybeRefsOrGetters>): ComputedRef | undefined>; + +/** + * Ensure an endpoint is available. Keeps it fresh once it is. + * + * useSuspense() + useSubscription() + * @see https://dataclient.io/docs/api/useLive + * @throws {Promise} If data is not yet available. + * @throws {NetworkError} If fetch fails. + */ +declare function useLive>(endpoint: E, ...args: MaybeRefsOrGetters>): Promise : Denormalize>>>; +declare function useLive>(endpoint: E, ...args: MaybeRefsOrGettersNullable> | readonly [null]): Promise | undefined : DenormalizeNullable>>>; + +/** + * Takes an async function and tracks resolution as a boolean. + * + * @see https://dataclient.io/docs/api/useLoading + * @param func A function returning a promise + * @example + ``` + function Button({ onClick, children, ...props }) { + const [clickHandler, loading] = useLoading(onClick); + return h('button', { onClick: clickHandler, ...props }, + loading.value ? 'Loading...' : children + ); + } + ``` + */ +declare function useLoading Promise>(func: F): [F, Ref, Ref]; + +/** + * Keeps value updated after delay time + * + * @see https://dataclient.io/docs/api/useDebounce + * @param value Any immutable value (can be a ref) + * @param delay Time in milliseconds to wait til updating the value + * @param updatable Whether to update at all + * @example + ``` + const [debouncedQuery, isPending] = useDebounce(query, 200); + const list = useSuspense(getThings, { query: debouncedQuery.value }); + ``` + */ +declare function useDebounce(value: T | Ref, delay: number, updatable?: boolean | Ref): [Ref, Ref]; + +type FetchPromise = Promise & { + resolved: boolean; +}; +/** + * Fetch an Endpoint if it is not in cache or stale. + * @see https://dataclient.io/docs/api/useFetch + */ +declare function useFetch>(endpoint: E, ...args: MaybeRefsOrGetters>): Readonly : Denormalize>>>; +declare function useFetch>(endpoint: E, ...args: MaybeRefsOrGettersNullable> | readonly [null]): Readonly : DenormalizeNullable> | undefined>>; + +/** + * Read an Endpoint's response if it is ready. + * + * `useCache` is globally memoized. + * @see https://dataclient.io/docs/api/useCache + */ +declare function useCache, 'key' | 'schema' | 'invalidIfStale'>>(endpoint: E, ...args: MaybeRefsOrGetters>): ComputedRef any ? ResolveType | undefined : any : DenormalizeNullable>; +declare function useCache, 'key' | 'schema' | 'invalidIfStale'>>(endpoint: E, ...args: MaybeRefsOrGettersNullable> | readonly [null]): ComputedRef any ? ResolveType | undefined : any : DenormalizeNullable>; + +/** + * Use async data with { data, loading, error } (DLE) + * @see https://dataclient.io/docs/api/useDLE + */ +declare function useDLE>(endpoint: E, ...args: MaybeRefsOrGetters>): { + data: ComputedRef | undefined : Denormalize | DenormalizeNullable>; + loading: ComputedRef; + error: ComputedRef; +}; +declare function useDLE>(endpoint: E, ...args: MaybeRefsOrGettersNullable> | readonly [null]): { + data: ComputedRef>; + loading: ComputedRef; + error: ComputedRef; +}; + +declare function useController(): Controller; + +/** Returns the default Managers used by DataProvider. + * + * @see https://dataclient.io/docs/api/getDefaultManagers + */ +declare let getDefaultManagers: (options?: GetManagersOptions) => Manager[]; + +type GetManagersOptions = { + devToolsManager?: DevToolsManager | DevToolsConfig | null; + networkManager?: NetworkManager | ConstructorArgs | null; + subscriptionManager?: SubscriptionManager | ConstructorArgs | null; +}; +type ConstructorArgs = T extends new (options: infer O) => any ? O : never; + +interface ProvideOptions { + managers?: Manager[]; + initialState?: State; + Controller?: typeof Controller; + gcPolicy?: GCInterface; + app?: App; +} +interface ProvidedDataClient { + controller: InstanceType; + /** Optimistic overlay state ref provided to consumers */ + stateRef: ShallowRef>; + /** Start the provider (called on mount) */ + start: () => void; + /** Stop the provider (called on unmount) */ + stop: () => void; +} +/** + * Core provider logic that can be used by both composable and plugin. + * This function handles the actual setup of the data client without Vue-specific concerns. + */ +declare function createDataClient(options?: ProvideOptions): ProvidedDataClient; + +/** + * Vue 3 Plugin for Reactive Data Client + * + * Usage: + * ```ts + * import { createApp } from 'vue'; + * import { DataClientPlugin } from '@data-client/vue'; + * + * const app = createApp(App); + * app.use(DataClientPlugin, { + * managers: getDefaultManagers(), + * initialState: customInitialState, + * }); + * app.mount('#app'); + * ``` + */ +declare const DataClientPlugin: { + install(app: App, options?: ProvideOptions): ProvidedDataClient; +}; +declare module 'vue' { + interface ComponentCustomProperties { + $dataClient: Controller; + } +} + +export { DataClientPlugin, type MaybeRefsOrGetters, type MaybeRefsOrGettersNullable, type ProvideOptions, type ProvidedDataClient, createDataClient, getDefaultManagers, useCache, useController, useDLE, useDebounce, useFetch, useLive, useLoading, useQuery, useSubscription, useSuspense }; diff --git a/website/src/components/Playground/monaco/typeLibs.ts b/website/src/components/Playground/monaco/typeLibs.ts index d7314e835e38..2509bacfe744 100644 --- a/website/src/components/Playground/monaco/typeLibs.ts +++ b/website/src/components/Playground/monaco/typeLibs.ts @@ -96,6 +96,7 @@ const DATA_CLIENT_ENTRIES = [ 'core/next', 'core', 'react', + 'vue', 'endpoint', 'normalizr', 'graphql',