diff --git a/.agents/skills/data-client-manager/references/Actions.vue.md b/.agents/skills/data-client-manager/references/Actions.vue.md index bdf040663376..a162d2d3a835 100644 --- a/.agents/skills/data-client-manager/references/Actions.vue.md +++ b/.agents/skills/data-client-manager/references/Actions.vue.md @@ -143,7 +143,7 @@ interface SetResponseAction { Sent by [Controller.setResponse()](./Controller.vue.md#setResponse), [NetworkManager](https://dataclient.io/vue/api/NetworkManager) -Read by [NetworkManager](https://dataclient.io/vue/api/NetworkManager), [LogoutManager](./LogoutManager.md) +Read by [NetworkManager](https://dataclient.io/vue/api/NetworkManager), [LogoutManager](./LogoutManager.vue.md) ## RESET diff --git a/.agents/skills/data-client-manager/references/Controller.md b/.agents/skills/data-client-manager/references/Controller.md index a17432382582..29fe4f0b7687 100644 --- a/.agents/skills/data-client-manager/references/Controller.md +++ b/.agents/skills/data-client-manager/references/Controller.md @@ -91,7 +91,7 @@ function PostListItem({ post }: { post: PostResource }) { await ctrl.fetch(PostResource.delete, { id: post.id }); history.push('/'); }, - [ctrl, id], + [ctrl, post.id], ); return ( @@ -109,7 +109,7 @@ function PostListItem({ post }: { post: PostResource }) { > When using schemas, the denormalized value is returned > > ```ts -> import { useController } from '@data-client/react'; +> const controller = useController(); > > const post = await controller.fetch( > PostResource.getList.push, @@ -175,7 +175,7 @@ when there are many parameterizations in cache. import { type Controller, useController } from '@data-client/react'; const createTradeHandler = (ctrl: Controller) => async trade => { - await ctrl.fetch(TradeResource.getList.push({ user: user.id }, trade)); + await ctrl.fetch(TradeResource.getList.push, { user: user.id }, trade); ctrl.expireAll(AccountResource.get); ctrl.expireAll(AccountResource.getList); }; @@ -200,7 +200,7 @@ function CreateTrade({ id }: { id: string }) { ### invalidate(endpoint, ...args) {#invalidate} -Forces refetching and suspense on [useSuspense](https://dataclient.io/docs/api/useSuspense) with the same Endpoint +Forces refetching and suspenseon [useSuspense](https://dataclient.io/docs/api/useSuspense) with the same Endpoint and parameters. ```tsx @@ -210,8 +210,10 @@ function ArticleName({ id }: { id: string }) { return (
-

{article.title}

- +

{article.title}

+
); } @@ -229,7 +231,7 @@ function ArticleName({ id }: { id: string }) { > > ```ts > // deletes MyResource(5) -> // this will resuspend MyResource.get({id: '5'}) +> // this will refetch MyResource.get({id: '5'}) > // and remove it from MyResource.getList > controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); > ``` @@ -245,8 +247,10 @@ function ArticleName({ id }: { id: string }) { return (
-

{article.title}

- +

{article.title}

+
); } @@ -258,7 +262,7 @@ function ArticleName({ id }: { id: string }) { Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache. -```tsx +```ts const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); @@ -271,11 +275,16 @@ function useLogout() { It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](./LogoutManager.md) as well. -```ts -import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react'; +```tsx +import { + DataProvider, + LogoutManager, + getDefaultManagers, +} from '@data-client/react'; import { createRoot } from 'react-dom/client'; import { unAuth } from '../authentication'; +const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); const managers = [ @@ -304,7 +313,7 @@ Resets/clears the entire Reactive Data Client cache. All inflight requests will This is typically used when logging out or changing authenticated users. ```tsx -const USER_NUMBER_ONE: string = "1111"; +const USER_NUMBER_ONE: string = '1111'; function UserName() { const user = useSuspense(CurrentUserResource.get); @@ -317,7 +326,7 @@ function UserName() { }, [ctrl]); return (
-

{user.name}

+

{user.name}

); @@ -494,18 +503,19 @@ This is used in [useQuery](https://dataclient.io/docs/api/useQuery) and can be u ```tsx title="useQuery.ts" import { useController, - useCacheState, + StateContext, type Queryable, type SchemaArgs, type DenormalizeNullable, -} from '@data-client/core'; +} from '@data-client/react'; +import { useContext } from 'react'; /** Oversimplified useQuery */ function useQuery( schema: S, ...args: SchemaArgs ): DenormalizeNullable | undefined { - const state = useCacheState(); + const state = useContext(StateContext); const controller = useController(); return controller.get(schema, ...args, state); @@ -566,11 +576,12 @@ This is used in [useCache](https://dataclient.io/docs/api/useCache), [useSuspens import { useController, StateContext, - EndpointInterface, -} from '@data-client/core'; + type EndpointInterface, +} from '@data-client/react'; +import { useContext } from 'react'; /** Oversimplified useCache */ -function useCache( +function useCache( endpoint: E, ...args: readonly [...Parameters] ) { diff --git a/.agents/skills/data-client-manager/references/Controller.vue.md b/.agents/skills/data-client-manager/references/Controller.vue.md index 7202b5440324..7398c5dfb2af 100644 --- a/.agents/skills/data-client-manager/references/Controller.vue.md +++ b/.agents/skills/data-client-manager/references/Controller.vue.md @@ -46,61 +46,72 @@ the response or error upon completion. **Create** -```tsx -function CreatePost() { +```html title="CreatePost.vue" + + + ``` **Update** -```tsx -function UpdatePost({ id }: { id: string }) { +```html title="UpdatePost.vue" + + + ``` **Delete** -```tsx -function PostListItem({ post }: { post: PostResource }) { - const ctrl = useController(); +```html title="PostListItem.vue" + + + ``` > **Tip** @@ -109,7 +120,7 @@ function PostListItem({ post }: { post: PostResource }) { > When using schemas, the denormalized value is returned > > ```ts -> import { useController } from '@data-client/react'; +> const controller = useController(); > > const post = await controller.fetch( > PostResource.getList.push, @@ -162,8 +173,6 @@ An [example](https://stackblitz.com/github/reactive/data-client/tree/master/exam }, ``` -Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/routing/routes.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/routing/routes.tsx)) - ### expireAll({ testKey }) {#expireAll} Sets all responses' [expiry status](https://dataclient.io/vue/concepts/expiry-policy) matching `testKey` to [Stale](https://dataclient.io/vue/concepts/expiry-policy#stale). @@ -171,26 +180,28 @@ Sets all responses' [expiry status](https://dataclient.io/vue/concepts/expiry-po This is sometimes useful to trigger refresh of only data presently shown when there are many parameterizations in cache. -```tsx -import { type Controller, useController } from '@data-client/react'; +```html title="CreateTrade.vue" + - return ( -
- - - - - ); -} + ``` > **Tip** @@ -200,27 +211,32 @@ function CreateTrade({ id }: { id: string }) { ### invalidate(endpoint, ...args) {#invalidate} -Forces refetching and suspense on [useSuspense](https://dataclient.io/vue/api/useSuspense) with the same Endpoint -and parameters. +Forces refetching on [useSuspense](https://dataclient.io/vue/api/useSuspense) with the same Endpoint +and parameters. Mounted components [keep showing their current data](https://dataclient.io/vue/concepts/expiry-policy#invalidate) +until the refetch resolves. -```tsx -function ArticleName({ id }: { id: string }) { - const article = useSuspense(ArticleResource.get, { id }); - const ctrl = useController(); +```html title="ArticleName.vue" + + + ``` -> **Tip** -> -> To refresh while continuing to display stale data - [Controller.fetch](#fetch). - > **Tip: Invalidate many endpoints at once** > > Use [schema.Invalidate](https://dataclient.io/rest/api/Invalidate) to invalidate every endpoint that contains a given entity. @@ -229,7 +245,7 @@ function ArticleName({ id }: { id: string }) { > > ```ts > // deletes MyResource(5) -> // this will resuspend MyResource.get({id: '5'}) +> // this will refetch MyResource.get({id: '5'}) > // and remove it from MyResource.getList > controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); > ``` @@ -238,27 +254,31 @@ function ArticleName({ id }: { id: string }) { [Invalidates](https://dataclient.io/vue/concepts/expiry-policy#invalid) all [endpoint keys](https://dataclient.io/rest/api/RestEndpoint#key) matching `testKey`. -```tsx -function ArticleName({ id }: { id: string }) { - const article = useSuspense(ArticleResource.get, { id }); - const ctrl = useController(); +```html title="ArticleName.vue" + + + ``` -> **Tip** -> -> To refresh while continuing to display stale data - [Controller.expireAll](#expireAll) instead. - Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache. -```tsx +```ts const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); @@ -268,14 +288,20 @@ function useLogout() { } ``` -It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](./LogoutManager.md) +It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](./LogoutManager.vue.md) as well. -```ts -import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react'; -import { createRoot } from 'react-dom/client'; +```ts title="main.ts" +import { createApp } from 'vue'; +import { + DataClientPlugin, + LogoutManager, + getDefaultManagers, +} from '@data-client/vue'; import { unAuth } from '../authentication'; +import App from './App.vue'; +const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); const managers = [ @@ -290,11 +316,9 @@ const managers = [ ...getDefaultManagers(), ]; -createRoot(document.body).render( - - - , -); +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); ``` ### resetEntireStore() {#resetEntireStore} @@ -303,25 +327,29 @@ Resets/clears the entire Reactive Data Client cache. All inflight requests will This is typically used when logging out or changing authenticated users. -```tsx -const USER_NUMBER_ONE: string = "1111"; +```html title="UserName.vue" + + + ``` ### set(queryable, ...args, value) {#set} @@ -421,11 +449,12 @@ Any components suspending for the given [Endpoint](https://dataclient.io/rest/ap If data already exists for the given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args, it will be updated. -```tsx +```ts const ctrl = useController(); +let websocket: WebSocket; -useEffect(() => { - const websocket = new Websocket(url); +onMounted(() => { + websocket = new WebSocket(url); websocket.onmessage = event => ctrl.setResponse( @@ -433,12 +462,12 @@ useEffect(() => { ...event.args, event.data, ); - - return () => websocket.close(); }); + +onUnmounted(() => websocket.close()); ``` -This shows a proof of concept in React; however a [Manager websockets implementation](./managers.vue.md#data-stream) +This shows a proof of concept in Vue; however a [Manager websockets implementation](./managers.vue.md#data-stream) would be much more robust. ### setError(endpoint, ...args, error) {#setError} @@ -461,16 +490,17 @@ Marks a new subscription to a given [Endpoint](https://dataclient.io/rest/api/En [useSubscription](https://dataclient.io/vue/api/useSubscription) and [useLive](https://dataclient.io/vue/api/useLive) call this on mount. -This might be useful for custom hooks to sub/unsub based on other factors. +This might be useful for custom composables to sub/unsub based on other factors. -```tsx +```ts const controller = useController(); -const key = endpoint.key(...args); -useEffect(() => { - controller.subscribe(endpoint, ...args); - return () => controller.unsubscribe(endpoint, ...args); -}, [controller, key]); +// args can be a ref, computed or getter; this re-runs when it changes +watchEffect(onCleanup => { + const currentArgs = toValue(args); + controller.subscribe(endpoint, ...currentArgs); + onCleanup(() => controller.unsubscribe(endpoint, ...currentArgs)); +}); ``` ### unsubscribe(endpoint, ...args) {#unsubscribe} @@ -491,25 +521,16 @@ Looks up any [Queryable](https://dataclient.io/rest/api/schema#queryable) [Schem This is used in [useQuery](https://dataclient.io/vue/api/useQuery) and can be used in [Managers](./Manager.vue.md) to safely access the store. -```tsx title="useQuery.ts" -import { - useController, - useCacheState, - type Queryable, - type SchemaArgs, - type DenormalizeNullable, -} from '@data-client/core'; - -/** Oversimplified useQuery */ -function useQuery( - schema: S, - ...args: SchemaArgs -): DenormalizeNullable | undefined { - const state = useCacheState(); - const controller = useController(); - - return controller.get(schema, ...args, state); -} +In components, [useQuery()](https://dataclient.io/vue/api/useQuery) keeps the result reactive. In event handlers, pass +[getState()](#getState) to read the latest store: + +```ts +const ctrl = useController(); + +const toggle = (id: string) => { + const todo = ctrl.get(Todo, { id }, ctrl.getState()); + if (todo) ctrl.set(Todo, { id }, { id, completed: !todo.completed }); +}; ``` ### getResponse(endpoint, ...args, state) {#getResponse} @@ -562,23 +583,8 @@ A number representing time when it expires. Compare to Date.now(). This is used in [useCache](https://dataclient.io/vue/api/useCache), [useSuspense](https://dataclient.io/vue/api/useSuspense) and can be used in [Managers](./Manager.vue.md) to lookup a response with the state provided. -```tsx title="useCache.ts" -import { - useController, - StateContext, - EndpointInterface, -} from '@data-client/core'; - -/** Oversimplified useCache */ -function useCache( - endpoint: E, - ...args: readonly [...Parameters] -) { - const state = useContext(StateContext); - const controller = useController(); - return controller.getResponse(endpoint, ...args, state).data; -} -``` +In event handlers, pass [getState()](#getState) to read the latest store, as in the +[getState() example](#getState). ```tsx title="MyManager.ts" import type { Manager, Middleware, actionTypes } from '@data-client/core'; @@ -623,31 +629,22 @@ Gets the internal state of Reactive Data Client that has _already been [committe > > This should only be used in event handlers or [Managers](./Manager.vue.md). > -> Using getState() in React's render lifecycle can result in data tearing. +> Using getState() in a `computed()` or template won't update when the store changes. Use +> [useQuery()](https://dataclient.io/vue/api/useQuery) or [useCache()](https://dataclient.io/vue/api/useCache) there instead. -```tsx +```ts const controller = useController(); -const updateHandler = useCallback( - async updatePayload => { - const response = await controller.fetch( - MyResource.update, - { id }, - updatePayload, - ); - // the fetch has completed, but react has not yet re-rendered - // this lets use sequence after the next re-render - // we're working on a better solution to this specific case - setTimeout(() => { - const { data: denormalized } = controller.getResponse( - MyResource.update, - { id }, - updatePayload, - controller.getState(), - ); - redirect(denormalized.getterUrl); - }, 40); - }, - [id], -); +const handleShare = () => { + // reads the latest store without making this handler reactive + const { data: article } = controller.getResponse( + ArticleResource.get, + { id: props.id }, + controller.getState(), + ); + if (article) navigator.share({ title: article.title, url: article.url }); +}; ``` + +[Mutations](#endpointsideeffect) resolve _before_ the store is updated, so read their result from +the value `fetch()` resolves with rather than `getState()`. diff --git a/.agents/skills/data-client-manager/references/LogoutManager.md b/.agents/skills/data-client-manager/references/LogoutManager.md index 06428975d00b..2946d38e0027 100644 --- a/.agents/skills/data-client-manager/references/LogoutManager.md +++ b/.agents/skills/data-client-manager/references/LogoutManager.md @@ -158,6 +158,7 @@ const managers = [ > ```ts > import { unAuth } from '../authentication'; > +> const myDomain = 'http://test.com'; > const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); > > const managers = [ diff --git a/.agents/skills/data-client-manager/references/LogoutManager.vue.md b/.agents/skills/data-client-manager/references/LogoutManager.vue.md new file mode 100644 index 000000000000..eb7a4fac994d --- /dev/null +++ b/.agents/skills/data-client-manager/references/LogoutManager.vue.md @@ -0,0 +1,86 @@ + + +# LogoutManager + +Logs out based on fetch responses. By default this is triggered by [401 (Unauthorized)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) status responses. + +> **Info: implements** +> +> `LogoutManager` implements [Manager](./Manager.vue.md) + +## Usage + +```ts title="main.ts" +import { createApp } from 'vue'; +import { + DataClientPlugin, + LogoutManager, + getDefaultManagers, +} from '@data-client/vue'; +import App from './App.vue'; + +const managers = [new LogoutManager(), ...getDefaultManagers()]; + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +### Custom logout handler + +```ts +import { unAuth } from '../authentication'; + +const managers = [ + new LogoutManager({ + handleLogout(controller) { + // call custom unAuth function we defined + unAuth(); + // still reset the store + controller.resetEntireStore(); + }, + }), + ...getDefaultManagers(), +]; +``` + +> **Tip** +> +> Use [controller.invalidateAll](./Controller.vue.md#invalidateAll) to only clear part of the cache. +> +> ```ts +> import { unAuth } from '../authentication'; +> +> const myDomain = 'http://test.com'; +> const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); +> +> const managers = [ +> new LogoutManager({ +> handleLogout(controller) { +> // call custom unAuth function we defined +> unAuth(); +> // still reset the store +> controller.invalidateAll({ testKey }); +> }, +> }), +> ...getDefaultManagers(), +> ]; +> ``` + +## Members + +### handleLogout(controller) + +By default simply calls [controller.resetEntireStore()](./Controller.vue.md#resetEntireStore) + +This should be sufficient if login state is determined by a user entity existance in the Reactive Data Client store. However, +you can override this method via inheritance if more should be done. + +### shouldLogout(error) + +```ts +protected shouldLogout(error: UnknownError) { + // 401 indicates reauthorization is needed + return error.status === 401; +} +``` diff --git a/.agents/skills/data-client-manager/references/Manager.md b/.agents/skills/data-client-manager/references/Manager.md index 96746db6c055..33147ea5ed06 100644 --- a/.agents/skills/data-client-manager/references/Manager.md +++ b/.agents/skills/data-client-manager/references/Manager.md @@ -30,7 +30,9 @@ interface Manager { ### middleware `middleware` is very similar to a [redux middleware](https://redux.js.org/advanced/middleware). -The only differences is that the `next()` function returns a `Promise`. This promise resolves when the reducer update is +The only differences is that the `next()` function returns a `Promise`. + +This promise resolves when the reducer update is [committed](https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/#general-algorithm) when using \. This is necessary since the commit phase is asynchronously scheduled. This enables building managers that perform work after the DOM is updated and also with the newly computed state. @@ -224,7 +226,7 @@ export default class LoggingManager implements Manager { console.info( `${action.endpoint.name} ${JSON.stringify(action.response)}`, ); - // wait for state update to be committed to React + // wait for state update to be committed await next(action); // get the data from the store, which may be merged with existing state const { data } = controller.getResponse( diff --git a/.agents/skills/data-client-manager/references/Manager.vue.md b/.agents/skills/data-client-manager/references/Manager.vue.md index 88ec2bad20b3..585d5e88ef4c 100644 --- a/.agents/skills/data-client-manager/references/Manager.vue.md +++ b/.agents/skills/data-client-manager/references/Manager.vue.md @@ -2,7 +2,7 @@ # Manager -`Managers` are singletons that handle global side-effects. Kind of like [useEffect()](https://react.dev/reference/react/useEffect) for the central data +`Managers` are singletons that handle global side-effects. Kind of like [watchEffect()](https://vuejs.org/api/reactivity-core.html#watcheffect) for the central data store. The default managers orchestrate the complex asynchronous behavior that Data Client @@ -30,10 +30,11 @@ interface Manager { ### middleware `middleware` is very similar to a [redux middleware](https://redux.js.org/advanced/middleware). -The only differences is that the `next()` function returns a `Promise`. This promise resolves when the reducer update is -[committed](https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/#general-algorithm) -when using \. This is necessary since the commit phase is asynchronously scheduled. This enables building -managers that perform work after the DOM is updated and also with the newly computed state. +The only differences is that the `next()` function returns a `Promise`. + +This promise resolves when the reducer update is committed to the +[DataClientPlugin](https://dataclient.io/vue/getting-started/installation) store. This enables building managers that perform work with the +newly computed state. Since redux is fully synchronous, an adapter must be placed in front of Reactive Data Client style middleware to ensure they can consume a promise. Conversely, redux middleware must be changed to pass through promises. @@ -55,120 +56,21 @@ Provides any cleanup of dangling resources after manager is no longer in use. Use the `managers` option of [DataClientPlugin](https://dataclient.io/vue/getting-started/installation). The plugin is installed once per app, so managers are created once. -**Web** - -```tsx title="/index.tsx" -import { DataProvider, getDefaultManagers } from '@data-client/react'; -import { createRoot } from 'react-dom/client'; - -const managers = [...getDefaultManagers(), new MyManager()]; - -createRoot(document.body).render( - - - , -); -``` - -**React Native** - -```tsx title="/index.tsx" -import { DataProvider, getDefaultManagers } from '@data-client/react'; -import { AppRegistry } from 'react-native'; - -const managers = [...getDefaultManagers(), new MyManager()]; - -const Root = () => ( - - - -); -AppRegistry.registerComponent('MyApp', () => Root); -``` - -**NextJS** - -```tsx title="app/Provider.tsx" -'use client'; -import { getDefaultManagers } from '@data-client/react'; -import { DataProvider } from '@data-client/react/nextjs'; +```ts title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin, getDefaultManagers } from '@data-client/vue'; +import App from './App.vue'; const managers = [...getDefaultManagers(), new MyManager()]; -export default function Provider({ - children, -}: { - children: React.ReactNode; -}) { - return {children}; -} -``` - -```tsx title="app/_layout.tsx" -import Provider from './Provider'; - -export default function RootLayout({ children }) { - return ( - - - {children} - - - ); -} -``` - -**Expo** - -```tsx title="app/Provider.tsx" -import { getDefaultManagers, DataProvider } from '@data-client/react'; -import { - DarkTheme, - DefaultTheme, - ThemeProvider, -} from '@react-navigation/native'; -import { useColorScheme } from '@/hooks/useColorScheme'; - -const managers = [...getDefaultManagers(), new MyManager()]; - -export default function Provider({ - children, -}: { - children: React.ReactNode; -}) { - const colorScheme = useColorScheme(); - - return ( - - {children} - - ); -} -``` - -```tsx title="app/_layout.tsx" -import { Stack } from 'expo-router'; -import 'react-native-reanimated'; - -import Provider from './Provider'; - -export default function RootLayout() { - return ( - - - - - - - ); -} +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); ``` ## Control flow -Managers integrate with the DataProvider store with their lifecycles and middleware. They orchestrate complex control +Managers integrate with the DataClientPlugin store with their lifecycles and middleware. They orchestrate complex control flows by interfacing via intercepting and dispatching [actions](./Actions.vue.md), as well as reading the internal state. The job of `middleware` is to dispatch actions, respond to [actions](./Actions.vue.md), or both. @@ -223,7 +125,7 @@ export default class LoggingManager implements Manager { console.info( `${action.endpoint.name} ${JSON.stringify(action.response)}`, ); - // wait for state update to be committed to React + // wait for state update to be committed await next(action); // get the data from the store, which may be merged with existing state const { data } = controller.getResponse( @@ -312,6 +214,6 @@ Minimal examples for common Manager use cases: - [Cross-tab synchronization](./managers.vue.md#cross-tab-sync) - [Offline persistence](./managers.vue.md#persistence) - [Data streams (websockets/SSE)](./managers.vue.md#data-stream) -- [Authentication: logout on 401](./LogoutManager.md) +- [Authentication: logout on 401](./LogoutManager.vue.md) - [Periodic updates (interval/ticker)](#dispatching-actions) - [Custom transport subscriptions](#reading-and-consuming-actions) diff --git a/.agents/skills/data-client-manager/references/managers.vue.md b/.agents/skills/data-client-manager/references/managers.vue.md index 13a4ad7d80c2..31da8beecf23 100644 --- a/.agents/skills/data-client-manager/references/managers.vue.md +++ b/.agents/skills/data-client-manager/references/managers.vue.md @@ -25,7 +25,7 @@ They can also be customized to change core behaviors. | [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager) | Handles polling [subscriptions](https://dataclient.io/vue/getting-started/data-dependency#subscriptions) | | [DevToolsManager](https://dataclient.io/vue/api/DevToolsManager) | Enables [debugging](https://dataclient.io/vue/getting-started/debugging) | | Extra managers | | -| [LogoutManager](./LogoutManager.md) | Handles HTTP `401` (or other logout conditions) | +| [LogoutManager](./LogoutManager.vue.md) | Handles HTTP `401` (or other logout conditions) | ## Examples diff --git a/.agents/skills/data-client-react-testing/references/mockInitialState.md b/.agents/skills/data-client-react-testing/references/mockInitialState.md index f67b05b3eec0..d3915e8213d9 100644 --- a/.agents/skills/data-client-react-testing/references/mockInitialState.md +++ b/.agents/skills/data-client-react-testing/references/mockInitialState.md @@ -26,18 +26,14 @@ This can be used as the initialState prop for [\](https://datacl ## Example -```typescript -import { DataProvider } from '@data-client/react'; -import { mockInitialState } from '@data-client/test'; - +```ts title="fixtures.ts" import ArticleResource from 'resources/ArticleResource'; -import MyComponentToTest from 'components/MyComponentToTest'; -const results = [ +export const results = [ { - request: ArticleResource.getList, - params: { maxResults: 10 }, - result: [ + endpoint: ArticleResource.getList, + args: [{ maxResults: 10 }], + response: [ { id: 5, content: 'have a merry christmas', @@ -53,6 +49,14 @@ const results = [ ], }, ]; +``` + +```tsx +import { DataProvider } from '@data-client/react'; +import { mockInitialState } from '@data-client/test'; + +import MyComponentToTest from 'components/MyComponentToTest'; +import { results } from './fixtures'; diff --git a/.agents/skills/data-client-react/references/Controller.md b/.agents/skills/data-client-react/references/Controller.md index eaf1bc603301..6da08e10c2c1 100644 --- a/.agents/skills/data-client-react/references/Controller.md +++ b/.agents/skills/data-client-react/references/Controller.md @@ -91,7 +91,7 @@ function PostListItem({ post }: { post: PostResource }) { await ctrl.fetch(PostResource.delete, { id: post.id }); history.push('/'); }, - [ctrl, id], + [ctrl, post.id], ); return ( @@ -109,7 +109,7 @@ function PostListItem({ post }: { post: PostResource }) { > When using schemas, the denormalized value is returned > > ```ts -> import { useController } from '@data-client/react'; +> const controller = useController(); > > const post = await controller.fetch( > PostResource.getList.push, @@ -175,7 +175,7 @@ when there are many parameterizations in cache. import { type Controller, useController } from '@data-client/react'; const createTradeHandler = (ctrl: Controller) => async trade => { - await ctrl.fetch(TradeResource.getList.push({ user: user.id }, trade)); + await ctrl.fetch(TradeResource.getList.push, { user: user.id }, trade); ctrl.expireAll(AccountResource.get); ctrl.expireAll(AccountResource.getList); }; @@ -200,7 +200,7 @@ function CreateTrade({ id }: { id: string }) { ### invalidate(endpoint, ...args) {#invalidate} -Forces refetching and suspense on [useSuspense](./useSuspense.md) with the same Endpoint +Forces refetching and suspenseon [useSuspense](./useSuspense.md) with the same Endpoint and parameters. ```tsx @@ -210,8 +210,10 @@ function ArticleName({ id }: { id: string }) { return (
-

{article.title}

- +

{article.title}

+
); } @@ -229,7 +231,7 @@ function ArticleName({ id }: { id: string }) { > > ```ts > // deletes MyResource(5) -> // this will resuspend MyResource.get({id: '5'}) +> // this will refetch MyResource.get({id: '5'}) > // and remove it from MyResource.getList > controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); > ``` @@ -245,8 +247,10 @@ function ArticleName({ id }: { id: string }) { return (
-

{article.title}

- +

{article.title}

+
); } @@ -258,7 +262,7 @@ function ArticleName({ id }: { id: string }) { Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache. -```tsx +```ts const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); @@ -271,11 +275,16 @@ function useLogout() { It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](https://dataclient.io/docs/api/LogoutManager) as well. -```ts -import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react'; +```tsx +import { + DataProvider, + LogoutManager, + getDefaultManagers, +} from '@data-client/react'; import { createRoot } from 'react-dom/client'; import { unAuth } from '../authentication'; +const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); const managers = [ @@ -304,7 +313,7 @@ Resets/clears the entire Reactive Data Client cache. All inflight requests will This is typically used when logging out or changing authenticated users. ```tsx -const USER_NUMBER_ONE: string = "1111"; +const USER_NUMBER_ONE: string = '1111'; function UserName() { const user = useSuspense(CurrentUserResource.get); @@ -317,7 +326,7 @@ function UserName() { }, [ctrl]); return (
-

{user.name}

+

{user.name}

); @@ -494,18 +503,19 @@ This is used in [useQuery](./useQuery.md) and can be used in ```tsx title="useQuery.ts" import { useController, - useCacheState, + StateContext, type Queryable, type SchemaArgs, type DenormalizeNullable, -} from '@data-client/core'; +} from '@data-client/react'; +import { useContext } from 'react'; /** Oversimplified useQuery */ function useQuery( schema: S, ...args: SchemaArgs ): DenormalizeNullable | undefined { - const state = useCacheState(); + const state = useContext(StateContext); const controller = useController(); return controller.get(schema, ...args, state); @@ -566,11 +576,12 @@ This is used in [useCache](./useCache.md), [useSuspense](./useSuspense.md) and c import { useController, StateContext, - EndpointInterface, -} from '@data-client/core'; + type EndpointInterface, +} from '@data-client/react'; +import { useContext } from 'react'; /** Oversimplified useCache */ -function useCache( +function useCache( endpoint: E, ...args: readonly [...Parameters] ) { diff --git a/.agents/skills/data-client-rest/references/expiry-policy.md b/.agents/skills/data-client-rest/references/expiry-policy.md index 0ed3ee46bf31..69ec8aba073a 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.md @@ -25,8 +25,8 @@ In these cases it will fetch if the data is considered stale. ### Invalid -Data should not be shown. Any components needing this data will trigger fetch and suspense. If -no components care about this data no action will be taken. +Data should not be shown. Any components needing this data will trigger fetch and suspense. If no components care about this +data no action will be taken. ## Expiry Time diff --git a/.agents/skills/data-client-rest/references/expiry-policy.vue.md b/.agents/skills/data-client-rest/references/expiry-policy.vue.md index 8d608580b39a..0a612772d846 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.vue.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.vue.md @@ -21,8 +21,9 @@ In these cases it will fetch if the data is considered stale. ### Invalid -Data should not be shown. Any components needing this data will trigger fetch and suspense. If -no components care about this data no action will be taken. +Data should not be shown. Any components needing this data will trigger fetch +([mounted components keep their data](#invalidate) until it resolves). If no components care about this +data no action will be taken. ## Expiry Time diff --git a/.agents/skills/data-client-vue-testing/SKILL.md b/.agents/skills/data-client-vue-testing/SKILL.md index 2fb77d8ce34c..ea213db27516 100644 --- a/.agents/skills/data-client-vue-testing/SKILL.md +++ b/.agents/skills/data-client-vue-testing/SKILL.md @@ -369,6 +369,7 @@ expect(result.current?.value?.title).toBe('hi ho'); For detailed API documentation, see the [references](references/) directory: - [Fixtures](references/Fixtures.md) - Fixture format reference +- [mockInitialState](references/mockInitialState.md) - Create initial state for `DataClientPlugin` - [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.json b/.agents/skills/data-client-vue-testing/references.json index 3e2b9c7b8c4d..ff3e4008d5d5 100644 --- a/.agents/skills/data-client-vue-testing/references.json +++ b/.agents/skills/data-client-vue-testing/references.json @@ -3,6 +3,7 @@ "vue" ], "docs": { - "Fixtures.md": "docs/core/api/Fixtures.md" + "Fixtures.md": "docs/core/api/Fixtures.md", + "mockInitialState.md": "docs/core/api/mockInitialState.md" } } diff --git a/.agents/skills/data-client-vue-testing/references/Fixtures.md b/.agents/skills/data-client-vue-testing/references/Fixtures.md index b3d3f0b1ea75..4ed2c4bd6c0f 100644 --- a/.agents/skills/data-client-vue-testing/references/Fixtures.md +++ b/.agents/skills/data-client-vue-testing/references/Fixtures.md @@ -4,7 +4,7 @@ Fixtures and Interceptors allow universal data mocking without the need for monkeypatching fetch behaviors. Fixtures define static responses to specific endpoint arg combinations. This -allows them to be used in static contexts like [mockInitialState()](https://dataclient.io/vue/api/mockInitialState). +allows them to be used in static contexts like [mockInitialState()](./mockInitialState.md). Interceptors are functions run and match a fetch pattern. This restricts them to being used only in dynamic response contexts like `MockPlugin`. diff --git a/.agents/skills/data-client-vue-testing/references/mockInitialState.md b/.agents/skills/data-client-vue-testing/references/mockInitialState.md new file mode 100644 index 000000000000..923759fbce67 --- /dev/null +++ b/.agents/skills/data-client-vue-testing/references/mockInitialState.md @@ -0,0 +1,65 @@ + + +# mockInitialState() + +```typescript +function mockInitialState(results: Fixture[]): State; +``` + +`mockInitialState()` makes it easy to construct prefill the cache with [fixtures](./Fixtures.md). It's +used in `MockPlugin` to process the fixtures option. However, this +can also be useful to send into a normal provider when testing more complete flows +that need to handle `dispatches` (and thus fetch). + +### Arguments + +#### results + +```typescript +export type Fixture = SuccessFixture | ErrorFixture; +``` + +This prop specifies the [fixtures](./Fixtures.md) to use data from. Each item represents a fetch defined by the +[Endpoint](https://dataclient.io/rest/api/Endpoint) and params. `Result` contains the JSON response expected from said fetch. + +This can be used as the `initialState` option for [DataClientPlugin](https://dataclient.io/vue/getting-started/installation) + +## Example + +```ts title="fixtures.ts" +import ArticleResource from 'resources/ArticleResource'; + +export const results = [ + { + endpoint: ArticleResource.getList, + args: [{ maxResults: 10 }], + response: [ + { + id: 5, + content: 'have a merry christmas', + author: 2, + contributors: [], + }, + { + id: 532, + content: 'never again', + author: 23, + contributors: [5], + }, + ], + }, +]; +``` + +```ts +import { createApp } from 'vue'; +import { DataClientPlugin } from '@data-client/vue'; +import { mockInitialState } from '@data-client/vue/test'; + +import MyComponentToTest from 'components/MyComponentToTest.vue'; +import { results } from './fixtures'; + +const app = createApp(MyComponentToTest); +app.use(DataClientPlugin, { initialState: mockInitialState(results) }); +app.mount('#app'); +``` diff --git a/.agents/skills/data-client-vue/references/Controller.md b/.agents/skills/data-client-vue/references/Controller.md index 390f59f258ec..11a4e6f3f303 100644 --- a/.agents/skills/data-client-vue/references/Controller.md +++ b/.agents/skills/data-client-vue/references/Controller.md @@ -46,61 +46,72 @@ the response or error upon completion. **Create** -```tsx -function CreatePost() { +```html title="CreatePost.vue" + + + ``` **Update** -```tsx -function UpdatePost({ id }: { id: string }) { +```html title="UpdatePost.vue" + + + ``` **Delete** -```tsx -function PostListItem({ post }: { post: PostResource }) { - const ctrl = useController(); +```html title="PostListItem.vue" + + + ``` > **Tip** @@ -109,7 +120,7 @@ function PostListItem({ post }: { post: PostResource }) { > When using schemas, the denormalized value is returned > > ```ts -> import { useController } from '@data-client/react'; +> const controller = useController(); > > const post = await controller.fetch( > PostResource.getList.push, @@ -162,8 +173,6 @@ An [example](https://stackblitz.com/github/reactive/data-client/tree/master/exam }, ``` -Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/routing/routes.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/routing/routes.tsx)) - ### expireAll({ testKey }) {#expireAll} Sets all responses' [expiry status](https://dataclient.io/vue/concepts/expiry-policy) matching `testKey` to [Stale](https://dataclient.io/vue/concepts/expiry-policy#stale). @@ -171,26 +180,28 @@ Sets all responses' [expiry status](https://dataclient.io/vue/concepts/expiry-po This is sometimes useful to trigger refresh of only data presently shown when there are many parameterizations in cache. -```tsx -import { type Controller, useController } from '@data-client/react'; +```html title="CreateTrade.vue" + - return ( -
- - - - - ); -} + ``` > **Tip** @@ -200,27 +211,32 @@ function CreateTrade({ id }: { id: string }) { ### invalidate(endpoint, ...args) {#invalidate} -Forces refetching and suspense on [useSuspense](./useSuspense.md) with the same Endpoint -and parameters. +Forces refetching on [useSuspense](./useSuspense.md) with the same Endpoint +and parameters. Mounted components [keep showing their current data](https://dataclient.io/vue/concepts/expiry-policy#invalidate) +until the refetch resolves. -```tsx -function ArticleName({ id }: { id: string }) { - const article = useSuspense(ArticleResource.get, { id }); - const ctrl = useController(); +```html title="ArticleName.vue" + + + ``` -> **Tip** -> -> To refresh while continuing to display stale data - [Controller.fetch](#fetch). - > **Tip: Invalidate many endpoints at once** > > Use [schema.Invalidate](https://dataclient.io/rest/api/Invalidate) to invalidate every endpoint that contains a given entity. @@ -229,7 +245,7 @@ function ArticleName({ id }: { id: string }) { > > ```ts > // deletes MyResource(5) -> // this will resuspend MyResource.get({id: '5'}) +> // this will refetch MyResource.get({id: '5'}) > // and remove it from MyResource.getList > controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); > ``` @@ -238,27 +254,31 @@ function ArticleName({ id }: { id: string }) { [Invalidates](https://dataclient.io/vue/concepts/expiry-policy#invalid) all [endpoint keys](https://dataclient.io/rest/api/RestEndpoint#key) matching `testKey`. -```tsx -function ArticleName({ id }: { id: string }) { - const article = useSuspense(ArticleResource.get, { id }); - const ctrl = useController(); +```html title="ArticleName.vue" + + + ``` -> **Tip** -> -> To refresh while continuing to display stale data - [Controller.expireAll](#expireAll) instead. - Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache. -```tsx +```ts const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); @@ -271,11 +291,17 @@ function useLogout() { It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](https://dataclient.io/vue/api/LogoutManager) as well. -```ts -import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react'; -import { createRoot } from 'react-dom/client'; +```ts title="main.ts" +import { createApp } from 'vue'; +import { + DataClientPlugin, + LogoutManager, + getDefaultManagers, +} from '@data-client/vue'; import { unAuth } from '../authentication'; +import App from './App.vue'; +const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); const managers = [ @@ -290,11 +316,9 @@ const managers = [ ...getDefaultManagers(), ]; -createRoot(document.body).render( - - - , -); +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); ``` ### resetEntireStore() {#resetEntireStore} @@ -303,25 +327,29 @@ Resets/clears the entire Reactive Data Client cache. All inflight requests will This is typically used when logging out or changing authenticated users. -```tsx -const USER_NUMBER_ONE: string = "1111"; +```html title="UserName.vue" + + + ``` ### set(queryable, ...args, value) {#set} @@ -421,11 +449,12 @@ Any components suspending for the given [Endpoint](https://dataclient.io/rest/ap If data already exists for the given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args, it will be updated. -```tsx +```ts const ctrl = useController(); +let websocket: WebSocket; -useEffect(() => { - const websocket = new Websocket(url); +onMounted(() => { + websocket = new WebSocket(url); websocket.onmessage = event => ctrl.setResponse( @@ -433,12 +462,12 @@ useEffect(() => { ...event.args, event.data, ); - - return () => websocket.close(); }); + +onUnmounted(() => websocket.close()); ``` -This shows a proof of concept in React; however a [Manager websockets implementation](https://dataclient.io/vue/concepts/managers#data-stream) +This shows a proof of concept in Vue; however a [Manager websockets implementation](https://dataclient.io/vue/concepts/managers#data-stream) would be much more robust. ### setError(endpoint, ...args, error) {#setError} @@ -461,16 +490,17 @@ Marks a new subscription to a given [Endpoint](https://dataclient.io/rest/api/En [useSubscription](./useSubscription.md) and [useLive](./useLive.md) call this on mount. -This might be useful for custom hooks to sub/unsub based on other factors. +This might be useful for custom composables to sub/unsub based on other factors. -```tsx +```ts const controller = useController(); -const key = endpoint.key(...args); -useEffect(() => { - controller.subscribe(endpoint, ...args); - return () => controller.unsubscribe(endpoint, ...args); -}, [controller, key]); +// args can be a ref, computed or getter; this re-runs when it changes +watchEffect(onCleanup => { + const currentArgs = toValue(args); + controller.subscribe(endpoint, ...currentArgs); + onCleanup(() => controller.unsubscribe(endpoint, ...currentArgs)); +}); ``` ### unsubscribe(endpoint, ...args) {#unsubscribe} @@ -491,25 +521,16 @@ Looks up any [Queryable](https://dataclient.io/rest/api/schema#queryable) [Schem This is used in [useQuery](./useQuery.md) and can be used in [Managers](https://dataclient.io/vue/api/Manager) to safely access the store. -```tsx title="useQuery.ts" -import { - useController, - useCacheState, - type Queryable, - type SchemaArgs, - type DenormalizeNullable, -} from '@data-client/core'; - -/** Oversimplified useQuery */ -function useQuery( - schema: S, - ...args: SchemaArgs -): DenormalizeNullable | undefined { - const state = useCacheState(); - const controller = useController(); - - return controller.get(schema, ...args, state); -} +In components, [useQuery()](./useQuery.md) keeps the result reactive. In event handlers, pass +[getState()](#getState) to read the latest store: + +```ts +const ctrl = useController(); + +const toggle = (id: string) => { + const todo = ctrl.get(Todo, { id }, ctrl.getState()); + if (todo) ctrl.set(Todo, { id }, { id, completed: !todo.completed }); +}; ``` ### getResponse(endpoint, ...args, state) {#getResponse} @@ -562,23 +583,8 @@ A number representing time when it expires. Compare to Date.now(). This is used in [useCache](./useCache.md), [useSuspense](./useSuspense.md) and can be used in [Managers](https://dataclient.io/vue/api/Manager) to lookup a response with the state provided. -```tsx title="useCache.ts" -import { - useController, - StateContext, - EndpointInterface, -} from '@data-client/core'; - -/** Oversimplified useCache */ -function useCache( - endpoint: E, - ...args: readonly [...Parameters] -) { - const state = useContext(StateContext); - const controller = useController(); - return controller.getResponse(endpoint, ...args, state).data; -} -``` +In event handlers, pass [getState()](#getState) to read the latest store, as in the +[getState() example](#getState). ```tsx title="MyManager.ts" import type { Manager, Middleware, actionTypes } from '@data-client/core'; @@ -623,31 +629,22 @@ Gets the internal state of Reactive Data Client that has _already been [committe > > This should only be used in event handlers or [Managers](https://dataclient.io/vue/api/Manager). > -> Using getState() in React's render lifecycle can result in data tearing. +> Using getState() in a `computed()` or template won't update when the store changes. Use +> [useQuery()](./useQuery.md) or [useCache()](./useCache.md) there instead. -```tsx +```ts const controller = useController(); -const updateHandler = useCallback( - async updatePayload => { - const response = await controller.fetch( - MyResource.update, - { id }, - updatePayload, - ); - // the fetch has completed, but react has not yet re-rendered - // this lets use sequence after the next re-render - // we're working on a better solution to this specific case - setTimeout(() => { - const { data: denormalized } = controller.getResponse( - MyResource.update, - { id }, - updatePayload, - controller.getState(), - ); - redirect(denormalized.getterUrl); - }, 40); - }, - [id], -); +const handleShare = () => { + // reads the latest store without making this handler reactive + const { data: article } = controller.getResponse( + ArticleResource.get, + { id: props.id }, + controller.getState(), + ); + if (article) navigator.share({ title: article.title, url: article.url }); +}; ``` + +[Mutations](#endpointsideeffect) resolve _before_ the store is updated, so read their result from +the value `fetch()` resolves with rather than `getState()`. diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index 4080d7c5c36d..8ffde8b38fd7 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -65,6 +65,8 @@ values={[ ]}> +:::react + ```tsx function CreatePost() { const ctrl = useController(); @@ -81,9 +83,36 @@ function CreatePost() { } ``` +::: + +:::vue + +```html title="CreatePost.vue" + + + +``` + +::: + +:::react + ```tsx function UpdatePost({ id }: { id: string }) { const ctrl = useController(); @@ -100,9 +129,38 @@ function UpdatePost({ id }: { id: string }) { } ``` +::: + +:::vue + +```html title="UpdatePost.vue" + + + +``` + +::: + +:::react + ```tsx function PostListItem({ post }: { post: PostResource }) { const ctrl = useController(); @@ -112,7 +170,7 @@ function PostListItem({ post }: { post: PostResource }) { await ctrl.fetch(PostResource.delete, { id: post.id }); history.push('/'); }, - [ctrl, id], + [ctrl, post.id], ); return ( @@ -124,6 +182,36 @@ function PostListItem({ post }: { post: PostResource }) { } ``` +::: + +:::vue + +```html title="PostListItem.vue" + + + +``` + +::: + @@ -133,8 +221,7 @@ function PostListItem({ post }: { post: PostResource }) { When using schemas, the denormalized value is returned ```ts -// highlight-next-line -import { useController } from '@data-client/react'; +const controller = useController(); const post = await controller.fetch( PostResource.getList.push, @@ -191,8 +278,12 @@ An [example](https://stackblitz.com/github/reactive/data-client/tree/master/exam }, ``` +:::react + +::: + ### expireAll(\{ testKey }) {#expireAll} Sets all responses' [expiry status](../concepts/expiry-policy.md) matching `testKey` to [Stale](../concepts/expiry-policy.md#stale). @@ -200,11 +291,13 @@ Sets all responses' [expiry status](../concepts/expiry-policy.md) matching `test This is sometimes useful to trigger refresh of only data presently shown when there are many parameterizations in cache. +:::react + ```tsx import { type Controller, useController } from '@data-client/react'; const createTradeHandler = (ctrl: Controller) => async trade => { - await ctrl.fetch(TradeResource.getList.push({ user: user.id }, trade)); + await ctrl.fetch(TradeResource.getList.push, { user: user.id }, trade); // highlight-start ctrl.expireAll(AccountResource.get); ctrl.expireAll(AccountResource.getList); @@ -224,6 +317,38 @@ function CreateTrade({ id }: { id: string }) { } ``` +::: + +:::vue + +```html title="CreateTrade.vue" + + + +``` + +::: + :::tip To reduce load, improve performance, and improve state consistency; it can often be @@ -233,8 +358,11 @@ better to [include mutation sideeffects in the mutation response](/rest/guides/s ### invalidate(endpoint, ...args) {#invalidate} -Forces refetching and suspense on [useSuspense](./useSuspense.md) with the same Endpoint -and parameters. +Forces refetching :react[and suspense ]on [useSuspense](./useSuspense.md) with the same Endpoint +and parameters.:vue[ Mounted components [keep showing their current data](../concepts/expiry-policy.md#invalidate) +until the refetch resolves.] + +:::react ```tsx function ArticleName({ id }: { id: string }) { @@ -243,19 +371,53 @@ function ArticleName({ id }: { id: string }) { return (
-

{article.title}

- +

{article.title}

+
); } ``` +::: + +:::vue + +```html title="ArticleName.vue" + + + +``` + +::: + +::::react + :::tip To refresh while continuing to display stale data - [Controller.fetch](#fetch). ::: +:::: + :::tip[Invalidate many endpoints at once] Use [schema.Invalidate](/rest/api/Invalidate) to invalidate every endpoint that contains a given entity. @@ -264,7 +426,7 @@ For REST try using [Resource.delete](/rest/api/resource#delete) ```ts // deletes MyResource(5) -// this will resuspend MyResource.get({id: '5'}) +// this will refetch MyResource.get({id: '5'}) // and remove it from MyResource.getList controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); ``` @@ -275,6 +437,8 @@ controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); [Invalidates](../concepts/expiry-policy#invalid) all [endpoint keys](/rest/api/RestEndpoint#key) matching `testKey`. +:::react + ```tsx function ArticleName({ id }: { id: string }) { const article = useSuspense(ArticleResource.get, { id }); @@ -282,22 +446,56 @@ function ArticleName({ id }: { id: string }) { return (
-

{article.title}

- +

{article.title}

+
); } ``` +::: + +:::vue + +```html title="ArticleName.vue" + + + +``` + +::: + +::::react + :::tip To refresh while continuing to display stale data - [Controller.expireAll](#expireAll) instead. ::: +:::: + Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache. -```tsx +```ts const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); @@ -310,11 +508,18 @@ function useLogout() { It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](./LogoutManager.md) as well. -```ts -import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react'; +:::react + +```tsx +import { + DataProvider, + LogoutManager, + getDefaultManagers, +} from '@data-client/react'; import { createRoot } from 'react-dom/client'; import { unAuth } from '../authentication'; +const myDomain = 'http://test.com'; const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); const managers = [ @@ -336,14 +541,52 @@ createRoot(document.body).render( ); ``` +::: + +:::vue + +```ts title="main.ts" +import { createApp } from 'vue'; +import { + DataClientPlugin, + LogoutManager, + getDefaultManagers, +} from '@data-client/vue'; +import { unAuth } from '../authentication'; +import App from './App.vue'; + +const myDomain = 'http://test.com'; +const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); + +const managers = [ + new LogoutManager({ + handleLogout(controller) { + // call custom unAuth function we defined + unAuth(); + // still reset the store + controller.invalidateAll({ testKey }); + }, + }), + ...getDefaultManagers(), +]; + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +::: + ### resetEntireStore() {#resetEntireStore} Resets/clears the entire Reactive Data Client cache. All inflight requests will not resolve. This is typically used when logging out or changing authenticated users. +:::react + ```tsx -const USER_NUMBER_ONE: string = "1111"; +const USER_NUMBER_ONE: string = '1111'; function UserName() { const user = useSuspense(CurrentUserResource.get); @@ -357,13 +600,45 @@ function UserName() { }, [ctrl]); return (
-

{user.name}

+

{user.name}

); } ``` +::: + +:::vue + +```html title="UserName.vue" + + + +``` + +::: + ### set(queryable, ...args, value) {#set} Updates any [Queryable](/rest/api/schema#queryable) [Schema](/rest/api/schema#schema-overview), or many entities at once with an [Array](/rest/api/Array) or [Values](/rest/api/Values) schema. @@ -463,6 +738,8 @@ Any components suspending for the given [Endpoint](/rest/api/Endpoint) and args If data already exists for the given [Endpoint](/rest/api/Endpoint) and args, it will be updated. +:::react + ```tsx const ctrl = useController(); @@ -480,7 +757,31 @@ useEffect(() => { }); ``` -This shows a proof of concept in React; however a [Manager websockets implementation](../concepts/managers.md#data-stream) +::: + +:::vue + +```ts +const ctrl = useController(); +let websocket: WebSocket; + +onMounted(() => { + websocket = new WebSocket(url); + + websocket.onmessage = event => + ctrl.setResponse( + EndpointLookup[event.endpoint], + ...event.args, + event.data, + ); +}); + +onUnmounted(() => websocket.close()); +``` + +::: + +This shows a proof of concept in :react[React]:vue[Vue]; however a [Manager websockets implementation](../concepts/managers.md#data-stream) would be much more robust. ### setError(endpoint, ...args, error) {#setError} @@ -503,7 +804,9 @@ Marks a new subscription to a given [Endpoint](/rest/api/Endpoint). This should [useSubscription](./useSubscription.md) and [useLive](./useLive.md) call this on mount. -This might be useful for custom hooks to sub/unsub based on other factors. +This might be useful for custom :react[hooks]:vue[composables] to sub/unsub based on other factors. + +:::react ```tsx const controller = useController(); @@ -515,6 +818,23 @@ useEffect(() => { }, [controller, key]); ``` +::: + +:::vue + +```ts +const controller = useController(); + +// args can be a ref, computed or getter; this re-runs when it changes +watchEffect(onCleanup => { + const currentArgs = toValue(args); + controller.subscribe(endpoint, ...currentArgs); + onCleanup(() => controller.unsubscribe(endpoint, ...currentArgs)); +}); +``` + +::: + ### unsubscribe(endpoint, ...args) {#unsubscribe} Marks completion of subscription to a given [Endpoint](/rest/api/Endpoint). This should @@ -533,27 +853,48 @@ Looks up any [Queryable](/rest/api/schema#queryable) [Schema](/rest/api/schema#s This is used in [useQuery](./useQuery.md) and can be used in [Managers](./Manager.md) to safely access the store. +:::react + ```tsx title="useQuery.ts" import { useController, - useCacheState, + StateContext, type Queryable, type SchemaArgs, type DenormalizeNullable, -} from '@data-client/core'; +} from '@data-client/react'; +import { useContext } from 'react'; /** Oversimplified useQuery */ function useQuery( schema: S, ...args: SchemaArgs ): DenormalizeNullable | undefined { - const state = useCacheState(); + const state = useContext(StateContext); const controller = useController(); return controller.get(schema, ...args, state); } ``` +::: + +:::vue + +In components, [useQuery()](./useQuery.md) keeps the result reactive. In event handlers, pass +[getState()](#getState) to read the latest store: + +```ts +const ctrl = useController(); + +const toggle = (id: string) => { + const todo = ctrl.get(Todo, { id }, ctrl.getState()); + if (todo) ctrl.set(Todo, { id }, { id, completed: !todo.completed }); +}; +``` + +::: + ### getResponse(endpoint, ...args, state) {#getResponse} ```ts title="returns" @@ -604,15 +945,18 @@ A number representing time when it expires. Compare to Date.now(). This is used in [useCache](./useCache.md), [useSuspense](./useSuspense.md) and can be used in [Managers](./Manager.md) to lookup a response with the state provided. +:::react + ```tsx title="useCache.ts" import { useController, StateContext, - EndpointInterface, -} from '@data-client/core'; + type EndpointInterface, +} from '@data-client/react'; +import { useContext } from 'react'; /** Oversimplified useCache */ -function useCache( +function useCache( endpoint: E, ...args: readonly [...Parameters] ) { @@ -622,6 +966,15 @@ function useCache( } ``` +::: + +:::vue + +In event handlers, pass [getState()](#getState) to read the latest store, as in the +[getState() example](#getState). + +::: + ```tsx title="MyManager.ts" import type { Manager, Middleware, actionTypes } from '@data-client/core'; import type { EndpointInterface } from '@data-client/endpoint'; @@ -661,14 +1014,27 @@ Returns a [Snapshot](./Snapshot.md). Gets the internal state of Reactive Data Client that has _already been [committed](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom)_. -:::warning +::::warning This should only be used in event handlers or [Managers](./Manager.md). +:::react + Using getState() in React's render lifecycle can result in data tearing. ::: +:::vue + +Using getState() in a `computed()` or template won't update when the store changes. Use +[useQuery()](./useQuery.md) or [useCache()](./useCache.md) there instead. + +::: + +:::: + +:::react + ```tsx const controller = useController(); @@ -695,3 +1061,26 @@ const updateHandler = useCallback( [id], ); ``` + +::: + +:::vue + +```ts +const controller = useController(); + +const handleShare = () => { + // reads the latest store without making this handler reactive + const { data: article } = controller.getResponse( + ArticleResource.get, + { id: props.id }, + controller.getState(), + ); + if (article) navigator.share({ title: article.title, url: article.url }); +}; +``` + +[Mutations](#endpointsideeffect) resolve _before_ the store is updated, so read their result from +the value `fetch()` resolves with rather than `getState()`. + +::: diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 7f2194ed06b1..03dc47cf3586 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -29,6 +29,8 @@ to send to redux devtools. 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. +:::react + ```tsx title="index.tsx" import { DataProvider, getDefaultManagers } from '@data-client/react'; import { createRoot } from 'react-dom/client'; @@ -45,6 +47,27 @@ createRoot(document.body).render( ); ``` +::: + +:::vue + +```ts title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin, getDefaultManagers } from '@data-client/vue'; +import App from './App.vue'; + +const managers = getDefaultManagers({ + // highlight-next-line + devToolsManager: { trace: true }, +}); + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +::: + ### skipLogging `(action: ActionTypes) => boolean` @@ -53,6 +76,8 @@ Can skip some actions to be registered in the browser devtool. By default will skip inflight [fetch actions](./Controller.md#fetch) +:::react + ```tsx title="index.tsx" import { DevToolsManager, @@ -61,17 +86,11 @@ import { } from '@data-client/react'; import { createRoot } from 'react-dom/client'; -const managers = - process.env.NODE_ENV !== 'production' - ? [ - // highlight-start - new DevToolsManager(undefined, () => true), - // highlight-end - ...getDefaultManagers().filter( - manager => manager.constructor.name !== 'DevToolsManager', - ), - ] - : getDefaultManagers(); +// production builds leave out DevToolsManager +const managers = getDefaultManagers({ + // highlight-next-line + devToolsManager: new DevToolsManager(undefined, () => true), +}); createRoot(document.body).render( @@ -80,12 +99,40 @@ createRoot(document.body).render( ); ``` +::: + +:::vue + +```ts title="main.ts" +import { createApp } from 'vue'; +import { + DevToolsManager, + DataClientPlugin, + getDefaultManagers, +} from '@data-client/vue'; +import App from './App.vue'; + +// production builds leave out DevToolsManager +const managers = getDefaultManagers({ + // highlight-next-line + devToolsManager: new DevToolsManager(undefined, () => true), +}); + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +::: + #### Skipping high-frequency updates When using [WebSockets](../concepts/managers.md#data-stream) or other real-time data sources, 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" import { getDefaultManagers, actionTypes } from '@data-client/react'; import { Ticker } from './resources/Ticker'; @@ -105,6 +152,31 @@ 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/LogoutManager.md b/docs/core/api/LogoutManager.md index bffd4905a0c0..7fd24f7f7f3e 100644 --- a/docs/core/api/LogoutManager.md +++ b/docs/core/api/LogoutManager.md @@ -19,6 +19,8 @@ Logs out based on fetch responses. By default this is triggered by [401 (Unautho ## Usage +:::react + +::: + +:::vue + +```ts title="main.ts" +import { createApp } from 'vue'; +import { + DataClientPlugin, + LogoutManager, + getDefaultManagers, +} from '@data-client/vue'; +import App from './App.vue'; + +// highlight-next-line +const managers = [new LogoutManager(), ...getDefaultManagers()]; + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +::: + ### Custom logout handler ```ts @@ -195,6 +220,7 @@ Use [controller.invalidateAll](./Controller.md#invalidateAll) to only clear part ```ts import { unAuth } from '../authentication'; +const myDomain = 'http://test.com'; // highlight-next-line const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); @@ -232,6 +258,10 @@ protected shouldLogout(error: UnknownError) { } ``` +:::react + ## Github Example + +::: diff --git a/docs/core/api/Manager.md b/docs/core/api/Manager.md index 45843e65ba30..20134982db74 100644 --- a/docs/core/api/Manager.md +++ b/docs/core/api/Manager.md @@ -15,7 +15,7 @@ import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; # Manager -`Managers` are singletons that handle global side-effects. Kind of like [useEffect()](https://react.dev/reference/react/useEffect) for the central data +`Managers` are singletons that handle global side-effects. Kind of like :react[[useEffect()](https://react.dev/reference/react/useEffect)]:vue[[watchEffect()](https://vuejs.org/api/reactivity-core.html#watcheffect)] for the central data store. The default managers orchestrate the complex asynchronous behavior that Data Client @@ -43,11 +43,25 @@ interface Manager { ### middleware `middleware` is very similar to a [redux middleware](https://redux.js.org/advanced/middleware). -The only differences is that the `next()` function returns a `Promise`. This promise resolves when the reducer update is +The only differences is that the `next()` function returns a `Promise`. + +:::react + +This promise resolves when the reducer update is [committed](https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/#general-algorithm) when using <DataProvider /\>. This is necessary since the commit phase is asynchronously scheduled. This enables building managers that perform work after the DOM is updated and also with the newly computed state. +::: + +:::vue + +This promise resolves when the reducer update is committed to the +[DataClientPlugin](../getting-started/installation.md) store. This enables building managers that perform work with the +newly computed state. + +::: + Since redux is fully synchronous, an adapter must be placed in front of Reactive Data Client style middleware to ensure they can consume a promise. Conversely, redux middleware must be changed to pass through promises. @@ -80,6 +94,8 @@ installed once per app, so managers are created once. ::: +:::react + +::: + +:::vue + +```ts title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin, getDefaultManagers } from '@data-client/vue'; +import App from './App.vue'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +::: + ## Control flow -Managers integrate with the DataProvider store with their lifecycles and middleware. They orchestrate complex control +Managers integrate with the :react[DataProvider]:vue[DataClientPlugin] store with their lifecycles and middleware. They orchestrate complex control flows by interfacing via intercepting and dispatching [actions](./Actions.md), as well as reading the internal state. #### Note: > -> This is already used by `DataProvider` by default. +> This is already used by :react[`DataProvider`]:vue[`DataClientPlugin`] by default. diff --git a/docs/core/api/Snapshot.md b/docs/core/api/Snapshot.md index 07138eef574e..2eaf3e935714 100644 --- a/docs/core/api/Snapshot.md +++ b/docs/core/api/Snapshot.md @@ -67,6 +67,13 @@ export enum ExpiryStatus { } ``` +:::vue + +Vue components only suspend while mounting; mounted components keep showing their data while it +refetches. + +::: + ##### Valid - Will never suspend. diff --git a/docs/core/api/mockInitialState.md b/docs/core/api/mockInitialState.md index 50d77bdbc6fa..3d4250155640 100644 --- a/docs/core/api/mockInitialState.md +++ b/docs/core/api/mockInitialState.md @@ -22,23 +22,18 @@ export type Fixture = SuccessFixture | ErrorFixture; This prop specifies the [fixtures](./Fixtures.md) to use data from. Each item represents a fetch defined by the [Endpoint](/rest/api/Endpoint) and params. `Result` contains the JSON response expected from said fetch. - This can be used as the :react[initialState prop for [<DataProvider /\>](./DataProvider)]:vue[`initialState` option for [DataClientPlugin](../getting-started/installation.md)] ## Example -```typescript -import { DataProvider } from '@data-client/react'; -import { mockInitialState } from '@data-client/test'; - +```ts title="fixtures.ts" import ArticleResource from 'resources/ArticleResource'; -import MyComponentToTest from 'components/MyComponentToTest'; -const results = [ +export const results = [ { - request: ArticleResource.getList, - params: { maxResults: 10 }, - result: [ + endpoint: ArticleResource.getList, + args: [{ maxResults: 10 }], + response: [ { id: 5, content: 'have a merry christmas', @@ -54,8 +49,37 @@ const results = [ ], }, ]; +``` + +:::react + +```tsx +import { DataProvider } from '@data-client/react'; +import { mockInitialState } from '@data-client/test'; + +import MyComponentToTest from 'components/MyComponentToTest'; +import { results } from './fixtures'; ; ``` + +::: + +:::vue + +```ts +import { createApp } from 'vue'; +import { DataClientPlugin } from '@data-client/vue'; +import { mockInitialState } from '@data-client/vue/test'; + +import MyComponentToTest from 'components/MyComponentToTest.vue'; +import { results } from './fixtures'; + +const app = createApp(MyComponentToTest); +app.use(DataClientPlugin, { initialState: mockInitialState(results) }); +app.mount('#app'); +``` + +::: diff --git a/docs/core/concepts/expiry-policy.md b/docs/core/concepts/expiry-policy.md index dfe1a3a5d8a5..d2af23d1501d 100644 --- a/docs/core/concepts/expiry-policy.md +++ b/docs/core/concepts/expiry-policy.md @@ -42,8 +42,9 @@ When using React Navigation, [focus events](https://reactnavigation.org/docs/use ### Invalid -Data should not be shown. Any components needing this data will trigger fetch and suspense. If -no components care about this data no action will be taken. +Data should not be shown. Any components needing this data will trigger fetch:react[ and suspense]:vue[ +([mounted components keep their data](#invalidate) until it resolves)]. If no components care about this +data no action will be taken. ## Expiry Time