From f74ba7e17988f88434fcdbcd31b7d4a37aa7c96c Mon Sep 17 00:00:00 2001
From: root
Date: Wed, 12 Aug 2026 15:04:08 -0400
Subject: [PATCH 01/12] docs: Replace atomic mutations with ACID integrity
concept
Explain atomicity, consistency, isolation, and durability for async
frontend data, with interactive demos and a redirect from the old page.
Co-authored-by: Cursor
---
GOALS.md | 2 +-
docs/core/concepts/acid.md | 267 +++++++++++++++++++++
docs/core/concepts/atomic-mutations.md | 40 ---
docs/core/getting-started/mutations.md | 2 +-
docs/core/shared/_acidCollections.mdx | 129 ++++++++++
docs/core/shared/_acidCreate.mdx | 96 ++++++++
docs/core/shared/_acidDelete.mdx | 82 +++++++
docs/core/shared/_acidFetchOrder.mdx | 113 +++++++++
docs/core/shared/_acidIdentity.mdx | 73 ++++++
docs/core/shared/_acidQuery.mdx | 70 ++++++
docs/core/shared/_acidRest.mdx | 102 ++++++++
docs/core/shared/_acidRollback.mdx | 54 +++++
docs/core/shared/_acidSideEffects.mdx | 88 +++++++
docs/core/shared/_acidSnapshot.mdx | 83 +++++++
docs/core/shared/_acidTransports.mdx | 80 ++++++
docs/core/shared/_acidUpdate.mdx | 92 +++++++
docs/core/shared/_acidValidate.mdx | 80 ++++++
docs/rest/api/Entity.md | 2 +-
packages/core/package.json | 2 +-
packages/endpoint/package.json | 2 +-
packages/graphql/package.json | 2 +-
packages/img/package.json | 2 +-
packages/normalizr/package.json | 2 +-
packages/react/package.json | 2 +-
packages/rest/package.json | 2 +-
packages/test/package.json | 2 +-
packages/use-enhanced-reducer/package.json | 2 +-
packages/vue/package.json | 2 +-
website/docusaurus.config.ts | 9 +-
website/sidebars.json | 2 +-
website/src/components/Demo/index.tsx | 2 +-
website/src/fixtures/acid.ts | 229 ++++++++++++++++++
32 files changed, 1660 insertions(+), 57 deletions(-)
create mode 100644 docs/core/concepts/acid.md
delete mode 100644 docs/core/concepts/atomic-mutations.md
create mode 100644 docs/core/shared/_acidCollections.mdx
create mode 100644 docs/core/shared/_acidCreate.mdx
create mode 100644 docs/core/shared/_acidDelete.mdx
create mode 100644 docs/core/shared/_acidFetchOrder.mdx
create mode 100644 docs/core/shared/_acidIdentity.mdx
create mode 100644 docs/core/shared/_acidQuery.mdx
create mode 100644 docs/core/shared/_acidRest.mdx
create mode 100644 docs/core/shared/_acidRollback.mdx
create mode 100644 docs/core/shared/_acidSideEffects.mdx
create mode 100644 docs/core/shared/_acidSnapshot.mdx
create mode 100644 docs/core/shared/_acidTransports.mdx
create mode 100644 docs/core/shared/_acidUpdate.mdx
create mode 100644 docs/core/shared/_acidValidate.mdx
create mode 100644 website/src/fixtures/acid.ts
diff --git a/GOALS.md b/GOALS.md
index fd863dc91506..0c73f615855c 100644
--- a/GOALS.md
+++ b/GOALS.md
@@ -10,8 +10,8 @@
- Intuitive to agents and humans; skills and codemods for onboarding, debugging, migrations
- Data binding directly in the component that renders the data
- Strong data integrity guarantees
+ - ACID over async durable stores, plus reactivity — clear cause and effect, without losing work
- Referential stability: unchanged data keeps the same object identity everywhere
- - Atomic mutations; every view consistent without refetching
- Types and runtime never silently diverge
- Best performance in class
- Networking overhead is the most expensive - minimize this first
diff --git a/docs/core/concepts/acid.md b/docs/core/concepts/acid.md
new file mode 100644
index 000000000000..9e2eccb5ea04
--- /dev/null
+++ b/docs/core/concepts/acid.md
@@ -0,0 +1,267 @@
+---
+title: 'ACID: Integrity for frontend data'
+sidebar_label: ACID
+description: Atomic, consistent, isolated, durable async data — using the server as the store.
+---
+
+
+
+
+
+import AcidUpdate from '../shared/\_acidUpdate.mdx';
+import AcidCreate from '../shared/\_acidCreate.mdx';
+import AcidDelete from '../shared/\_acidDelete.mdx';
+import AcidRollback from '../shared/\_acidRollback.mdx';
+import AcidSideEffects from '../shared/\_acidSideEffects.mdx';
+import AcidIdentity from '../shared/\_acidIdentity.mdx';
+import AcidCollections from '../shared/\_acidCollections.mdx';
+import AcidQuery from '../shared/\_acidQuery.mdx';
+import AcidValidate from '../shared/\_acidValidate.mdx';
+import AcidTransports from '../shared/\_acidTransports.mdx';
+import AcidFetchOrder from '../shared/\_acidFetchOrder.mdx';
+import AcidSnapshot from '../shared/\_acidSnapshot.mdx';
+import AcidRest from '../shared/\_acidRest.mdx';
+
+# ACID for frontend data
+
+Users expect **clear cause and effect**: actions have consequences, and those
+consequences are obvious. Things should not appear, disappear, or change
+on their own. A user's time is valuable — don't lose their work.
+
+[Relational databases](https://en.wikipedia.org/wiki/ACID) call these guarantees
+ACID. The frontend store is that database for interactive data — but every
+durable write is [asynchronous](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous).
+
+Reactive Data Client applies the same guarantees so every view agrees without
+[refetching](../api/Controller.md#expireAll), mutations don't flash torn state, and
+crashes don't lose data that reached a durable store like a REST server or
+[IndexedDB](./managers.md#persistence).
+
+[Normalization](./normalization.md) is what makes this possible.
+
+## Atomicity
+
+A mutation is a single unit: it succeeds completely or fails completely.
+Other components never observe it halfway. That prevents *temporal data
+tearing* — flashes of inconsistent state as usages update one by one.
+
+### Update
+
+[Resource.update](/rest/api/resource#update) and
+[Resource.partialUpdate](/rest/api/resource#partialupdate) merge the response
+into the one copy of that entity. Every consumer of that [pk](/rest/api/Entity#pk)
+updates together. [Read more about defining other update endpoints](/rest/guides/side-effects).
+
+Toggle a todo. Both lists update at once — no flash of one list lagging.
+
+
+
+### Create
+
+Created entities are immediately available. They are added to existing
+[Collections](/rest/api/Collection) with
+[.push](/rest/api/RestEndpoint#push),
+[.unshift](/rest/api/RestEndpoint#unshift), or
+[.assign](/rest/api/RestEndpoint#assign).
+
+Add a todo. It appears in both lists together — never invisible, never an
+orphan, never a list hole.
+
+
+
+### Delete
+
+[schema.Invalidate](/rest/api/Invalidate) removes the entity.
+[Resource.delete](/rest/api/resource#delete) provides such an endpoint.
+
+Delete a todo. It disappears from both lists in the same commit.
+
+
+
+### Rollback
+
+Optimistic updates apply as that same snapshot. If the network fails, they
+roll back as that snapshot.
+
+Click add. The todo appears immediately, then vanishes when the server errors.
+
+
+
+### Side effects
+
+When a mutation changes more than one resource, include every changed entity
+in the response. That is one commit. [Invalidating](../api/Controller.md#expireAll)
+and refetching the others can fail partway — a flash of torn state.
+
+[See mutation side-effects](/rest/guides/side-effects) for the full pattern.
+
+Add a todo. The list and the user's count update together.
+
+
+
+## Consistency
+
+A write takes the store from one valid state to another. Invariants hold:
+one copy of each entity, relationships join, invalid data is rejected.
+That prevents *data tearing* — the same todo showing two different values.
+
+### Identity
+
+[Entity.pk()](/rest/api/Entity#pk) is the unique index. The same todo from
+[getList](/rest/api/resource#getlist) and [get](/rest/api/resource#get) is the
+**same object** — the same value, wherever it is embedded.
+
+Select a todo, then toggle it. `fromList === get` stays true.
+
+
+
+### Collections
+
+When [Collection.argsKey](/rest/api/Collection#argskey) and
+[Collection.nestKey](/rest/api/Collection#nestkey) return the same shape, a nested
+list and a top-level list are the **same array**.
+
+Toggle a todo. `user.todos === getList` stays true, and both columns update.
+
+
+
+### Query
+
+[Query](/rest/api/Query) derived values stay consistent for the same reason —
+they read the entity table, not a copy.
+
+Toggle todos. The remaining count updates without refetching.
+
+
+
+### Validation
+
+[Entity.validate()](./validation.md) is the check constraint. Invalid responses
+are not committed.
+
+Switch between payloads. Only the valid article renders.
+
+
+
+### Transports
+
+The same entity is the same value whether it arrived from fetch, initial
+load, [Controller.set()](../api/Controller.md#set), or a
+[websocket](./managers.md#data-stream).
+
+Click simulate websocket. Both lists update — no copy left behind.
+
+
+
+## Isolation
+
+Concurrent work leaves the store as if it ran in sequence. A slower
+response cannot confuse a newer local edit.
+
+### Fetch order
+
+Overlapping fetches complete in any order. Reactive Data Client pairs each
+[optimistic update](/rest/guides/optimistic-updates) with its own request and
+commits in [fetchedAt](/docs/api/Snapshot#fetchedat) order. A late response cannot
+clobber a newer commit.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Client
+ participant Server
+ Client->>+Server: Increment from 0
+ Client->>+Server: Increment from 1
+ Server->>-Client: Response: 2
+ Server->>-Client: Response: 1
+```
+
+With other libraries this would show 0, then 2, then 1. Reactive Data Client
+keeps 0, 1, 2.
+
+Click increment several times quickly.
+
+
+
+[Optimistic updates](/rest/guides/optimistic-updates) amplify these races;
+Reactive Data Client handles them automatically.
+
+### Snapshots
+
+All hooks in one render read the same snapshot, so the tree never paints mixed
+old and new values.
+
+Toggle a todo. `list` and `query` in that row always agree.
+
+
+
+## Durability
+
+Once work is committed, it stays committed through a crash or a closed
+tab. Storing in memory is not enough — mutations must reach an async API.
+Later retrievals reflect those updates.
+
+### REST
+
+`ctrl.fetch` is the commit path. Saving as you go (a toggle, an inline
+edit) commits to the server. Use a form when the friction is the point —
+publish, purchase.
+
+Toggle some todos, then simulate a crash. Data Client refetches from the
+server and the work is still there. The local-only note is gone.
+
+
+
+In-flight optimistic updates are not the durable commit — the `fetch` is.
+
+### IndexedDB
+
+A [persist Manager](./managers.md#persistence) can replicate confirmed state to
+[IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
+for offline reloads. Restore it with
+[DataProvider's initialState](../api/DataProvider.md#initialState). Drop
+in-flight optimistic updates — they are not cloneable, and they are not the ack.
+
+```typescript
+import type { Manager, Middleware } from '@data-client/react';
+import { set } from 'idb-keyval';
+
+export default class PersistManager implements Manager {
+ declare protected timer?: ReturnType;
+
+ middleware: Middleware = controller => next => async action => {
+ await next(action);
+ clearTimeout(this.timer);
+ this.timer = setTimeout(() => {
+ const state = { ...controller.getState(), optimistic: [] };
+ set('data-client', state);
+ }, 1000);
+ };
+
+ cleanup() {
+ clearTimeout(this.timer);
+ }
+}
+```
+
+```tsx
+import { get } from 'idb-keyval';
+
+const initialState = await get('data-client');
+
+createRoot(document.body).render(
+
+
+ ,
+);
+```
+
+:::info[Reactivity]
+
+ACID makes writes trustworthy. [useLive()](../api/useLive.md),
+[polling](/rest/api/Endpoint#pollfrequency), and
+[push](./managers.md#data-stream) keep the UI a live function of the store.
+Reactivity is how you watch the durable store; it is not a substitute for
+reaching it.
+
+:::
diff --git a/docs/core/concepts/atomic-mutations.md b/docs/core/concepts/atomic-mutations.md
deleted file mode 100644
index a2b3b26c22f0..000000000000
--- a/docs/core/concepts/atomic-mutations.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-title: '⚛ Atomic Mutations: Safe, high performance async mutations'
-sidebar_label: Atomic Mutations
----
-
-
-
-
-
-# Safety beyond types
-
-When a user causes mutations like creating, updating, or deleting resources, it's important
-to have those changed be reflected in the application. A simple publish cache
-that has no underlying knowledge of the data structures would require a refetch of any endpoints
-that are changed. This would reduce performance and put extra burden on the backend.
-
-However, like many other cases, a normalized cache - one with underlying knowledge of the relationships
-between resources - is capable of keeping all data consistent and fresh without
-any refetches.
-
-## Update
-
-Reactive Data Client uses your schema definitions to understand how to normalize response data into
-an `entity table` and `result table`. Of course, this means that there is only ever one copy
-of a given `entity`. Aside from providing consistency when using different response endpoints,
-this means that by providing an accurate schema definition, Reactive Data Client can automatically keep
-all data uses consistent and fresh. The default update endpoints [Resource.update](/rest/api/resource#update) and
-[Resource.partialUpdate](/rest/api/resource#partialupdate) both do this automatically. [Read more about defining other
-update endpoints](/rest/guides/side-effects)
-
-## Delete
-
-Reactive Data Client automatically deletes entity entries [schema.Invalidate](/rest/api/Invalidate) is used.
-[Resource.delete](/rest/api/resource#delete)
-provides such an endpoint.
-
-## Create
-
-Created entities are immediately available. They can also be added to existing [Collections](/rest/api/Collection)
-with [.push](/rest/api/RestEndpoint#push), [.unshift](/rest/api/RestEndpoint#unshift), or [.assign](/rest/api/RestEndpoint#assign).
\ No newline at end of file
diff --git a/docs/core/getting-started/mutations.md b/docs/core/getting-started/mutations.md
index 44f881adfc18..301db56a06b8 100644
--- a/docs/core/getting-started/mutations.md
+++ b/docs/core/getting-started/mutations.md
@@ -18,7 +18,7 @@ import VoteDemo from '../shared/\_VoteDemo.mdx';
# Data mutations
-Using our [Create, Update, and Delete](/docs/concepts/atomic-mutations) endpoints with
+Using our [Create, Update, and Delete](/docs/concepts/acid) endpoints with
[Controller.fetch()](../api/Controller.md#fetch) reactively updates _all_ appropriate components atomically (at the same time).
[useController()](../api/useController.md) gives components access to this global supercharged [setState()](https://react.dev/reference/react/useState#setstate).
diff --git a/docs/core/shared/_acidCollections.mdx b/docs/core/shared/_acidCollections.mdx
new file mode 100644
index 000000000000..8e8a51089d29
--- /dev/null
+++ b/docs/core/shared/_acidCollections.mdx
@@ -0,0 +1,129 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidConsistencyFixtures,
+ getAcidConsistencyData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="api/Todo" {12-15} collapsed
+import { Entity, RestEndpoint, Collection } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+
+export const userTodos = new Collection([Todo], {
+ argsKey: ({ userId }: { userId?: string }) => ({ userId }),
+ nestKey: (parent: { id: string }) => ({ userId: parent.id }),
+});
+
+export const getTodos = new RestEndpoint({
+ path: '/todos',
+ searchParams: {} as { userId?: string },
+ schema: userTodos,
+});
+
+export const updateTodo = new RestEndpoint({
+ path: '/todos/:id',
+ method: 'PATCH',
+ schema: Todo,
+ getOptimisticResponse(snap, { id }, body) {
+ const cur = snap.get(Todo, { id });
+ if (!cur) throw snap.abort;
+ return { ...cur, ...body };
+ },
+});
+```
+
+```ts title="api/User" collapsed
+import { Entity, RestEndpoint } from '@data-client/rest';
+import { Todo, userTodos } from './api/Todo';
+
+export class User extends Entity {
+ id = '';
+ name = '';
+ todos: Todo[] = [];
+
+ static key = 'User';
+ static schema = {
+ todos: userTodos,
+ };
+}
+
+export const getUser = new RestEndpoint({
+ path: '/users/:id',
+ schema: User,
+});
+```
+
+```tsx title="TodoList" {8}
+import { useController, useSuspense } from '@data-client/react';
+import { getTodos, updateTodo } from './api/Todo';
+import { getUser } from './api/User';
+
+function TodoList() {
+ const ctrl = useController();
+ const user = useSuspense(getUser, { id: '1' });
+ const todos = useSuspense(getTodos, { userId: '1' });
+ const handleChange = (todo, e) =>
+ ctrl.fetch(
+ updateTodo,
+ { id: todo.id },
+ { completed: e.currentTarget.checked },
+ );
+ return (
+
+
+ user.todos === getList: {String(user.todos === todos)}
+
+
+
+
user.todos
+ {user.todos.map(todo => (
+
+
+ handleChange(todo, e)}
+ />
+ {todo.title}
+
+
+ ))}
+
+
+
getList
+ {todos.map(todo => (
+
+
+ handleChange(todo, e)}
+ />
+ {todo.title}
+
+
+ ))}
+
+
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidCreate.mdx b/docs/core/shared/_acidCreate.mdx
new file mode 100644
index 000000000000..eade292d37d9
--- /dev/null
+++ b/docs/core/shared/_acidCreate.mdx
@@ -0,0 +1,96 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidTodoFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+```
+
+```tsx title="CreateTodo" collapsed
+import { useController } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+
+export default function CreateTodo({ userId }: { userId: string }) {
+ const ctrl = useController();
+ const handleKeyDown = e => {
+ if (e.key === 'Enter') {
+ ctrl.fetch(TodoResource.getList.push, {
+ userId,
+ title: e.currentTarget.value,
+ });
+ e.currentTarget.value = '';
+ }
+ };
+ return (
+
+
+
+
+
+
+
+ );
+}
+```
+
+```tsx title="TodoList" collapsed
+import { useSuspense } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+import CreateTodo from './CreateTodo';
+
+export default function TodoList() {
+ const userId = '1';
+ const todos = useSuspense(TodoResource.getList, { userId });
+ return (
+
+ {todos.map(todo => (
+
+ {todo.title}
+
+ ))}
+
+
+ );
+}
+```
+
+```tsx title="TodoPage"
+import TodoList from './TodoList';
+
+function TodoPage() {
+ return (
+
+
+
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidDelete.mdx b/docs/core/shared/_acidDelete.mdx
new file mode 100644
index 000000000000..7e266670da1c
--- /dev/null
+++ b/docs/core/shared/_acidDelete.mdx
@@ -0,0 +1,82 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidTodoFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+```
+
+```tsx title="TodoItem" collapsed
+import { useController } from '@data-client/react';
+import { TodoResource, type Todo } from './TodoResource';
+
+export default function TodoItem({ todo }: { todo: Todo }) {
+ const ctrl = useController();
+ const handleDelete = () =>
+ ctrl.fetch(TodoResource.delete, { id: todo.id });
+ return (
+
+ {todo.title}
+
+
+ );
+}
+```
+
+```tsx title="TodoList" collapsed
+import { useSuspense } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+import TodoItem from './TodoItem';
+
+export default function TodoList() {
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ return (
+
+ {todos.map(todo => (
+
+ ))}
+
+ );
+}
+```
+
+```tsx title="TodoPage"
+import TodoList from './TodoList';
+
+function TodoPage() {
+ return (
+
+
+
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidFetchOrder.mdx b/docs/core/shared/_acidFetchOrder.mdx
new file mode 100644
index 000000000000..e168042ca34a
--- /dev/null
+++ b/docs/core/shared/_acidFetchOrder.mdx
@@ -0,0 +1,113 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import { RestEndpoint } from '@data-client/rest';
+
+ 500 + Math.random() * 4500,
+}
+]}
+getInitialInterceptorData={() => ({count: 0})}
+row
+>
+
+```ts title="count" collapsed
+export class CountEntity extends Entity {
+ count = 0;
+
+ pk() {
+ return `SINGLETON`;
+ }
+}
+export const getCount = new RestEndpoint({
+ path: '/api/count',
+ schema: CountEntity,
+ name: 'get',
+});
+```
+
+```ts title="increment" {9-15}
+import { CountEntity, getCount } from './count';
+
+export const increment = new RestEndpoint({
+ path: '/api/count/increment',
+ method: 'POST',
+ body: undefined,
+ name: 'increment',
+ schema: CountEntity,
+ getOptimisticResponse(snap) {
+ const data = snap.get(CountEntity, {});
+ if (!data) throw snap.abort;
+ return {
+ count: data.count + 1,
+ };
+ },
+});
+```
+
+```tsx title="CounterPage" collapsed
+import { useLoading } from '@data-client/react';
+import { getCount } from './count';
+import { increment } from './increment';
+
+function CounterPage() {
+ const ctrl = useController();
+ const { count } = useSuspense(getCount);
+ const [stateCount, setStateCount] = React.useState(0);
+ const [responseCount, setResponseCount] = React.useState(0);
+ const [clickHandler, loading, error] = useLoading(async () => {
+ setStateCount(stateCount + 1);
+ const val = await ctrl.fetch(increment);
+ setResponseCount(val.count);
+ setStateCount(val.count);
+ });
+ return (
+
+
+ Click the button multiple times quickly to trigger the race
+ condition
+
+
+
+
+ Optimistic
+ Normal
+
+
+
+ Data Client :
+ {count}
+
+
+
+ Other :
+ {stateCount}
+ {responseCount}
+
+
+
+
+
+
+
{loading ? ' ...loading' : ''}
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidIdentity.mdx b/docs/core/shared/_acidIdentity.mdx
new file mode 100644
index 000000000000..69d505bee991
--- /dev/null
+++ b/docs/core/shared/_acidIdentity.mdx
@@ -0,0 +1,73 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidTodoFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+```
+
+```tsx title="TodoPage" {8-10}
+import { useController, useSuspense } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+
+function TodoPage() {
+ const ctrl = useController();
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ const [id, setId] = React.useState(todos[0].id);
+ const todo = useSuspense(TodoResource.get, { id });
+ const fromList = todos.find(item => item.id === id);
+ const handleChange = e =>
+ ctrl.fetch(
+ TodoResource.partialUpdate,
+ { id },
+ { completed: e.currentTarget.checked },
+ );
+ return (
+
+
+ fromList === get: {String(fromList === todo)}
+
+ {todos.map(item => (
+
setId(item.id)}
+ >
+ {item.id === id ? {item.title} : item.title}
+
+ ))}
+
+
+ {todo.title}
+
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidQuery.mdx b/docs/core/shared/_acidQuery.mdx
new file mode 100644
index 000000000000..6ddcc9b58f60
--- /dev/null
+++ b/docs/core/shared/_acidQuery.mdx
@@ -0,0 +1,70 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidTodoFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource, Query } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+
+export const remainingTodos = new Query(
+ TodoResource.getList.schema,
+ entries => entries.filter(todo => !todo.completed).length,
+);
+```
+
+```tsx title="TodoList" {7}
+import { useController, useQuery, useSuspense } from '@data-client/react';
+import { remainingTodos, TodoResource } from './TodoResource';
+
+function TodoList() {
+ const ctrl = useController();
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ const remaining = useQuery(remainingTodos, { userId: '1' });
+ const handleChange = (todo, e) =>
+ ctrl.fetch(
+ TodoResource.partialUpdate,
+ { id: todo.id },
+ { completed: e.currentTarget.checked },
+ );
+ return (
+
+
+ {remaining} remaining
+
+ {todos.map(todo => (
+
+
+ handleChange(todo, e)}
+ />
+ {todo.completed ? {todo.title} : todo.title}
+
+
+ ))}
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidRest.mdx b/docs/core/shared/_acidRest.mdx
new file mode 100644
index 000000000000..b763ba784fc8
--- /dev/null
+++ b/docs/core/shared/_acidRest.mdx
@@ -0,0 +1,102 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidTodoFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+```
+
+```tsx title="TodoItem" collapsed
+import { useController } from '@data-client/react';
+import { TodoResource, type Todo } from './TodoResource';
+
+export default function TodoItem({ todo }: { todo: Todo }) {
+ const ctrl = useController();
+ const handleChange = e =>
+ ctrl.fetch(
+ TodoResource.partialUpdate,
+ { id: todo.id },
+ { completed: e.currentTarget.checked },
+ );
+ return (
+
+
+
+ {todo.completed ? {todo.title} : todo.title}
+
+
+ );
+}
+```
+
+```tsx title="Session" collapsed
+import { useSuspense } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+import TodoItem from './TodoItem';
+
+export default function Session() {
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ const [note, setNote] = React.useState('');
+ return (
+
+ setNote(e.currentTarget.value)}
+ />
+ {todos.map(todo => (
+
+ ))}
+
+ );
+}
+```
+
+```tsx title="App"
+import { useController } from '@data-client/react';
+import Session from './Session';
+
+function App() {
+ const ctrl = useController();
+ const [session, setSession] = React.useState(0);
+ const handleCrash = async () => {
+ await ctrl.resetEntireStore();
+ setSession(s => s + 1);
+ };
+ return (
+
+
Simulate crash
+
}>
+
+
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidRollback.mdx b/docs/core/shared/_acidRollback.mdx
new file mode 100644
index 000000000000..47c12d10beb2
--- /dev/null
+++ b/docs/core/shared/_acidRollback.mdx
@@ -0,0 +1,54 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidRollbackFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+```
+
+```tsx title="TodoList"
+import { useController, useSuspense } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+
+function TodoList() {
+ const ctrl = useController();
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ const handleAdd = () =>
+ ctrl.fetch(TodoResource.getList.push, {
+ userId: '1',
+ title: 'New todo',
+ });
+ return (
+
+ {todos.map(todo => (
+
+ {todo.title}
+
+ ))}
+
Add todo
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidSideEffects.mdx b/docs/core/shared/_acidSideEffects.mdx
new file mode 100644
index 000000000000..87985df9de33
--- /dev/null
+++ b/docs/core/shared/_acidSideEffects.mdx
@@ -0,0 +1,88 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidSideEffectFixtures,
+ getAcidSideEffectData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="api/Todo" collapsed
+import { Entity, RestEndpoint, Collection } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+
+export const userTodos = new Collection([Todo], {
+ argsKey: ({ userId }: { userId?: string }) => ({ userId }),
+});
+
+export const getTodos = new RestEndpoint({
+ path: '/todos',
+ searchParams: {} as { userId?: string },
+ schema: userTodos,
+});
+```
+
+```ts title="api/User" {18-21}
+import { Entity, RestEndpoint } from '@data-client/rest';
+import { getTodos } from './api/Todo';
+
+export class User extends Entity {
+ id = '';
+ name = '';
+ todoCount = 0;
+
+ static key = 'User';
+}
+
+export const getUser = new RestEndpoint({
+ path: '/users/:id',
+ schema: User,
+});
+
+export const createTodo = getTodos.push.extend({
+ schema: {
+ todo: getTodos.push.schema,
+ user: User,
+ },
+});
+```
+
+```tsx title="TodoPage"
+import { useController, useSuspense } from '@data-client/react';
+import { getTodos } from './api/Todo';
+import { createTodo, getUser } from './api/User';
+
+function TodoPage() {
+ const ctrl = useController();
+ const user = useSuspense(getUser, { id: '1' });
+ const todos = useSuspense(getTodos, { userId: '1' });
+ const handleAdd = () =>
+ ctrl.fetch(createTodo, {
+ userId: '1',
+ title: 'New todo',
+ });
+ return (
+
+
+ {user.name} has {user.todoCount} todos
+
+ {todos.map(todo => (
+
+ {todo.title}
+
+ ))}
+
Add todo
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidSnapshot.mdx b/docs/core/shared/_acidSnapshot.mdx
new file mode 100644
index 000000000000..988a8dd1dfe0
--- /dev/null
+++ b/docs/core/shared/_acidSnapshot.mdx
@@ -0,0 +1,83 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidTodoFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+```
+
+```tsx title="TodoItem" {8-9,20}
+import { useController, useQuery, useSuspense } from '@data-client/react';
+import { Todo, TodoResource } from './TodoResource';
+
+export default function TodoItem({ id }: { id: string }) {
+ const ctrl = useController();
+ const fromList = useSuspense(TodoResource.getList, {
+ userId: '1',
+ }).find(todo => todo.id === id);
+ const fromQuery = useQuery(Todo, { id });
+ if (!fromList) return null;
+ const handleChange = e =>
+ ctrl.fetch(
+ TodoResource.partialUpdate,
+ { id },
+ { completed: e.currentTarget.checked },
+ );
+ return (
+
+
+
+ {fromList.title}
+
+
+ list={String(fromList.completed)} query=
+ {String(fromQuery?.completed)} same render=
+ {String(fromList.completed === fromQuery?.completed)}
+
+
+ );
+}
+```
+
+```tsx title="TodoList" collapsed
+import { useSuspense } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+import TodoItem from './TodoItem';
+
+function TodoList() {
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ return (
+
+ {todos.map(todo => (
+
+ ))}
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidTransports.mdx b/docs/core/shared/_acidTransports.mdx
new file mode 100644
index 000000000000..0939a9a1f45f
--- /dev/null
+++ b/docs/core/shared/_acidTransports.mdx
@@ -0,0 +1,80 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidTodoFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+```
+
+```tsx title="TodoList" collapsed
+import { useSuspense } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+
+export default function TodoList() {
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ return (
+
+ {todos.map(todo => (
+
+ {todo.title}
+
+ ))}
+
+ );
+}
+```
+
+```tsx title="TodoPage"
+import { useController, useSuspense } from '@data-client/react';
+import { Todo, TodoResource } from './TodoResource';
+import TodoList from './TodoList';
+
+function TodoPage() {
+ const ctrl = useController();
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ const handlePush = () => {
+ const todo = todos[0];
+ ctrl.set(Todo, { id: todo.id }, current => ({
+ ...current,
+ title: `${current.title} (pushed)`,
+ }));
+ };
+ return (
+
+
Simulate websocket
+
+
+
+
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidUpdate.mdx b/docs/core/shared/_acidUpdate.mdx
new file mode 100644
index 000000000000..153156e57d1b
--- /dev/null
+++ b/docs/core/shared/_acidUpdate.mdx
@@ -0,0 +1,92 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import {
+ acidTodoFixtures,
+ getAcidTodoData,
+} from '@site/src/fixtures/acid';
+
+
+
+```ts title="TodoResource" collapsed
+import { Entity, resource } from '@data-client/rest';
+
+export class Todo extends Entity {
+ id = '';
+ userId = '';
+ title = '';
+ completed = false;
+
+ static key = 'Todo';
+}
+export const TodoResource = resource({
+ path: '/todos/:id',
+ searchParams: {} as { userId?: string | number } | undefined,
+ schema: Todo,
+ optimistic: true,
+});
+```
+
+```tsx title="TodoItem" collapsed
+import { useController } from '@data-client/react';
+import { TodoResource, type Todo } from './TodoResource';
+
+export default function TodoItem({ todo }: { todo: Todo }) {
+ const ctrl = useController();
+ const handleChange = e =>
+ ctrl.fetch(
+ TodoResource.partialUpdate,
+ { id: todo.id },
+ { completed: e.currentTarget.checked },
+ );
+ return (
+
+
+
+ {todo.completed ? {todo.title} : todo.title}
+
+
+ );
+}
+```
+
+```tsx title="TodoList" collapsed
+import { useSuspense } from '@data-client/react';
+import { TodoResource } from './TodoResource';
+import TodoItem from './TodoItem';
+
+export default function TodoList() {
+ const todos = useSuspense(TodoResource.getList, { userId: '1' });
+ return (
+
+ {todos.map(todo => (
+
+ ))}
+
+ );
+}
+```
+
+```tsx title="TodoPage"
+import TodoList from './TodoList';
+
+function TodoPage() {
+ return (
+
+
+
+
+ );
+}
+render( );
+```
+
+
diff --git a/docs/core/shared/_acidValidate.mdx b/docs/core/shared/_acidValidate.mdx
new file mode 100644
index 000000000000..71eb596d4213
--- /dev/null
+++ b/docs/core/shared/_acidValidate.mdx
@@ -0,0 +1,80 @@
+import HooksPlayground from '@site/src/components/HooksPlayground';
+import { RestEndpoint } from '@data-client/rest';
+
+
+
+```ts title="api/Article" {7-10}
+export class Article extends Entity {
+ id = '';
+ title = '';
+
+ static validate(processedEntity) {
+ if (!Object.hasOwn(processedEntity, 'title')) return 'missing title field';
+ if (typeof processedEntity.title !== 'string') return 'title is wrong type';
+ }
+}
+
+export const getArticle = new RestEndpoint({
+ path: '/article/:id',
+ schema: Article,
+});
+```
+
+```tsx title="ArticlePage" collapsed
+import { getArticle } from './api/Article';
+
+export default function ArticlePage({ id }: { id: string }) {
+ const article = useSuspense(getArticle, { id });
+ return {article.title}
;
+}
+```
+
+```tsx title="Navigator"
+import ArticlePage from './ArticlePage';
+
+function Navigator() {
+ const [id, setId] = React.useState('1');
+ return (
+
+
setId(e.currentTarget.value)}>
+ Valid
+
+
setId(e.currentTarget.value)}>
+ Missing title
+
+
setId(e.currentTarget.value)}>
+ Wrong type
+
+
}>
+
+
+
+ );
+}
+render(
+
+
+ ,
+);
+```
+
+
diff --git a/docs/rest/api/Entity.md b/docs/rest/api/Entity.md
index 7797775da183..3f2685d990ca 100644
--- a/docs/rest/api/Entity.md
+++ b/docs/rest/api/Entity.md
@@ -32,7 +32,7 @@ import TypeScriptEditor from '@site/src/components/TypeScriptEditor';
`Entity` defines a single _unique_ object.
[Entity.key](#key) + [Entity.pk()](#pk) (primary key) enable a [flat lookup table](https://react.dev/learn/choosing-the-state-structure#principles-for-structuring-state) store, enabling high
-performance, data consistency and atomic mutations.
+performance and [ACID](/docs/concepts/acid) integrity.
`Entities` enable customizing the data processing lifecycle by defining its static members like [schema](#schema)
and overriding its [lifecycle methods](#lifecycle).
diff --git a/packages/core/package.json b/packages/core/package.json
index 9357a83e9d50..8bf63eb44bff 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -127,7 +127,7 @@
"homepage": "https://dataclient.io",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/core"
},
"bugs": {
diff --git a/packages/endpoint/package.json b/packages/endpoint/package.json
index a89034f59265..9c8a8269cd28 100644
--- a/packages/endpoint/package.json
+++ b/packages/endpoint/package.json
@@ -127,7 +127,7 @@
"license": "Apache-2.0",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/endpoint"
},
"bugs": {
diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index 35e659c7061c..d54c9fcb7193 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -5,7 +5,7 @@
"homepage": "https://dataclient.io/docs/graphql",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/graphql"
},
"bugs": {
diff --git a/packages/img/package.json b/packages/img/package.json
index 3598bbfda8f9..6dfb4df261c2 100644
--- a/packages/img/package.json
+++ b/packages/img/package.json
@@ -5,7 +5,7 @@
"homepage": "https://dataclient.io/docs/guides/img-media#just-images",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/img"
},
"bugs": {
diff --git a/packages/normalizr/package.json b/packages/normalizr/package.json
index f5994b0036d7..e25c1fff654e 100644
--- a/packages/normalizr/package.json
+++ b/packages/normalizr/package.json
@@ -116,7 +116,7 @@
"license": "MIT",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/normalizr"
},
"bugs": {
diff --git a/packages/react/package.json b/packages/react/package.json
index d6a7bce90986..7a99917a1a76 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -5,7 +5,7 @@
"homepage": "https://dataclient.io",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/react"
},
"bugs": {
diff --git a/packages/rest/package.json b/packages/rest/package.json
index 172db4399918..8752b71d9fe9 100644
--- a/packages/rest/package.json
+++ b/packages/rest/package.json
@@ -5,7 +5,7 @@
"homepage": "https://dataclient.io/rest",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/rest"
},
"bugs": {
diff --git a/packages/test/package.json b/packages/test/package.json
index 2cf3c69d30e6..3010470ee6d1 100644
--- a/packages/test/package.json
+++ b/packages/test/package.json
@@ -5,7 +5,7 @@
"homepage": "https://dataclient.io/docs/guides/storybook",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/test"
},
"bugs": {
diff --git a/packages/use-enhanced-reducer/package.json b/packages/use-enhanced-reducer/package.json
index 62a59ae855f2..af48d49f38a0 100644
--- a/packages/use-enhanced-reducer/package.json
+++ b/packages/use-enhanced-reducer/package.json
@@ -5,7 +5,7 @@
"homepage": "https://github.com/reactive/data-client/tree/master/packages/use-enhanced-reducer#readme",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/use-enhanced-reducer"
},
"bugs": {
diff --git a/packages/vue/package.json b/packages/vue/package.json
index 086681ba4fd0..0b6e2ff73e54 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -5,7 +5,7 @@
"homepage": "https://dataclient.io",
"repository": {
"type": "git",
- "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "url": "git+ssh://git@github.com/reactive/data-client.git",
"directory": "packages/vue"
},
"bugs": {
diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts
index ce617108a02d..695b40b6a2e3 100644
--- a/website/docusaurus.config.ts
+++ b/website/docusaurus.config.ts
@@ -1,3 +1,4 @@
+import type * as DocsPlugin from '@docusaurus/plugin-content-docs';
import type * as Preset from '@docusaurus/preset-classic';
import type * as PresetMermaid from '@docusaurus/theme-mermaid';
import type { Config } from '@docusaurus/types';
@@ -270,7 +271,7 @@ const config: Config = {
/*onlyIncludeVersions: isDev
? ['current', ...versionsRest.slice(0, 4)]
: ['current', ...versionsRest],*/
- },
+ } satisfies DocsPlugin.Options,
],
[
'@docusaurus/plugin-content-docs',
@@ -298,7 +299,7 @@ const config: Config = {
/*onlyIncludeVersions: isDev
? ['current', ...versionsRest.slice(0, 4)]
: ['current', ...versionsRest],*/
- },
+ } satisfies DocsPlugin.Options,
],
[
'@docusaurus/plugin-client-redirects',
@@ -329,6 +330,10 @@ const config: Config = {
to: '/rest/guides/partial-entities',
from: ['/rest/guides/summary-list'],
},
+ {
+ to: '/docs/concepts/acid',
+ from: ['/docs/concepts/atomic-mutations'],
+ },
{
to: '/docs/getting-started/resource',
from: ['/docs/getting-started/endpoint'],
diff --git a/website/sidebars.json b/website/sidebars.json
index 973399303366..bdad7cae2f60 100644
--- a/website/sidebars.json
+++ b/website/sidebars.json
@@ -62,7 +62,7 @@
},
{
"type": "doc",
- "id": "concepts/atomic-mutations"
+ "id": "concepts/acid"
},
{
"type": "doc",
diff --git a/website/src/components/Demo/index.tsx b/website/src/components/Demo/index.tsx
index 727131fc6b3d..f48b0e7c8966 100644
--- a/website/src/components/Demo/index.tsx
+++ b/website/src/components/Demo/index.tsx
@@ -23,7 +23,7 @@ export default function Demo() {
This updates all usages{' '}
-
+
atomically and immediately
{' '}
with zero additional fetches. Reactive Data Client automatically
diff --git a/website/src/fixtures/acid.ts b/website/src/fixtures/acid.ts
new file mode 100644
index 000000000000..80d2617b9b08
--- /dev/null
+++ b/website/src/fixtures/acid.ts
@@ -0,0 +1,229 @@
+import { RestEndpoint } from '@data-client/rest';
+import type { Interceptor } from '@data-client/test';
+import { v4 as uuid } from 'uuid';
+
+export type AcidTodo = {
+ id: string;
+ userId: string;
+ title: string;
+ completed: boolean;
+};
+
+export type AcidTodoState = {
+ todos: AcidTodo[];
+};
+
+export type AcidUser = {
+ id: string;
+ name: string;
+};
+
+export type AcidConsistencyState = AcidTodoState & {
+ users: AcidUser[];
+};
+
+const getTodoList = new RestEndpoint({
+ path: '/todos',
+ searchParams: {} as { userId?: string | number } | undefined,
+});
+const getTodo = new RestEndpoint({
+ path: '/todos/:id',
+});
+const partialUpdateTodo = new RestEndpoint({
+ path: '/todos/:id',
+ method: 'PATCH',
+});
+const createTodo = new RestEndpoint({
+ path: '/todos',
+ method: 'POST',
+});
+const deleteTodo = new RestEndpoint({
+ path: '/todos/:id',
+ method: 'DELETE',
+});
+const getUser = new RestEndpoint({
+ path: '/users/:id',
+});
+
+export function getAcidTodoData(): AcidTodoState {
+ return {
+ todos: [
+ {
+ id: '1',
+ userId: '1',
+ title: 'Write tests',
+ completed: false,
+ },
+ {
+ id: '2',
+ userId: '1',
+ title: 'Ship it',
+ completed: false,
+ },
+ {
+ id: '3',
+ userId: '1',
+ title: 'Take a break',
+ completed: true,
+ },
+ ],
+ };
+}
+
+export function getAcidConsistencyData(): AcidConsistencyState {
+ return {
+ users: [{ id: '1', name: 'Bob' }],
+ todos: getAcidTodoData().todos,
+ };
+}
+
+export type AcidSideEffectState = AcidTodoState & {
+ users: (AcidUser & { todoCount: number })[];
+};
+
+export function getAcidSideEffectData(): AcidSideEffectState {
+ const todos = getAcidTodoData().todos;
+ return {
+ todos,
+ users: [
+ {
+ id: '1',
+ name: 'Bob',
+ todoCount: todos.filter(todo => todo.userId === '1').length,
+ },
+ ],
+ };
+}
+
+export const acidTodoFixtures: Interceptor[] = [
+ {
+ endpoint: getTodoList,
+ response(params) {
+ if (params?.userId != null) {
+ return this.todos.filter(todo => todo.userId == params.userId);
+ }
+ return this.todos;
+ },
+ delay: 150,
+ },
+ {
+ endpoint: getTodo,
+ response({ id }) {
+ return this.todos.find(todo => todo.id == id);
+ },
+ delay: 150,
+ },
+ {
+ endpoint: partialUpdateTodo,
+ response({ id }, body) {
+ const todo = this.todos.find(item => item.id == id);
+ if (!todo) return { id, ...body };
+ Object.assign(todo, body);
+ return { ...todo };
+ },
+ delay: 150,
+ },
+ {
+ endpoint: createTodo,
+ response(body) {
+ const todo = {
+ completed: false,
+ ...body,
+ id: uuid(),
+ };
+ this.todos.push(todo);
+ return todo;
+ },
+ delay: 150,
+ },
+ {
+ endpoint: deleteTodo,
+ response({ id }) {
+ this.todos = this.todos.filter(todo => todo.id != id);
+ return { id };
+ },
+ delay: 150,
+ },
+];
+
+export const acidConsistencyFixtures: Interceptor[] = [
+ ...acidTodoFixtures,
+ {
+ endpoint: getUser,
+ response({ id }) {
+ const user = this.users.find(item => item.id == id);
+ if (!user) return { id, todos: [] };
+ return {
+ ...user,
+ todos: this.todos.filter(todo => todo.userId == id),
+ };
+ },
+ delay: 150,
+ },
+];
+
+export const acidRollbackFixtures: Interceptor[] = [
+ {
+ endpoint: getTodoList,
+ response(params) {
+ if (params?.userId != null) {
+ return this.todos.filter(todo => todo.userId == params.userId);
+ }
+ return this.todos;
+ },
+ delay: 150,
+ },
+ {
+ endpoint: createTodo,
+ response() {
+ throw Object.assign(new Error('Internal Server Error'), {
+ status: 500,
+ });
+ },
+ delay: 500,
+ },
+];
+
+export const acidSideEffectFixtures: Interceptor[] = [
+ {
+ endpoint: getTodoList,
+ response(params) {
+ if (params?.userId != null) {
+ return this.todos.filter(todo => todo.userId == params.userId);
+ }
+ return this.todos;
+ },
+ delay: 150,
+ },
+ {
+ endpoint: getUser,
+ response({ id }) {
+ const user = this.users.find(item => item.id == id);
+ if (!user) return { id, todoCount: 0 };
+ return { ...user };
+ },
+ delay: 150,
+ },
+ {
+ endpoint: createTodo,
+ response(...args) {
+ const body = args.length > 1 ? args[1] : args[0];
+ const todo = {
+ completed: false,
+ userId: '1',
+ ...body,
+ id: uuid(),
+ };
+ this.todos.push(todo);
+ const user = this.users.find(item => item.id === '1');
+ if (user) {
+ user.todoCount = this.todos.filter(item => item.userId === '1').length;
+ }
+ return {
+ todo,
+ user: user ? { ...user } : { id: '1', todoCount: 0 },
+ };
+ },
+ delay: 150,
+ },
+];
From d414bcd03fdb7c6e3b0966e974a4ddae2e839ea8 Mon Sep 17 00:00:00 2001
From: root
Date: Wed, 12 Aug 2026 15:51:30 -0400
Subject: [PATCH 02/12] docs: Deduplicate ACID playgrounds and persist example
Reuse the existing optimistic-transform demo and list interceptor instead of copying them, and point durability at the managers persist example.
Co-authored-by: Cursor
---
docs/core/concepts/acid.md | 40 +---------
docs/core/shared/_acidFetchOrder.mdx | 113 ---------------------------
website/src/fixtures/acid.ts | 47 ++++-------
3 files changed, 18 insertions(+), 182 deletions(-)
delete mode 100644 docs/core/shared/_acidFetchOrder.mdx
diff --git a/docs/core/concepts/acid.md b/docs/core/concepts/acid.md
index 9e2eccb5ea04..3dcb6c1e174d 100644
--- a/docs/core/concepts/acid.md
+++ b/docs/core/concepts/acid.md
@@ -18,7 +18,7 @@ import AcidCollections from '../shared/\_acidCollections.mdx';
import AcidQuery from '../shared/\_acidQuery.mdx';
import AcidValidate from '../shared/\_acidValidate.mdx';
import AcidTransports from '../shared/\_acidTransports.mdx';
-import AcidFetchOrder from '../shared/\_acidFetchOrder.mdx';
+import OptimisticTransform from '../../rest/shared/\_optimisticTransform.mdx';
import AcidSnapshot from '../shared/\_acidSnapshot.mdx';
import AcidRest from '../shared/\_acidRest.mdx';
@@ -33,7 +33,7 @@ ACID. The frontend store is that database for interactive data — but every
durable write is [asynchronous](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous).
Reactive Data Client applies the same guarantees so every view agrees without
-[refetching](../api/Controller.md#expireAll), mutations don't flash torn state, and
+refetching, mutations don't flash torn state, and
crashes don't lose data that reached a durable store like a REST server or
[IndexedDB](./managers.md#persistence).
@@ -181,7 +181,7 @@ keeps 0, 1, 2.
Click increment several times quickly.
-
+
[Optimistic updates](/rest/guides/optimistic-updates) amplify these races;
Reactive Data Client handles them automatically.
@@ -222,40 +222,6 @@ for offline reloads. Restore it with
[DataProvider's initialState](../api/DataProvider.md#initialState). Drop
in-flight optimistic updates — they are not cloneable, and they are not the ack.
-```typescript
-import type { Manager, Middleware } from '@data-client/react';
-import { set } from 'idb-keyval';
-
-export default class PersistManager implements Manager {
- declare protected timer?: ReturnType;
-
- middleware: Middleware = controller => next => async action => {
- await next(action);
- clearTimeout(this.timer);
- this.timer = setTimeout(() => {
- const state = { ...controller.getState(), optimistic: [] };
- set('data-client', state);
- }, 1000);
- };
-
- cleanup() {
- clearTimeout(this.timer);
- }
-}
-```
-
-```tsx
-import { get } from 'idb-keyval';
-
-const initialState = await get('data-client');
-
-createRoot(document.body).render(
-
-
- ,
-);
-```
-
:::info[Reactivity]
ACID makes writes trustworthy. [useLive()](../api/useLive.md),
diff --git a/docs/core/shared/_acidFetchOrder.mdx b/docs/core/shared/_acidFetchOrder.mdx
deleted file mode 100644
index e168042ca34a..000000000000
--- a/docs/core/shared/_acidFetchOrder.mdx
+++ /dev/null
@@ -1,113 +0,0 @@
-import HooksPlayground from '@site/src/components/HooksPlayground';
-import { RestEndpoint } from '@data-client/rest';
-
- 500 + Math.random() * 4500,
-}
-]}
-getInitialInterceptorData={() => ({count: 0})}
-row
->
-
-```ts title="count" collapsed
-export class CountEntity extends Entity {
- count = 0;
-
- pk() {
- return `SINGLETON`;
- }
-}
-export const getCount = new RestEndpoint({
- path: '/api/count',
- schema: CountEntity,
- name: 'get',
-});
-```
-
-```ts title="increment" {9-15}
-import { CountEntity, getCount } from './count';
-
-export const increment = new RestEndpoint({
- path: '/api/count/increment',
- method: 'POST',
- body: undefined,
- name: 'increment',
- schema: CountEntity,
- getOptimisticResponse(snap) {
- const data = snap.get(CountEntity, {});
- if (!data) throw snap.abort;
- return {
- count: data.count + 1,
- };
- },
-});
-```
-
-```tsx title="CounterPage" collapsed
-import { useLoading } from '@data-client/react';
-import { getCount } from './count';
-import { increment } from './increment';
-
-function CounterPage() {
- const ctrl = useController();
- const { count } = useSuspense(getCount);
- const [stateCount, setStateCount] = React.useState(0);
- const [responseCount, setResponseCount] = React.useState(0);
- const [clickHandler, loading, error] = useLoading(async () => {
- setStateCount(stateCount + 1);
- const val = await ctrl.fetch(increment);
- setResponseCount(val.count);
- setStateCount(val.count);
- });
- return (
-
-
- Click the button multiple times quickly to trigger the race
- condition
-
-
-
-
- Optimistic
- Normal
-
-
-
- Data Client :
- {count}
-
-
-
- Other :
- {stateCount}
- {responseCount}
-
-
-
-
-
-
-
{loading ? ' ...loading' : ''}
-
- );
-}
-render( );
-```
-
-
diff --git a/website/src/fixtures/acid.ts b/website/src/fixtures/acid.ts
index 80d2617b9b08..a51311b8141c 100644
--- a/website/src/fixtures/acid.ts
+++ b/website/src/fixtures/acid.ts
@@ -95,17 +95,19 @@ export function getAcidSideEffectData(): AcidSideEffectState {
};
}
-export const acidTodoFixtures: Interceptor[] = [
- {
- endpoint: getTodoList,
- response(params) {
- if (params?.userId != null) {
- return this.todos.filter(todo => todo.userId == params.userId);
- }
- return this.todos;
- },
- delay: 150,
+const getTodoListInterceptor: Interceptor = {
+ endpoint: getTodoList,
+ response(params) {
+ if (params?.userId != null) {
+ return this.todos.filter(todo => todo.userId == params.userId);
+ }
+ return this.todos;
},
+ delay: 150,
+};
+
+export const acidTodoFixtures: Interceptor[] = [
+ getTodoListInterceptor,
{
endpoint: getTodo,
response({ id }) {
@@ -163,16 +165,7 @@ export const acidConsistencyFixtures: Interceptor[] = [
];
export const acidRollbackFixtures: Interceptor[] = [
- {
- endpoint: getTodoList,
- response(params) {
- if (params?.userId != null) {
- return this.todos.filter(todo => todo.userId == params.userId);
- }
- return this.todos;
- },
- delay: 150,
- },
+ getTodoListInterceptor,
{
endpoint: createTodo,
response() {
@@ -185,16 +178,7 @@ export const acidRollbackFixtures: Interceptor[] = [
];
export const acidSideEffectFixtures: Interceptor[] = [
- {
- endpoint: getTodoList,
- response(params) {
- if (params?.userId != null) {
- return this.todos.filter(todo => todo.userId == params.userId);
- }
- return this.todos;
- },
- delay: 150,
- },
+ getTodoListInterceptor,
{
endpoint: getUser,
response({ id }) {
@@ -206,8 +190,7 @@ export const acidSideEffectFixtures: Interceptor[] = [
},
{
endpoint: createTodo,
- response(...args) {
- const body = args.length > 1 ? args[1] : args[0];
+ response(body) {
const todo = {
completed: false,
userId: '1',
From 1ea3b0bbe45de79d7554e31b20f71b3e38ccb9d2 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 13 Aug 2026 11:09:19 -0400
Subject: [PATCH 03/12] fix(website): Type homepage SVGs from SVGR imports
Avoid dual @types/react mismatch between Docusaurus SVG modules and React.ComponentProps<'svg'>.
Co-authored-by: Cursor
---
website/src/components/HomepageFeatures.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/website/src/components/HomepageFeatures.tsx b/website/src/components/HomepageFeatures.tsx
index d68234be891e..c6256afc0dfa 100644
--- a/website/src/components/HomepageFeatures.tsx
+++ b/website/src/components/HomepageFeatures.tsx
@@ -9,7 +9,7 @@ import ChemicalCompositionSvg from '../../static/img/chemical-composition.svg';
import FastCarSvg from '../../static/img/fast-car.svg';
import GrowingBarChartSvg from '../../static/img/growing-bar-chart.svg';
-type SvgComponent = React.ComponentType>;
+type SvgComponent = typeof FastCarSvg;
interface BaseFeature {
description: React.ReactNode;
From 68617433207e0792f3c6cfe565942623a407ba93 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 13 Aug 2026 11:34:58 -0400
Subject: [PATCH 04/12] docs: Use issue-tracker and trade playgrounds for ACID
Replace duplicate todo lists with list+detail issues and a bundled trade/account mutation so each guarantee shows up in a recognizable product UI.
Co-authored-by: Cursor
---
docs/core/concepts/acid.md | 35 ++--
docs/core/shared/_acidCollections.mdx | 117 ++++++------
docs/core/shared/_acidCreate.mdx | 102 +++++------
docs/core/shared/_acidDelete.mdx | 97 +++++-----
docs/core/shared/_acidIdentity.mdx | 96 +++++-----
docs/core/shared/_acidQuery.mdx | 69 ++++---
docs/core/shared/_acidRest.mdx | 86 ++++-----
docs/core/shared/_acidRollback.mdx | 83 ++++++---
docs/core/shared/_acidSideEffects.mdx | 98 +++++-----
docs/core/shared/_acidSnapshot.mdx | 79 ++++----
docs/core/shared/_acidTransports.mdx | 92 +++++-----
docs/core/shared/_acidUpdate.mdx | 107 +++++------
website/src/fixtures/acid.ts | 255 ++++++++++++++------------
13 files changed, 656 insertions(+), 660 deletions(-)
diff --git a/docs/core/concepts/acid.md b/docs/core/concepts/acid.md
index 3dcb6c1e174d..427c0cdf4812 100644
--- a/docs/core/concepts/acid.md
+++ b/docs/core/concepts/acid.md
@@ -52,7 +52,8 @@ tearing* — flashes of inconsistent state as usages update one by one.
into the one copy of that entity. Every consumer of that [pk](/rest/api/Entity#pk)
updates together. [Read more about defining other update endpoints](/rest/guides/side-effects).
-Toggle a todo. Both lists update at once — no flash of one list lagging.
+Close an issue. The list and the detail pane update together — no flash of
+one view lagging.
@@ -64,8 +65,8 @@ Created entities are immediately available. They are added to existing
[.unshift](/rest/api/RestEndpoint#unshift), or
[.assign](/rest/api/RestEndpoint#assign).
-Add a todo. It appears in both lists together — never invisible, never an
-orphan, never a list hole.
+Open an issue. It appears in the list and is immediately readable with
+[get](/rest/api/resource#get) — never invisible, never an orphan.
@@ -74,7 +75,8 @@ orphan, never a list hole.
[schema.Invalidate](/rest/api/Invalidate) removes the entity.
[Resource.delete](/rest/api/resource#delete) provides such an endpoint.
-Delete a todo. It disappears from both lists in the same commit.
+Delete an issue. It disappears from the list and the detail pane in the same
+commit.
@@ -83,7 +85,7 @@ Delete a todo. It disappears from both lists in the same commit.
Optimistic updates apply as that same snapshot. If the network fails, they
roll back as that snapshot.
-Click add. The todo appears immediately, then vanishes when the server errors.
+Close an issue. It flips immediately, then snaps back when the server errors.
@@ -95,7 +97,7 @@ and refetching the others can fail partway — a flash of torn state.
[See mutation side-effects](/rest/guides/side-effects) for the full pattern.
-Add a todo. The list and the user's count update together.
+Buy DOGE. The trade list and the account balance update together.
@@ -103,15 +105,15 @@ Add a todo. The list and the user's count update together.
A write takes the store from one valid state to another. Invariants hold:
one copy of each entity, relationships join, invalid data is rejected.
-That prevents *data tearing* — the same todo showing two different values.
+That prevents *data tearing* — the same issue showing two different values.
### Identity
-[Entity.pk()](/rest/api/Entity#pk) is the unique index. The same todo from
+[Entity.pk()](/rest/api/Entity#pk) is the unique index. The same issue from
[getList](/rest/api/resource#getlist) and [get](/rest/api/resource#get) is the
**same object** — the same value, wherever it is embedded.
-Select a todo, then toggle it. `fromList === get` stays true.
+Select an issue, then close it. `fromList === get` stays true.
@@ -121,7 +123,7 @@ When [Collection.argsKey](/rest/api/Collection#argskey) and
[Collection.nestKey](/rest/api/Collection#nestkey) return the same shape, a nested
list and a top-level list are the **same array**.
-Toggle a todo. `user.todos === getList` stays true, and both columns update.
+Close an issue. `repo.issues === getList` stays true, and both columns update.
@@ -130,7 +132,7 @@ Toggle a todo. `user.todos === getList` stays true, and both columns update.
[Query](/rest/api/Query) derived values stay consistent for the same reason —
they read the entity table, not a copy.
-Toggle todos. The remaining count updates without refetching.
+Close issues. The open count updates without refetching.
@@ -149,7 +151,8 @@ The same entity is the same value whether it arrived from fetch, initial
load, [Controller.set()](../api/Controller.md#set), or a
[websocket](./managers.md#data-stream).
-Click simulate websocket. Both lists update — no copy left behind.
+Click **Alice closed this**. The list and the detail pane update — no copy
+left behind.
@@ -191,7 +194,7 @@ Reactive Data Client handles them automatically.
All hooks in one render read the same snapshot, so the tree never paints mixed
old and new values.
-Toggle a todo. `list` and `query` in that row always agree.
+Close an issue. `list` and `query` in that row always agree.
@@ -203,12 +206,12 @@ Later retrievals reflect those updates.
### REST
-`ctrl.fetch` is the commit path. Saving as you go (a toggle, an inline
+`ctrl.fetch` is the commit path. Saving as you go (a close, an inline
edit) commits to the server. Use a form when the friction is the point —
publish, purchase.
-Toggle some todos, then simulate a crash. Data Client refetches from the
-server and the work is still there. The local-only note is gone.
+Close some issues, type a draft comment, then simulate a crash. Data Client
+refetches from the server and the closes are still there. The draft is gone.
diff --git a/docs/core/shared/_acidCollections.mdx b/docs/core/shared/_acidCollections.mdx
index 8e8a51089d29..4e8d26eac80c 100644
--- a/docs/core/shared/_acidCollections.mdx
+++ b/docs/core/shared/_acidCollections.mdx
@@ -1,86 +1,88 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidConsistencyFixtures,
- getAcidConsistencyData,
+ acidCollectionFixtures,
+ getAcidCollectionData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="api/Todo" {12-15} collapsed
+```ts title="IssueResource" {12-15}
import { Entity, RestEndpoint, Collection } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const userTodos = new Collection([Todo], {
- argsKey: ({ userId }: { userId?: string }) => ({ userId }),
- nestKey: (parent: { id: string }) => ({ userId: parent.id }),
+export const repoIssues = new Collection([Issue], {
+ argsKey: ({ repoId }: { repoId?: string }) => ({ repoId }),
+ nestKey: (parent: { id: string }) => ({ repoId: parent.id }),
});
-export const getTodos = new RestEndpoint({
- path: '/todos',
- searchParams: {} as { userId?: string },
- schema: userTodos,
+export const getIssues = new RestEndpoint({
+ path: '/issues',
+ searchParams: {} as { repoId?: string },
+ schema: repoIssues,
});
-export const updateTodo = new RestEndpoint({
- path: '/todos/:id',
+export const updateIssue = new RestEndpoint({
+ path: '/issues/:id',
method: 'PATCH',
- schema: Todo,
+ schema: Issue,
getOptimisticResponse(snap, { id }, body) {
- const cur = snap.get(Todo, { id });
+ const cur = snap.get(Issue, { id });
if (!cur) throw snap.abort;
return { ...cur, ...body };
},
});
```
-```ts title="api/User" collapsed
+```ts title="RepoResource" collapsed
import { Entity, RestEndpoint } from '@data-client/rest';
-import { Todo, userTodos } from './api/Todo';
+import { Issue, repoIssues } from './IssueResource';
-export class User extends Entity {
+export class Repo extends Entity {
id = '';
name = '';
- todos: Todo[] = [];
+ issues: Issue[] = [];
- static key = 'User';
+ static key = 'Repo';
static schema = {
- todos: userTodos,
+ issues: repoIssues,
};
}
-export const getUser = new RestEndpoint({
- path: '/users/:id',
- schema: User,
+export const getRepo = new RestEndpoint({
+ path: '/repos/:id',
+ schema: Repo,
});
```
-```tsx title="TodoList" {8}
+```tsx title="IssuePage" collapsed
import { useController, useSuspense } from '@data-client/react';
-import { getTodos, updateTodo } from './api/Todo';
-import { getUser } from './api/User';
+import { getIssues, updateIssue } from './IssueResource';
+import { getRepo } from './RepoResource';
-function TodoList() {
+function IssuePage() {
const ctrl = useController();
- const user = useSuspense(getUser, { id: '1' });
- const todos = useSuspense(getTodos, { userId: '1' });
- const handleChange = (todo, e) =>
+ const repo = useSuspense(getRepo, { id: '1' });
+ const issues = useSuspense(getIssues, { repoId: '1' });
+ const handleToggle = issue =>
ctrl.fetch(
- updateTodo,
- { id: todo.id },
- { completed: e.currentTarget.checked },
+ updateIssue,
+ { id: issue.id },
+ { state: issue.state === 'open' ? 'closed' : 'open' },
);
return (
- user.todos === getList: {String(user.todos === todos)}
+
+ repo.issues === getList: {String(repo.issues === issues)}
+
-
user.todos
- {user.todos.map(todo => (
-
-
- handleChange(todo, e)}
- />
- {todo.title}
-
+
Repo page
+ {repo.issues.map(issue => (
+
+ {issue.title}
+ {issue.state}
+ handleToggle(issue)}>
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
))}
-
getList
- {todos.map(todo => (
-
-
- handleChange(todo, e)}
- />
- {todo.title}
-
+
Issues tab
+ {issues.map(issue => (
+
+ {issue.title}
+ {issue.state}
))}
@@ -123,7 +116,7 @@ function TodoList() {
);
}
-render(
);
+render(
);
```
diff --git a/docs/core/shared/_acidCreate.mdx b/docs/core/shared/_acidCreate.mdx
index eade292d37d9..10bc55ad9895 100644
--- a/docs/core/shared/_acidCreate.mdx
+++ b/docs/core/shared/_acidCreate.mdx
@@ -1,82 +1,49 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidTodoFixtures,
- getAcidTodoData,
+ acidIssueFixtures,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
```
-```tsx title="CreateTodo" collapsed
-import { useController } from '@data-client/react';
-import { TodoResource } from './TodoResource';
+```tsx title="IssuePage" {13}
+import { useController, useSuspense } from '@data-client/react';
+import { IssueResource } from './IssueResource';
-export default function CreateTodo({ userId }: { userId: string }) {
+function IssuePage() {
const ctrl = useController();
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const issue = useSuspense(IssueResource.get, {
+ id: issues[issues.length - 1].id,
+ });
const handleKeyDown = e => {
- if (e.key === 'Enter') {
- ctrl.fetch(TodoResource.getList.push, {
- userId,
+ if (e.key === 'Enter' && e.currentTarget.value.trim()) {
+ ctrl.fetch(IssueResource.getList.push, {
+ repoId: '1',
title: e.currentTarget.value,
});
e.currentTarget.value = '';
}
};
- return (
-
-
-
-
-
-
-
- );
-}
-```
-
-```tsx title="TodoList" collapsed
-import { useSuspense } from '@data-client/react';
-import { TodoResource } from './TodoResource';
-import CreateTodo from './CreateTodo';
-
-export default function TodoList() {
- const userId = '1';
- const todos = useSuspense(TodoResource.getList, { userId });
- return (
-
- {todos.map(todo => (
-
- {todo.title}
-
- ))}
-
-
- );
-}
-```
-
-```tsx title="TodoPage"
-import TodoList from './TodoList';
-
-function TodoPage() {
return (
-
-
+
+ {issues.map(item => (
+
+ {item.title}
+
+ ))}
+
+
+
+
+
+
Newest
+
{issue.title}
+
{issue.state}
+
);
}
-render( );
+render( );
```
diff --git a/docs/core/shared/_acidDelete.mdx b/docs/core/shared/_acidDelete.mdx
index 7e266670da1c..9d39602a3759 100644
--- a/docs/core/shared/_acidDelete.mdx
+++ b/docs/core/shared/_acidDelete.mdx
@@ -1,68 +1,45 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidTodoFixtures,
- getAcidTodoData,
+ acidIssueFixtures,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
```
-```tsx title="TodoItem" collapsed
-import { useController } from '@data-client/react';
-import { TodoResource, type Todo } from './TodoResource';
+```tsx title="IssuePage" {14}
+import { useController, useSuspense } from '@data-client/react';
+import { IssueResource } from './IssueResource';
-export default function TodoItem({ todo }: { todo: Todo }) {
+function IssuePage() {
const ctrl = useController();
- const handleDelete = () =>
- ctrl.fetch(TodoResource.delete, { id: todo.id });
- return (
-
- {todo.title}
-
-
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const [id, setId] = React.useState(issues[0]?.id);
+ const selected = issues.find(item => item.id === id) ?? issues[0];
+ const issue = useSuspense(
+ IssueResource.get,
+ selected ? { id: selected.id } : null,
);
-}
-```
-
-```tsx title="TodoList" collapsed
-import { useSuspense } from '@data-client/react';
-import { TodoResource } from './TodoResource';
-import TodoItem from './TodoItem';
-
-export default function TodoList() {
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
- return (
-
- {todos.map(todo => (
-
- ))}
-
- );
-}
-```
-
-```tsx title="TodoPage"
-import TodoList from './TodoList';
-
-function TodoPage() {
+ const handleDelete = () =>
+ ctrl.fetch(IssueResource.delete, { id: selected.id });
return (
-
-
+
+ {issues.map(item => (
+
setId(item.id)}
+ >
+ {item === selected ?
+ {item.title}
+ : item.title}
+
+ ))}
+
+
+ {issue ?
+
+ {issue.title}
+
+
+ :
No issues }
+
);
}
-render( );
+render( );
```
diff --git a/docs/core/shared/_acidIdentity.mdx b/docs/core/shared/_acidIdentity.mdx
index 69d505bee991..ba8fab67ab81 100644
--- a/docs/core/shared/_acidIdentity.mdx
+++ b/docs/core/shared/_acidIdentity.mdx
@@ -1,73 +1,87 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidTodoFixtures,
- getAcidTodoData,
+ acidIssueFixtures,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
```
-```tsx title="TodoPage" {8-10}
+```tsx title="IssuePage" {6,8,9,19}
import { useController, useSuspense } from '@data-client/react';
-import { TodoResource } from './TodoResource';
+import { IssueResource } from './IssueResource';
-function TodoPage() {
+function IssuePage() {
const ctrl = useController();
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
- const [id, setId] = React.useState(todos[0].id);
- const todo = useSuspense(TodoResource.get, { id });
- const fromList = todos.find(item => item.id === id);
- const handleChange = e =>
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const [id, setId] = React.useState(issues[0].id);
+ const issue = useSuspense(IssueResource.get, { id });
+ const fromList = issues.find(item => item.id === id);
+ const handleToggle = () =>
ctrl.fetch(
- TodoResource.partialUpdate,
+ IssueResource.partialUpdate,
{ id },
- { completed: e.currentTarget.checked },
+ { state: issue.state === 'open' ? 'closed' : 'open' },
);
return (
- fromList === get: {String(fromList === todo)}
+ fromList === get: {String(fromList === issue)}
- {todos.map(item => (
-
setId(item.id)}
- >
- {item.id === id ?
{item.title} : item.title}
+
+
+ {issues.map(item => (
+
setId(item.id)}
+ >
+ {item.id === id ?
+ {item.title}
+ : item.title}
+ {item.state}
+
+ ))}
- ))}
-
-
- {todo.title}
-
+
+
{issue.title}
+
+ {issue.state}
+
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
+
);
}
-render(
);
+render(
);
```
diff --git a/docs/core/shared/_acidQuery.mdx b/docs/core/shared/_acidQuery.mdx
index 6ddcc9b58f60..bcc08c3ce641 100644
--- a/docs/core/shared/_acidQuery.mdx
+++ b/docs/core/shared/_acidQuery.mdx
@@ -1,70 +1,67 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidTodoFixtures,
- getAcidTodoData,
+ acidIssueFixtures,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" {18-21}
import { Entity, resource, Query } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
-export const remainingTodos = new Query(
- TodoResource.getList.schema,
- entries => entries.filter(todo => !todo.completed).length,
+export const openCount = new Query(
+ IssueResource.getList.schema,
+ entries => entries.filter(issue => issue.state === 'open').length,
);
```
-```tsx title="TodoList" {7}
+```tsx title="IssuePage" collapsed
import { useController, useQuery, useSuspense } from '@data-client/react';
-import { remainingTodos, TodoResource } from './TodoResource';
+import { openCount, IssueResource } from './IssueResource';
-function TodoList() {
+function IssuePage() {
const ctrl = useController();
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
- const remaining = useQuery(remainingTodos, { userId: '1' });
- const handleChange = (todo, e) =>
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const open = useQuery(openCount, { repoId: '1' });
+ const handleToggle = issue =>
ctrl.fetch(
- TodoResource.partialUpdate,
- { id: todo.id },
- { completed: e.currentTarget.checked },
+ IssueResource.partialUpdate,
+ { id: issue.id },
+ { state: issue.state === 'open' ? 'closed' : 'open' },
);
return (
- {remaining} remaining
+ {open} open
- {todos.map(todo => (
-
-
- handleChange(todo, e)}
- />
- {todo.completed ? {todo.title} : todo.title}
-
+ {issues.map(issue => (
+
+ {issue.title}
+ {issue.state}
+ handleToggle(issue)}>
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
))}
);
}
-render(
);
+render(
);
```
diff --git a/docs/core/shared/_acidRest.mdx b/docs/core/shared/_acidRest.mdx
index b763ba784fc8..9046755928ea 100644
--- a/docs/core/shared/_acidRest.mdx
+++ b/docs/core/shared/_acidRest.mdx
@@ -1,82 +1,66 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidTodoFixtures,
- getAcidTodoData,
+ acidIssueFixtures,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
```
-```tsx title="TodoItem" collapsed
-import { useController } from '@data-client/react';
-import { TodoResource, type Todo } from './TodoResource';
+```tsx title="Session" collapsed
+import { useController, useSuspense } from '@data-client/react';
+import { IssueResource } from './IssueResource';
-export default function TodoItem({ todo }: { todo: Todo }) {
+export default function Session() {
const ctrl = useController();
- const handleChange = e =>
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const [draft, setDraft] = React.useState('');
+ const handleToggle = issue =>
ctrl.fetch(
- TodoResource.partialUpdate,
- { id: todo.id },
- { completed: e.currentTarget.checked },
+ IssueResource.partialUpdate,
+ { id: issue.id },
+ { state: issue.state === 'open' ? 'closed' : 'open' },
);
- return (
-
-
-
- {todo.completed ? {todo.title} : todo.title}
-
-
- );
-}
-```
-
-```tsx title="Session" collapsed
-import { useSuspense } from '@data-client/react';
-import { TodoResource } from './TodoResource';
-import TodoItem from './TodoItem';
-
-export default function Session() {
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
- const [note, setNote] = React.useState('');
return (
-
setNote(e.currentTarget.value)}
+
);
}
```
-```tsx title="App"
+```tsx title="App" {8-9}
import { useController } from '@data-client/react';
import Session from './Session';
diff --git a/docs/core/shared/_acidRollback.mdx b/docs/core/shared/_acidRollback.mdx
index 47c12d10beb2..8440bce5dec8 100644
--- a/docs/core/shared/_acidRollback.mdx
+++ b/docs/core/shared/_acidRollback.mdx
@@ -1,54 +1,81 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
acidRollbackFixtures,
- getAcidTodoData,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
```
-```tsx title="TodoList"
+```tsx title="IssuePage" {10-14}
import { useController, useSuspense } from '@data-client/react';
-import { TodoResource } from './TodoResource';
+import { IssueResource } from './IssueResource';
-function TodoList() {
+function IssuePage() {
const ctrl = useController();
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
- const handleAdd = () =>
- ctrl.fetch(TodoResource.getList.push, {
- userId: '1',
- title: 'New todo',
- });
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const [id, setId] = React.useState(issues[0].id);
+ const issue = useSuspense(IssueResource.get, { id });
+ const handleToggle = () =>
+ ctrl.fetch(
+ IssueResource.partialUpdate,
+ { id },
+ { state: issue.state === 'open' ? 'closed' : 'open' },
+ );
return (
-
- {todos.map(todo => (
-
- {todo.title}
-
- ))}
-
Add todo
+
+
+ {issues.map(item => (
+
setId(item.id)}
+ >
+ {item.id === id ?
+ {item.title}
+ : item.title}
+ {item.state}
+
+ ))}
+
+
+
{issue.title}
+
+ {issue.state}
+
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
);
}
-render(
);
+render(
);
```
diff --git a/docs/core/shared/_acidSideEffects.mdx b/docs/core/shared/_acidSideEffects.mdx
index 87985df9de33..90185aa46571 100644
--- a/docs/core/shared/_acidSideEffects.mdx
+++ b/docs/core/shared/_acidSideEffects.mdx
@@ -1,88 +1,76 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidSideEffectFixtures,
- getAcidSideEffectData,
+ acidTradeFixtures,
+ getAcidTradeData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="api/Todo" collapsed
-import { Entity, RestEndpoint, Collection } from '@data-client/rest';
+```ts title="AccountResource" collapsed
+import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Account extends Entity {
id = '';
- userId = '';
- title = '';
- completed = false;
+ balance = 0;
- static key = 'Todo';
+ static key = 'Account';
}
-
-export const userTodos = new Collection([Todo], {
- argsKey: ({ userId }: { userId?: string }) => ({ userId }),
-});
-
-export const getTodos = new RestEndpoint({
- path: '/todos',
- searchParams: {} as { userId?: string },
- schema: userTodos,
+export const AccountResource = resource({
+ path: '/accounts/:id',
+ schema: Account,
});
```
-```ts title="api/User" {18-21}
-import { Entity, RestEndpoint } from '@data-client/rest';
-import { getTodos } from './api/Todo';
+```ts title="TradeResource" {16-19}
+import { Entity, resource } from '@data-client/rest';
+import { Account } from './AccountResource';
-export class User extends Entity {
+export class Trade extends Entity {
id = '';
- name = '';
- todoCount = 0;
+ amount = 0;
+ coin = '';
- static key = 'User';
+ static key = 'Trade';
}
-
-export const getUser = new RestEndpoint({
- path: '/users/:id',
- schema: User,
-});
-
-export const createTodo = getTodos.push.extend({
- schema: {
- todo: getTodos.push.schema,
- user: User,
- },
-});
+export const TradeResource = resource({
+ path: '/trade/:id',
+ schema: Trade,
+}).extend(Base => ({
+ create: Base.getList.push.extend({
+ schema: {
+ trade: Base.getList.push.schema,
+ account: Account,
+ },
+ }),
+}));
```
-```tsx title="TodoPage"
+```tsx title="TradePage" collapsed
import { useController, useSuspense } from '@data-client/react';
-import { getTodos } from './api/Todo';
-import { createTodo, getUser } from './api/User';
+import { AccountResource } from './AccountResource';
+import { TradeResource } from './TradeResource';
-function TodoPage() {
+function TradePage() {
const ctrl = useController();
- const user = useSuspense(getUser, { id: '1' });
- const todos = useSuspense(getTodos, { userId: '1' });
- const handleAdd = () =>
- ctrl.fetch(createTodo, {
- userId: '1',
- title: 'New todo',
- });
+ const account = useSuspense(AccountResource.get, { id: '1' });
+ const trades = useSuspense(TradeResource.getList);
+ const handleBuy = () =>
+ ctrl.fetch(TradeResource.create, { amount: 10, coin: 'DOGE' });
return (
- {user.name} has {user.todoCount} todos
+ Balance: {account.balance} USD
- {todos.map(todo => (
-
- {todo.title}
+ {trades.map(trade => (
+
+ {trade.amount} {trade.coin}
))}
-
Add todo
+
Buy 10 DOGE
);
}
-render(
);
+render(
);
```
diff --git a/docs/core/shared/_acidSnapshot.mdx b/docs/core/shared/_acidSnapshot.mdx
index 988a8dd1dfe0..352c93aafbfd 100644
--- a/docs/core/shared/_acidSnapshot.mdx
+++ b/docs/core/shared/_acidSnapshot.mdx
@@ -1,83 +1,78 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidTodoFixtures,
- getAcidTodoData,
+ acidIssueFixtures,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
```
-```tsx title="TodoItem" {8-9,20}
+```tsx title="IssueRow" {6-9,24-25}
import { useController, useQuery, useSuspense } from '@data-client/react';
-import { Todo, TodoResource } from './TodoResource';
+import { Issue, IssueResource } from './IssueResource';
-export default function TodoItem({ id }: { id: string }) {
+export default function IssueRow({ id }: { id: string }) {
const ctrl = useController();
- const fromList = useSuspense(TodoResource.getList, {
- userId: '1',
- }).find(todo => todo.id === id);
- const fromQuery = useQuery(Todo, { id });
+ const fromList = useSuspense(IssueResource.getList, {
+ repoId: '1',
+ }).find(issue => issue.id === id);
+ const fromQuery = useQuery(Issue, { id });
if (!fromList) return null;
- const handleChange = e =>
+ const handleToggle = () =>
ctrl.fetch(
- TodoResource.partialUpdate,
+ IssueResource.partialUpdate,
{ id },
- { completed: e.currentTarget.checked },
+ { state: fromList.state === 'open' ? 'closed' : 'open' },
);
return (
-
-
-
- {fromList.title}
-
+
+ {fromList.title}
+
+ {fromList.state === 'open' ? 'Close' : 'Reopen'}
+
- list={String(fromList.completed)} query=
- {String(fromQuery?.completed)} same render=
- {String(fromList.completed === fromQuery?.completed)}
+ list={fromList.state} query={fromQuery?.state} same render=
+ {String(fromList.state === fromQuery?.state)}
);
}
```
-```tsx title="TodoList" collapsed
+```tsx title="IssueList" collapsed
import { useSuspense } from '@data-client/react';
-import { TodoResource } from './TodoResource';
-import TodoItem from './TodoItem';
+import { IssueResource } from './IssueResource';
+import IssueRow from './IssueRow';
-function TodoList() {
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
+function IssueList() {
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
return (
- {todos.map(todo => (
-
+ {issues.map(issue => (
+
))}
);
}
-render(
);
+render(
);
```
diff --git a/docs/core/shared/_acidTransports.mdx b/docs/core/shared/_acidTransports.mdx
index 0939a9a1f45f..bb980e0d0015 100644
--- a/docs/core/shared/_acidTransports.mdx
+++ b/docs/core/shared/_acidTransports.mdx
@@ -1,66 +1,51 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidTodoFixtures,
- getAcidTodoData,
+ acidIssueFixtures,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
```
-```tsx title="TodoList" collapsed
-import { useSuspense } from '@data-client/react';
-import { TodoResource } from './TodoResource';
-
-export default function TodoList() {
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
- return (
-
- {todos.map(todo => (
-
- {todo.title}
-
- ))}
-
- );
-}
-```
-
-```tsx title="TodoPage"
+```tsx title="IssuePage" {10-13}
import { useController, useSuspense } from '@data-client/react';
-import { Todo, TodoResource } from './TodoResource';
-import TodoList from './TodoList';
+import { Issue, IssueResource } from './IssueResource';
-function TodoPage() {
+function IssuePage() {
const ctrl = useController();
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
- const handlePush = () => {
- const todo = todos[0];
- ctrl.set(Todo, { id: todo.id }, current => ({
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const [id, setId] = React.useState(issues[0].id);
+ const issue = useSuspense(IssueResource.get, { id });
+ const handlePush = () =>
+ ctrl.set(Issue, { id }, current => ({
...current,
- title: `${current.title} (pushed)`,
+ state: current.state === 'open' ? 'closed' : 'open',
}));
- };
return (
-
Simulate websocket
+
+ {issue.state === 'open' ?
+ 'Alice closed this'
+ : 'Alice reopened this'}
+
-
-
+
+ {issues.map(item => (
+
setId(item.id)}
+ >
+ {item.id === id ?
+ {item.title}
+ : item.title}
+ {item.state}
+
+ ))}
+
+
+
{issue.title}
+
+ {issue.state}
+
+
);
}
-render( );
+render( );
```
diff --git a/docs/core/shared/_acidUpdate.mdx b/docs/core/shared/_acidUpdate.mdx
index 153156e57d1b..87138eb4a962 100644
--- a/docs/core/shared/_acidUpdate.mdx
+++ b/docs/core/shared/_acidUpdate.mdx
@@ -1,78 +1,45 @@
import HooksPlayground from '@site/src/components/HooksPlayground';
import {
- acidTodoFixtures,
- getAcidTodoData,
+ acidIssueFixtures,
+ getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
-```ts title="TodoResource" collapsed
+```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
-export class Todo extends Entity {
+export class Issue extends Entity {
id = '';
- userId = '';
+ repoId = '';
title = '';
- completed = false;
+ state: 'open' | 'closed' = 'open';
- static key = 'Todo';
+ static key = 'Issue';
}
-export const TodoResource = resource({
- path: '/todos/:id',
- searchParams: {} as { userId?: string | number } | undefined,
- schema: Todo,
+export const IssueResource = resource({
+ path: '/issues/:id',
+ searchParams: {} as { repoId?: string } | undefined,
+ schema: Issue,
optimistic: true,
});
```
-```tsx title="TodoItem" collapsed
-import { useController } from '@data-client/react';
-import { TodoResource, type Todo } from './TodoResource';
+```tsx title="IssuePage" {10-14}
+import { useController, useSuspense } from '@data-client/react';
+import { IssueResource } from './IssueResource';
-export default function TodoItem({ todo }: { todo: Todo }) {
+function IssuePage() {
const ctrl = useController();
- const handleChange = e =>
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const [id, setId] = React.useState(issues[0].id);
+ const issue = useSuspense(IssueResource.get, { id });
+ const handleToggle = () =>
ctrl.fetch(
- TodoResource.partialUpdate,
- { id: todo.id },
- { completed: e.currentTarget.checked },
+ IssueResource.partialUpdate,
+ { id },
+ { state: issue.state === 'open' ? 'closed' : 'open' },
);
- return (
-
-
-
- {todo.completed ? {todo.title} : todo.title}
-
-
- );
-}
-```
-
-```tsx title="TodoList" collapsed
-import { useSuspense } from '@data-client/react';
-import { TodoResource } from './TodoResource';
-import TodoItem from './TodoItem';
-
-export default function TodoList() {
- const todos = useSuspense(TodoResource.getList, { userId: '1' });
- return (
-
- {todos.map(todo => (
-
- ))}
-
- );
-}
-```
-
-```tsx title="TodoPage"
-import TodoList from './TodoList';
-
-function TodoPage() {
return (
-
-
+
+ {issues.map(item => (
+
setId(item.id)}
+ >
+ {item.id === id ?
+ {item.title}
+ : item.title}
+ {item.state}
+
+ ))}
+
+
+
{issue.title}
+
+ {issue.state}
+
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
);
}
-render( );
+render( );
```
diff --git a/website/src/fixtures/acid.ts b/website/src/fixtures/acid.ts
index a51311b8141c..97d5b242ef26 100644
--- a/website/src/fixtures/acid.ts
+++ b/website/src/fixtures/acid.ts
@@ -2,172 +2,192 @@ import { RestEndpoint } from '@data-client/rest';
import type { Interceptor } from '@data-client/test';
import { v4 as uuid } from 'uuid';
-export type AcidTodo = {
+export type AcidIssue = {
id: string;
- userId: string;
+ repoId: string;
title: string;
- completed: boolean;
+ state: 'open' | 'closed';
};
-export type AcidTodoState = {
- todos: AcidTodo[];
+export type AcidIssueState = {
+ issues: AcidIssue[];
};
-export type AcidUser = {
+export type AcidRepo = {
id: string;
name: string;
};
-export type AcidConsistencyState = AcidTodoState & {
- users: AcidUser[];
+export type AcidCollectionState = AcidIssueState & {
+ repos: AcidRepo[];
};
-const getTodoList = new RestEndpoint({
- path: '/todos',
- searchParams: {} as { userId?: string | number } | undefined,
+export type AcidAccount = {
+ id: string;
+ balance: number;
+};
+
+export type AcidTrade = {
+ id: string;
+ amount: number;
+ coin: string;
+};
+
+export type AcidTradeState = {
+ account: AcidAccount;
+ trades: AcidTrade[];
+};
+
+const getIssueList = new RestEndpoint({
+ path: '/issues',
+ searchParams: {} as { repoId?: string } | undefined,
});
-const getTodo = new RestEndpoint({
- path: '/todos/:id',
+const getIssue = new RestEndpoint({
+ path: '/issues/:id',
});
-const partialUpdateTodo = new RestEndpoint({
- path: '/todos/:id',
+const partialUpdateIssue = new RestEndpoint({
+ path: '/issues/:id',
method: 'PATCH',
});
-const createTodo = new RestEndpoint({
- path: '/todos',
+const createIssue = new RestEndpoint({
+ path: '/issues',
method: 'POST',
});
-const deleteTodo = new RestEndpoint({
- path: '/todos/:id',
+const deleteIssue = new RestEndpoint({
+ path: '/issues/:id',
method: 'DELETE',
});
-const getUser = new RestEndpoint({
- path: '/users/:id',
+const getRepo = new RestEndpoint({
+ path: '/repos/:id',
+});
+const getAccount = new RestEndpoint({
+ path: '/accounts/:id',
+});
+const getTradeList = new RestEndpoint({
+ path: '/trade',
+});
+const createTrade = new RestEndpoint({
+ path: '/trade',
+ method: 'POST',
});
-export function getAcidTodoData(): AcidTodoState {
+export function getAcidIssueData(): AcidIssueState {
return {
- todos: [
+ issues: [
{
- id: '1',
- userId: '1',
- title: 'Write tests',
- completed: false,
+ id: '3',
+ repoId: '1',
+ title: 'Rate limit the API',
+ state: 'closed',
},
{
- id: '2',
- userId: '1',
- title: 'Ship it',
- completed: false,
+ id: '1',
+ repoId: '1',
+ title: 'Fix login timeout',
+ state: 'open',
},
{
- id: '3',
- userId: '1',
- title: 'Take a break',
- completed: true,
+ id: '2',
+ repoId: '1',
+ title: 'Document ACID guarantees',
+ state: 'open',
},
],
};
}
-export function getAcidConsistencyData(): AcidConsistencyState {
+export function getAcidCollectionData(): AcidCollectionState {
return {
- users: [{ id: '1', name: 'Bob' }],
- todos: getAcidTodoData().todos,
+ repos: [{ id: '1', name: 'data-client' }],
+ issues: getAcidIssueData().issues,
};
}
-export type AcidSideEffectState = AcidTodoState & {
- users: (AcidUser & { todoCount: number })[];
-};
-
-export function getAcidSideEffectData(): AcidSideEffectState {
- const todos = getAcidTodoData().todos;
+export function getAcidTradeData(): AcidTradeState {
return {
- todos,
- users: [
- {
- id: '1',
- name: 'Bob',
- todoCount: todos.filter(todo => todo.userId === '1').length,
- },
- ],
+ account: { id: '1', balance: 1337 },
+ trades: [{ id: '1', amount: 50, coin: 'DOGE' }],
};
}
-const getTodoListInterceptor: Interceptor = {
- endpoint: getTodoList,
- response(params) {
- if (params?.userId != null) {
- return this.todos.filter(todo => todo.userId == params.userId);
- }
- return this.todos;
+const delay = 150;
+
+const getIssueListInterceptor: Interceptor = {
+ endpoint: getIssueList,
+ response({ repoId }) {
+ return this.issues.filter(issue => issue.repoId == repoId);
},
- delay: 150,
+ delay,
};
-export const acidTodoFixtures: Interceptor[] = [
- getTodoListInterceptor,
- {
- endpoint: getTodo,
- response({ id }) {
- return this.todos.find(todo => todo.id == id);
- },
- delay: 150,
+const getIssueInterceptor: Interceptor = {
+ endpoint: getIssue,
+ response({ id }) {
+ return this.issues.find(issue => issue.id == id);
},
- {
- endpoint: partialUpdateTodo,
- response({ id }, body) {
- const todo = this.todos.find(item => item.id == id);
- if (!todo) return { id, ...body };
- Object.assign(todo, body);
- return { ...todo };
- },
- delay: 150,
+ delay,
+};
+
+const partialUpdateIssueInterceptor: Interceptor = {
+ endpoint: partialUpdateIssue,
+ response({ id }, body) {
+ const issue = this.issues.find(item => item.id == id);
+ if (!issue) return { id, ...body };
+ Object.assign(issue, body);
+ return { ...issue };
},
+ delay,
+};
+
+export const acidIssueFixtures: Interceptor[] = [
+ getIssueListInterceptor,
+ getIssueInterceptor,
+ partialUpdateIssueInterceptor,
{
- endpoint: createTodo,
+ endpoint: createIssue,
response(body) {
- const todo = {
- completed: false,
+ const issue = {
+ state: 'open' as const,
+ repoId: '1',
...body,
id: uuid(),
};
- this.todos.push(todo);
- return todo;
+ this.issues.push(issue);
+ return issue;
},
- delay: 150,
+ delay,
},
{
- endpoint: deleteTodo,
+ endpoint: deleteIssue,
response({ id }) {
- this.todos = this.todos.filter(todo => todo.id != id);
+ this.issues = this.issues.filter(issue => issue.id != id);
return { id };
},
- delay: 150,
+ delay,
},
];
-export const acidConsistencyFixtures: Interceptor[] = [
- ...acidTodoFixtures,
+export const acidCollectionFixtures: Interceptor[] = [
+ ...acidIssueFixtures,
{
- endpoint: getUser,
+ endpoint: getRepo,
response({ id }) {
- const user = this.users.find(item => item.id == id);
- if (!user) return { id, todos: [] };
+ const repo = this.repos.find(item => item.id == id);
+ if (!repo) return { id, issues: [] };
return {
- ...user,
- todos: this.todos.filter(todo => todo.userId == id),
+ ...repo,
+ issues: this.issues.filter(issue => issue.repoId == id),
};
},
- delay: 150,
+ delay,
},
];
-export const acidRollbackFixtures: Interceptor[] = [
- getTodoListInterceptor,
+export const acidRollbackFixtures: Interceptor[] = [
+ getIssueListInterceptor,
+ getIssueInterceptor,
{
- endpoint: createTodo,
+ endpoint: partialUpdateIssue,
response() {
throw Object.assign(new Error('Internal Server Error'), {
status: 500,
@@ -177,36 +197,37 @@ export const acidRollbackFixtures: Interceptor[] = [
},
];
-export const acidSideEffectFixtures: Interceptor[] = [
- getTodoListInterceptor,
+export const acidTradeFixtures: Interceptor[] = [
{
- endpoint: getUser,
- response({ id }) {
- const user = this.users.find(item => item.id == id);
- if (!user) return { id, todoCount: 0 };
- return { ...user };
+ endpoint: getAccount,
+ response() {
+ return { ...this.account };
+ },
+ delay,
+ },
+ {
+ endpoint: getTradeList,
+ response() {
+ return this.trades;
},
- delay: 150,
+ delay,
},
{
- endpoint: createTodo,
+ endpoint: createTrade,
response(body) {
- const todo = {
- completed: false,
- userId: '1',
+ const trade = {
+ amount: 10,
+ coin: 'DOGE',
...body,
id: uuid(),
};
- this.todos.push(todo);
- const user = this.users.find(item => item.id === '1');
- if (user) {
- user.todoCount = this.todos.filter(item => item.userId === '1').length;
- }
+ this.trades.push(trade);
+ this.account.balance -= trade.amount;
return {
- todo,
- user: user ? { ...user } : { id: '1', todoCount: 0 },
+ trade,
+ account: { ...this.account },
};
},
- delay: 150,
+ delay,
},
];
From 7ab1e091bbe71d20a847b0ab6587f6ce413cb32d Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 13 Aug 2026 12:01:57 -0400
Subject: [PATCH 05/12] docs: Contrast ACID playgrounds with typical
independent caches
The working case looks uneventful unless tearing is visible beside it.
Co-authored-by: Cursor
---
docs/core/concepts/acid.md | 40 +++---
docs/core/shared/_acidCollections.mdx | 126 ++++++++++++-----
docs/core/shared/_acidCreate.mdx | 107 +++++++++-----
docs/core/shared/_acidDelete.mdx | 96 +++++++++----
docs/core/shared/_acidIdentity.mdx | 108 ++++++++------
docs/core/shared/_acidQuery.mdx | 67 +++++++--
docs/core/shared/_acidRest.mdx | 97 +++++++++----
docs/core/shared/_acidRollback.mdx | 132 +++++++++++++-----
docs/core/shared/_acidSideEffects.mdx | 71 ++++++++--
docs/core/shared/_acidSnapshot.mdx | 87 ++++++++++--
docs/core/shared/_acidTransports.mdx | 101 +++++++++-----
docs/core/shared/_acidUpdate.mdx | 108 +++++++++-----
docs/core/shared/_acidValidate.mdx | 87 ++++++++++--
docs/rest/shared/_optimisticTransform.mdx | 19 +++
.../Playground/DesignSystem/Acid.tsx | 76 ++++++++++
.../Playground/DesignSystem/design-system.css | 116 +++++++++++++++
.../Playground/DesignSystem/index.ts | 1 +
.../src/components/Playground/monaco-init.ts | 4 +
18 files changed, 1111 insertions(+), 332 deletions(-)
create mode 100644 website/src/components/Playground/DesignSystem/Acid.tsx
diff --git a/docs/core/concepts/acid.md b/docs/core/concepts/acid.md
index 427c0cdf4812..787ffcdc54d4 100644
--- a/docs/core/concepts/acid.md
+++ b/docs/core/concepts/acid.md
@@ -52,8 +52,8 @@ tearing* — flashes of inconsistent state as usages update one by one.
into the one copy of that entity. Every consumer of that [pk](/rest/api/Entity#pk)
updates together. [Read more about defining other update endpoints](/rest/guides/side-effects).
-Close an issue. The list and the detail pane update together — no flash of
-one view lagging.
+Close an issue. Data Client updates the list and the detail together.
+Typical independent caches close the detail and leave the list open.
@@ -65,8 +65,8 @@ Created entities are immediately available. They are added to existing
[.unshift](/rest/api/RestEndpoint#unshift), or
[.assign](/rest/api/RestEndpoint#assign).
-Open an issue. It appears in the list and is immediately readable with
-[get](/rest/api/resource#get) — never invisible, never an orphan.
+Open an issue. Data Client adds it to the list and the newest detail.
+Typical independent caches show it in detail while the list still misses it.
@@ -75,8 +75,8 @@ Open an issue. It appears in the list and is immediately readable with
[schema.Invalidate](/rest/api/Invalidate) removes the entity.
[Resource.delete](/rest/api/resource#delete) provides such an endpoint.
-Delete an issue. It disappears from the list and the detail pane in the same
-commit.
+Delete an issue. Data Client removes it from the list and the detail together.
+Typical independent caches clear the detail and leave a ghost in the list.
@@ -85,7 +85,8 @@ commit.
Optimistic updates apply as that same snapshot. If the network fails, they
roll back as that snapshot.
-Close an issue. It flips immediately, then snaps back when the server errors.
+Close an issue. Data Client flips both views, then rolls both back on the 500.
+Typical independent caches roll the detail back and leave the list closed.
@@ -97,7 +98,8 @@ and refetching the others can fail partway — a flash of torn state.
[See mutation side-effects](/rest/guides/side-effects) for the full pattern.
-Buy DOGE. The trade list and the account balance update together.
+Buy DOGE. Data Client records the trade and the new balance in one commit.
+Typical independent caches append the trade and leave the balance stale.
@@ -113,7 +115,8 @@ That prevents *data tearing* — the same issue showing two different values.
[getList](/rest/api/resource#getlist) and [get](/rest/api/resource#get) is the
**same object** — the same value, wherever it is embedded.
-Select an issue, then close it. `fromList === get` stays true.
+Select an issue, then close it. Data Client `getList` and `get` stay locked
+together. Typical independent caches keep two copies that drift.
@@ -123,7 +126,8 @@ When [Collection.argsKey](/rest/api/Collection#argskey) and
[Collection.nestKey](/rest/api/Collection#nestkey) return the same shape, a nested
list and a top-level list are the **same array**.
-Close an issue. `repo.issues === getList` stays true, and both columns update.
+Close an issue. Data Client updates the repo page and the issues tab together.
+Typical independent caches update one list and leave the other open.
@@ -132,7 +136,8 @@ Close an issue. `repo.issues === getList` stays true, and both columns update.
[Query](/rest/api/Query) derived values stay consistent for the same reason —
they read the entity table, not a copy.
-Close issues. The open count updates without refetching.
+Close issues. Data Client drops the open count immediately. Typical
+independent caches keep a stale count.
@@ -141,7 +146,8 @@ Close issues. The open count updates without refetching.
[Entity.validate()](./validation.md) is the check constraint. Invalid responses
are not committed.
-Switch between payloads. Only the valid article renders.
+Switch between payloads. Data Client rejects invalid articles and keeps the
+last good commit. Typical independent caches render the malformed fields.
@@ -151,8 +157,8 @@ The same entity is the same value whether it arrived from fetch, initial
load, [Controller.set()](../api/Controller.md#set), or a
[websocket](./managers.md#data-stream).
-Click **Alice closed this**. The list and the detail pane update — no copy
-left behind.
+Click **Alice closed this**. Data Client updates the list and the detail.
+Typical independent caches update a local detail copy and leave the list behind.
@@ -194,7 +200,8 @@ Reactive Data Client handles them automatically.
All hooks in one render read the same snapshot, so the tree never paints mixed
old and new values.
-Close an issue. `list` and `query` in that row always agree.
+Close an issue. Data Client only paints matching `list`/`query` pairs. Typical
+independent caches record a mixed-version paint.
@@ -211,7 +218,8 @@ edit) commits to the server. Use a form when the friction is the point —
publish, purchase.
Close some issues, type a draft comment, then simulate a crash. Data Client
-refetches from the server and the closes are still there. The draft is gone.
+refetches the closes from the server. Typical independent caches lose the
+closes. Both lose the draft.
diff --git a/docs/core/shared/_acidCollections.mdx b/docs/core/shared/_acidCollections.mdx
index 4e8d26eac80c..1aaa1cba9206 100644
--- a/docs/core/shared/_acidCollections.mdx
+++ b/docs/core/shared/_acidCollections.mdx
@@ -62,57 +62,107 @@ export const getRepo = new RestEndpoint({
});
```
+```ts title="TypicalCache" collapsed
+export function useTypicalCollections(seed) {
+ const [repoIssues, setRepoIssues] = React.useState(() =>
+ seed.map(issue => ({ ...issue })),
+ );
+ const [tabIssues] = React.useState(() =>
+ seed.map(issue => ({ ...issue })),
+ );
+ const update = (id, body) =>
+ setRepoIssues(current =>
+ current.map(issue => (issue.id === id ? { ...issue, ...body } : issue)),
+ );
+ const torn = repoIssues.some(
+ (issue, i) => issue.state !== tabIssues[i].state,
+ );
+ return { repoIssues, tabIssues, update, torn };
+}
+```
+
```tsx title="IssuePage" collapsed
import { useController, useSuspense } from '@data-client/react';
import { getIssues, updateIssue } from './IssueResource';
import { getRepo } from './RepoResource';
+import { useTypicalCollections } from './TypicalCache';
function IssuePage() {
const ctrl = useController();
const repo = useSuspense(getRepo, { id: '1' });
const issues = useSuspense(getIssues, { repoId: '1' });
- const handleToggle = issue =>
- ctrl.fetch(
- updateIssue,
- { id: issue.id },
- { state: issue.state === 'open' ? 'closed' : 'open' },
- );
+ const typical = useTypicalCollections(issues);
+ const handleToggle = issue => {
+ const state = issue.state === 'open' ? 'closed' : 'open';
+ ctrl.fetch(updateIssue, { id: issue.id }, { state });
+ typical.update(issue.id, { state });
+ };
return (
-
-
- repo.issues === getList: {String(repo.issues === issues)}
-
-
-
-
-
Repo page
- {repo.issues.map(issue => (
-
- {issue.title}
-
{issue.state}
-
handleToggle(issue)}>
- {issue.state === 'open' ? 'Close' : 'Reopen'}
-
+ {repo.issues.map(issue => (
+
+ handleToggle(issue)}>
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
+ ))}
+
+
+
+
+ Repo page
+ {repo.issues.map(issue => (
+
+ ))}
+
+
+ Issues tab
+ {issues.map(issue => (
+
+ ))}
+
+
+
+
+
+
+ Repo page
+ {typical.repoIssues.map(issue => (
+
+ ))}
- ))}
-
-
-
Issues tab
- {issues.map(issue => (
-
- {issue.title}
-
{issue.state}
+
+ Issues tab
+ {typical.tabIssues.map(issue => (
+
+ ))}
- ))}
-
-
+
+
+
);
}
diff --git a/docs/core/shared/_acidCreate.mdx b/docs/core/shared/_acidCreate.mdx
index 10bc55ad9895..b947be2b886d 100644
--- a/docs/core/shared/_acidCreate.mdx
+++ b/docs/core/shared/_acidCreate.mdx
@@ -25,52 +25,93 @@ export const IssueResource = resource({
});
```
-```tsx title="IssuePage" {13}
+```ts title="TypicalCache" collapsed
+export function useTypicalCreate(seed) {
+ const [list] = React.useState(() => seed.map(issue => ({ ...issue })));
+ const [orphan, setOrphan] = React.useState(null);
+ const create = title =>
+ setOrphan({ id: 'local', title, state: 'open' });
+ return { list, orphan, create, torn: orphan != null };
+}
+```
+
+```tsx title="IssuePage" {14}
import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
+import { useTypicalCreate } from './TypicalCache';
function IssuePage() {
const ctrl = useController();
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
- const issue = useSuspense(IssueResource.get, {
- id: issues[issues.length - 1].id,
- });
+ const typical = useTypicalCreate(issues);
+ const newest = issues[issues.length - 1];
+ const issue = useSuspense(IssueResource.get, { id: newest.id });
const handleKeyDown = e => {
if (e.key === 'Enter' && e.currentTarget.value.trim()) {
- ctrl.fetch(IssueResource.getList.push, {
- repoId: '1',
- title: e.currentTarget.value,
- });
+ const title = e.currentTarget.value;
+ ctrl.fetch(IssueResource.getList.push, { repoId: '1', title });
+ typical.create(title);
e.currentTarget.value = '';
}
};
return (
-
-
- {issues.map(item => (
-
- {item.title}
-
- ))}
-
-
-
-
-
-
Newest
-
{issue.title}
-
{issue.state}
+
+
+
+
+
+
+
+ {issues.map(item => (
+
+ ))}
+
+
+
Newest
+
{issue.title}
+
+
+
+
+
+
+
+ {typical.list.map(item => (
+
+ ))}
+
+
+
Newest
+ {typical.orphan ?
+ <>
+
{typical.orphan.title}
+
+ >
+ : <>
+
{newest.title}
+
+ >
+ }
+
+
+
+
);
}
diff --git a/docs/core/shared/_acidDelete.mdx b/docs/core/shared/_acidDelete.mdx
index 9d39602a3759..2a2f5d958bab 100644
--- a/docs/core/shared/_acidDelete.mdx
+++ b/docs/core/shared/_acidDelete.mdx
@@ -25,52 +25,88 @@ export const IssueResource = resource({
});
```
-```tsx title="IssuePage" {14}
+```ts title="TypicalCache" collapsed
+export function useTypicalDelete(seed) {
+ const [list] = React.useState(() => seed.map(issue => ({ ...issue })));
+ const [deleted, setDeleted] = React.useState({});
+ const remove = id => setDeleted(current => ({ ...current, [id]: true }));
+ return { list, deleted, remove };
+}
+```
+
+```tsx title="IssuePage" {16}
import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
+import { useTypicalDelete } from './TypicalCache';
function IssuePage() {
const ctrl = useController();
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const typical = useTypicalDelete(issues);
const [id, setId] = React.useState(issues[0]?.id);
const selected = issues.find(item => item.id === id) ?? issues[0];
const issue = useSuspense(
IssueResource.get,
selected ? { id: selected.id } : null,
);
- const handleDelete = () =>
+ const handleDelete = () => {
ctrl.fetch(IssueResource.delete, { id: selected.id });
+ typical.remove(selected.id);
+ };
return (
-
-
- {issues.map(item => (
-
setId(item.id)}
- >
- {item === selected ?
-
{item.title}
- : item.title}
+
+
+
+
+ {issues.map(item => (
+ setId(item.id)}
+ />
+ ))}
+
+
+ {issue ?
+
+ {issue.title}
+
+
+ :
No issues }
+
+
+
+ 0}
+ >
+
+
+ {typical.list.map(item => (
+ setId(item.id)}
+ />
+ ))}
- ))}
-
-
- {issue ?
-
- {issue.title}
-
+
+ {typical.deleted[id] ?
+
Deleted
+ :
+ {typical.list.find(item => item.id === id)?.title}
+
+
+ }
- :
No issues }
-
-
+
+
+
);
}
render(
);
diff --git a/docs/core/shared/_acidIdentity.mdx b/docs/core/shared/_acidIdentity.mdx
index ba8fab67ab81..0b489798a505 100644
--- a/docs/core/shared/_acidIdentity.mdx
+++ b/docs/core/shared/_acidIdentity.mdx
@@ -25,59 +25,85 @@ export const IssueResource = resource({
});
```
-```tsx title="IssuePage" {6,8,9,19}
+```ts title="TypicalCache" collapsed
+export function useTypicalIdentity(seed) {
+ const [list] = React.useState(() => seed.map(issue => ({ ...issue })));
+ const [details, setDetails] = React.useState(() =>
+ Object.fromEntries(seed.map(issue => [issue.id, { ...issue }])),
+ );
+ const view = id => details[id];
+ const update = (id, body) =>
+ setDetails(current => ({
+ ...current,
+ [id]: { ...current[id], ...body },
+ }));
+ const torn = id => list.find(issue => issue.id === id)?.state !== view(id)?.state;
+ return { list, view, update, torn };
+}
+```
+
+```tsx title="IssuePage" {7,10,11}
import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
+import { useTypicalIdentity } from './TypicalCache';
function IssuePage() {
const ctrl = useController();
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const typical = useTypicalIdentity(issues);
const [id, setId] = React.useState(issues[0].id);
const issue = useSuspense(IssueResource.get, { id });
const fromList = issues.find(item => item.id === id);
- const handleToggle = () =>
- ctrl.fetch(
- IssueResource.partialUpdate,
- { id },
- { state: issue.state === 'open' ? 'closed' : 'open' },
- );
+ const handleToggle = () => {
+ const state = issue.state === 'open' ? 'closed' : 'open';
+ ctrl.fetch(IssueResource.partialUpdate, { id }, { state });
+ typical.update(id, { state });
+ };
return (
-
- fromList === get: {String(fromList === issue)}
-
-
-
- {issues.map(item => (
-
setId(item.id)}
- >
- {item.id === id ?
-
{item.title}
- : item.title}
-
{item.state}
+ {issues.map(item => (
+
setId(item.id)}
+ />
+ ))}
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
+
+
+
+ getList
+
+
+
+ get
+
+
+
+
+
+
+
+ getList
+ item.id === id).state}
+ />
+
+
+ get
+
- ))}
-
-
-
{issue.title}
-
- {issue.state}
-
-
- {issue.state === 'open' ? 'Close' : 'Reopen'}
-
-
-
+
+
+
);
}
diff --git a/docs/core/shared/_acidQuery.mdx b/docs/core/shared/_acidQuery.mdx
index bcc08c3ce641..2afe7944b6eb 100644
--- a/docs/core/shared/_acidQuery.mdx
+++ b/docs/core/shared/_acidQuery.mdx
@@ -30,34 +30,75 @@ export const openCount = new Query(
);
```
+```ts title="TypicalCache" collapsed
+export function useTypicalQuery(seed) {
+ const [list, setList] = React.useState(() =>
+ seed.map(issue => ({ ...issue })),
+ );
+ const [open] = React.useState(
+ () => seed.filter(issue => issue.state === 'open').length,
+ );
+ const update = (id, body) =>
+ setList(current =>
+ current.map(issue => (issue.id === id ? { ...issue, ...body } : issue)),
+ );
+ const liveOpen = list.filter(issue => issue.state === 'open').length;
+ return { list, open, update, torn: open !== liveOpen };
+}
+```
+
```tsx title="IssuePage" collapsed
import { useController, useQuery, useSuspense } from '@data-client/react';
import { openCount, IssueResource } from './IssueResource';
+import { useTypicalQuery } from './TypicalCache';
function IssuePage() {
const ctrl = useController();
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
const open = useQuery(openCount, { repoId: '1' });
- const handleToggle = issue =>
- ctrl.fetch(
- IssueResource.partialUpdate,
- { id: issue.id },
- { state: issue.state === 'open' ? 'closed' : 'open' },
- );
+ const typical = useTypicalQuery(issues);
+ const handleToggle = issue => {
+ const state = issue.state === 'open' ? 'closed' : 'open';
+ ctrl.fetch(IssueResource.partialUpdate, { id: issue.id }, { state });
+ typical.update(issue.id, { state });
+ };
return (
-
- {open} open
-
{issues.map(issue => (
-
- {issue.title}
- {issue.state}
+
handleToggle(issue)}>
{issue.state === 'open' ? 'Close' : 'Reopen'}
-
+
))}
+
+
+
+
+
+ {' '}
+ open
+
+
+
+
+
+
+ {typical.open}
+
+ {' '}
+ open
+
+
+
);
}
diff --git a/docs/core/shared/_acidRest.mdx b/docs/core/shared/_acidRest.mdx
index 9046755928ea..aa52f1ca6c29 100644
--- a/docs/core/shared/_acidRest.mdx
+++ b/docs/core/shared/_acidRest.mdx
@@ -25,57 +25,102 @@ export const IssueResource = resource({
});
```
+```ts title="TypicalCache" collapsed
+const SEED = [
+ { id: '3', repoId: '1', title: 'Rate limit the API', state: 'closed' },
+ { id: '1', repoId: '1', title: 'Fix login timeout', state: 'open' },
+ { id: '2', repoId: '1', title: 'Document ACID guarantees', state: 'open' },
+];
+
+export function useTypicalSession() {
+ const [list, setList] = React.useState(() =>
+ SEED.map(issue => ({ ...issue })),
+ );
+ const update = (id, body) =>
+ setList(current =>
+ current.map(issue => (issue.id === id ? { ...issue, ...body } : issue)),
+ );
+ return { list, update };
+}
+```
+
```tsx title="Session" collapsed
import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
+import { useTypicalSession } from './TypicalCache';
-export default function Session() {
+export default function Session({ draft, setDraft }) {
const ctrl = useController();
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
- const [draft, setDraft] = React.useState('');
- const handleToggle = issue =>
- ctrl.fetch(
- IssueResource.partialUpdate,
- { id: issue.id },
- { state: issue.state === 'open' ? 'closed' : 'open' },
- );
+ const typical = useTypicalSession();
+ const handleToggle = issue => {
+ const state = issue.state === 'open' ? 'closed' : 'open';
+ ctrl.fetch(IssueResource.partialUpdate, { id: issue.id }, { state });
+ typical.update(issue.id, { state });
+ };
return (
-
+
+
+ ctrl.fetch — survives crash
+ {issues.map(issue => (
+
+ handleToggle(issue)}>
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
+ ))}
+
+
+ useState — lost on crash
+ {typical.list.map(issue => (
+
+ ))}
+
+
);
}
```
-```tsx title="App" {8-9}
+```tsx title="App" {9}
import { useController } from '@data-client/react';
import Session from './Session';
function App() {
const ctrl = useController();
const [session, setSession] = React.useState(0);
+ const [draft, setDraft] = React.useState('');
const handleCrash = async () => {
await ctrl.resetEntireStore();
+ setDraft('');
setSession(s => s + 1);
};
return (
);
diff --git a/docs/core/shared/_acidRollback.mdx b/docs/core/shared/_acidRollback.mdx
index 8440bce5dec8..40aa18b7ee88 100644
--- a/docs/core/shared/_acidRollback.mdx
+++ b/docs/core/shared/_acidRollback.mdx
@@ -25,53 +25,111 @@ export const IssueResource = resource({
});
```
-```tsx title="IssuePage" {10-14}
+```ts title="TypicalCache" collapsed
+export function useTypicalRollback(seed) {
+ const [list, setList] = React.useState(() =>
+ seed.map(issue => ({ ...issue })),
+ );
+ const [details, setDetails] = React.useState(() =>
+ Object.fromEntries(seed.map(issue => [issue.id, { ...issue }])),
+ );
+ const timers = React.useRef
>>(
+ {},
+ );
+ React.useEffect(
+ () => () => {
+ Object.values(timers.current).forEach(clearTimeout);
+ },
+ [],
+ );
+ const view = id => details[id];
+ const update = (id, state) => {
+ if (timers.current[id]) clearTimeout(timers.current[id]);
+ let prev = state;
+ setDetails(current => {
+ prev = current[id].state;
+ return { ...current, [id]: { ...current[id], state } };
+ });
+ setList(current =>
+ current.map(issue => (issue.id === id ? { ...issue, state } : issue)),
+ );
+ timers.current[id] = setTimeout(() => {
+ setDetails(current => ({
+ ...current,
+ [id]: { ...current[id], state: prev },
+ }));
+ }, 500);
+ };
+ const torn = id =>
+ list.find(issue => issue.id === id)?.state !== view(id)?.state;
+ return { list, view, update, torn };
+}
+```
+
+```tsx title="IssuePage" {13}
import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
+import { useTypicalRollback } from './TypicalCache';
function IssuePage() {
const ctrl = useController();
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const typical = useTypicalRollback(issues);
const [id, setId] = React.useState(issues[0].id);
const issue = useSuspense(IssueResource.get, { id });
- const handleToggle = () =>
- ctrl.fetch(
- IssueResource.partialUpdate,
- { id },
- { state: issue.state === 'open' ? 'closed' : 'open' },
- );
+ const handleToggle = () => {
+ const state = issue.state === 'open' ? 'closed' : 'open';
+ ctrl.fetch(IssueResource.partialUpdate, { id }, { state });
+ typical.update(id, state);
+ };
return (
-
-
- {issues.map(item => (
-
setId(item.id)}
- >
- {item.id === id ?
-
{item.title}
- : item.title}
-
{item.state}
+
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
+
+
+
+ {issues.map(item => (
+ setId(item.id)}
+ />
+ ))}
+
+
+
+
+
+
+
+ {typical.list.map(item => (
+ setId(item.id)}
+ />
+ ))}
+
+
+
{typical.view(id).title}
+
+
- ))}
-
-
-
{issue.title}
-
- {issue.state}
-
-
- {issue.state === 'open' ? 'Close' : 'Reopen'}
-
-
+
+
);
}
diff --git a/docs/core/shared/_acidSideEffects.mdx b/docs/core/shared/_acidSideEffects.mdx
index 90185aa46571..78e9ab114165 100644
--- a/docs/core/shared/_acidSideEffects.mdx
+++ b/docs/core/shared/_acidSideEffects.mdx
@@ -45,28 +45,81 @@ export const TradeResource = resource({
}));
```
+```ts title="TypicalCache" collapsed
+export function useTypicalTrade(seedTrades, seedBalance) {
+ const [trades, setTrades] = React.useState(() =>
+ seedTrades.map(trade => ({ ...trade })),
+ );
+ const [balance] = React.useState(seedBalance);
+ const initialCount = React.useRef(seedTrades.length);
+ const buy = () =>
+ setTrades(current => [
+ ...current,
+ { id: `local-${current.length}`, amount: 10, coin: 'DOGE' },
+ ]);
+ return {
+ trades,
+ balance,
+ buy,
+ torn: trades.length > initialCount.current,
+ };
+}
+```
+
```tsx title="TradePage" collapsed
import { useController, useSuspense } from '@data-client/react';
import { AccountResource } from './AccountResource';
import { TradeResource } from './TradeResource';
+import { useTypicalTrade } from './TypicalCache';
function TradePage() {
const ctrl = useController();
const account = useSuspense(AccountResource.get, { id: '1' });
const trades = useSuspense(TradeResource.getList);
- const handleBuy = () =>
+ const typical = useTypicalTrade(trades, account.balance);
+ const handleBuy = () => {
ctrl.fetch(TradeResource.create, { amount: 10, coin: 'DOGE' });
+ typical.buy();
+ };
return (
-
- Balance: {account.balance} USD
-
- {trades.map(trade => (
-
- {trade.amount} {trade.coin}
-
- ))}
Buy 10 DOGE
+
+
+
+ Balance:{' '}
+
+
+
+
+ {trades.map(trade => (
+
+ {trade.amount} {trade.coin}
+
+ ))}
+
+
+
+ Balance:{' '}
+
+
+ {typical.balance} USD
+
+
+
+ {typical.trades.map(trade => (
+
+ {trade.amount} {trade.coin}
+
+ ))}
+
+
);
}
diff --git a/docs/core/shared/_acidSnapshot.mdx b/docs/core/shared/_acidSnapshot.mdx
index 352c93aafbfd..ddb468ac1005 100644
--- a/docs/core/shared/_acidSnapshot.mdx
+++ b/docs/core/shared/_acidSnapshot.mdx
@@ -25,50 +25,117 @@ export const IssueResource = resource({
});
```
-```tsx title="IssueRow" {6-9,24-25}
+```ts title="TypicalCache" collapsed
+export function useTypicalSnapshot(seed) {
+ const [copies, setCopies] = React.useState(() =>
+ Object.fromEntries(
+ seed.map(issue => [
+ issue.id,
+ { list: issue.state, query: issue.state },
+ ]),
+ ),
+ );
+ const toggle = id =>
+ setCopies(current => ({
+ ...current,
+ [id]: {
+ list: current[id].list === 'open' ? 'closed' : 'open',
+ query: current[id].query,
+ },
+ }));
+ return { copies, toggle };
+}
+```
+
+```tsx title="IssueRow" {12-15}
import { useController, useQuery, useSuspense } from '@data-client/react';
import { Issue, IssueResource } from './IssueResource';
-export default function IssueRow({ id }: { id: string }) {
+export default function IssueRow({
+ id,
+ onToggle,
+}: {
+ id: string;
+ onToggle: () => void;
+}) {
const ctrl = useController();
const fromList = useSuspense(IssueResource.getList, {
repoId: '1',
}).find(issue => issue.id === id);
const fromQuery = useQuery(Issue, { id });
if (!fromList) return null;
- const handleToggle = () =>
+ const handleToggle = () => {
ctrl.fetch(
IssueResource.partialUpdate,
{ id },
{ state: fromList.state === 'open' ? 'closed' : 'open' },
);
+ onToggle();
+ };
return (
{fromList.title}
{fromList.state === 'open' ? 'Close' : 'Reopen'}
-
- list={fromList.state} query={fromQuery?.state} same render=
- {String(fromList.state === fromQuery?.state)}
-
+
);
}
```
```tsx title="IssueList" collapsed
-import { useSuspense } from '@data-client/react';
-import { IssueResource } from './IssueResource';
+import { useQuery, useSuspense } from '@data-client/react';
+import { Issue, IssueResource } from './IssueResource';
import IssueRow from './IssueRow';
+import { useTypicalSnapshot } from './TypicalCache';
function IssueList() {
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const typical = useTypicalSnapshot(issues);
+ const [active, setActive] = React.useState(issues[0].id);
+ const fromList = issues.find(issue => issue.id === active);
+ const fromQuery = useQuery(Issue, { id: active });
+ const other = typical.copies[active];
+ const torn = other.list !== other.query;
return (
{issues.map(issue => (
-
+
{
+ typical.toggle(issue.id);
+ setActive(issue.id);
+ }}
+ />
))}
+
+
+
+
+ list
+
+
+
+ query
+
+
+
+
+
+
+
+ list
+
+
+
+ query
+
+
+
+
+
);
}
diff --git a/docs/core/shared/_acidTransports.mdx b/docs/core/shared/_acidTransports.mdx
index bb980e0d0015..e8253aedb950 100644
--- a/docs/core/shared/_acidTransports.mdx
+++ b/docs/core/shared/_acidTransports.mdx
@@ -25,20 +25,39 @@ export const IssueResource = resource({
});
```
-```tsx title="IssuePage" {10-13}
+```ts title="TypicalCache" collapsed
+export function useTypicalPush(seed) {
+ const [list] = React.useState(() => seed.map(issue => ({ ...issue })));
+ const [details, setDetails] = React.useState(() =>
+ Object.fromEntries(seed.map(issue => [issue.id, { ...issue }])),
+ );
+ const view = id => details[id];
+ const update = (id, body) =>
+ setDetails(current => ({
+ ...current,
+ [id]: { ...current[id], ...body },
+ }));
+ const torn = id => list.find(issue => issue.id === id)?.state !== view(id)?.state;
+ return { list, view, update, torn };
+}
+```
+
+```tsx title="IssuePage" {13}
import { useController, useSuspense } from '@data-client/react';
import { Issue, IssueResource } from './IssueResource';
+import { useTypicalPush } from './TypicalCache';
function IssuePage() {
const ctrl = useController();
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const typical = useTypicalPush(issues);
const [id, setId] = React.useState(issues[0].id);
const issue = useSuspense(IssueResource.get, { id });
- const handlePush = () =>
- ctrl.set(Issue, { id }, current => ({
- ...current,
- state: current.state === 'open' ? 'closed' : 'open',
- }));
+ const handlePush = () => {
+ const state = issue.state === 'open' ? 'closed' : 'open';
+ ctrl.set(Issue, { id }, current => ({ ...current, state }));
+ typical.update(id, { state });
+ };
return (
@@ -46,35 +65,49 @@ function IssuePage() {
'Alice closed this'
: 'Alice reopened this'}
-
-
- {issues.map(item => (
-
setId(item.id)}
- >
- {item.id === id ?
-
{item.title}
- : item.title}
-
{item.state}
+
+
+
+
+ {issues.map(item => (
+ setId(item.id)}
+ />
+ ))}
+
+
+
+
+
+
+
+ {typical.list.map(item => (
+ setId(item.id)}
+ />
+ ))}
+
+
+
{typical.view(id).title}
+
- ))}
-
-
-
{issue.title}
-
- {issue.state}
-
-
-
+
+
+
);
}
diff --git a/docs/core/shared/_acidUpdate.mdx b/docs/core/shared/_acidUpdate.mdx
index 87138eb4a962..3b193c56b8a9 100644
--- a/docs/core/shared/_acidUpdate.mdx
+++ b/docs/core/shared/_acidUpdate.mdx
@@ -25,53 +25,87 @@ export const IssueResource = resource({
});
```
-```tsx title="IssuePage" {10-14}
+```ts title="TypicalCache" collapsed
+export function useTypicalUpdate(seed) {
+ const [list] = React.useState(() => seed.map(issue => ({ ...issue })));
+ const [details, setDetails] = React.useState(() =>
+ Object.fromEntries(seed.map(issue => [issue.id, { ...issue }])),
+ );
+ const view = id => details[id];
+ const update = (id, body) =>
+ setDetails(current => ({
+ ...current,
+ [id]: { ...current[id], ...body },
+ }));
+ const torn = id => list.find(issue => issue.id === id)?.state !== view(id)?.state;
+ return { list, view, update, torn };
+}
+```
+
+```tsx title="IssuePage" {13}
import { useController, useSuspense } from '@data-client/react';
import { IssueResource } from './IssueResource';
+import { useTypicalUpdate } from './TypicalCache';
function IssuePage() {
const ctrl = useController();
const issues = useSuspense(IssueResource.getList, { repoId: '1' });
+ const typical = useTypicalUpdate(issues);
const [id, setId] = React.useState(issues[0].id);
const issue = useSuspense(IssueResource.get, { id });
- const handleToggle = () =>
- ctrl.fetch(
- IssueResource.partialUpdate,
- { id },
- { state: issue.state === 'open' ? 'closed' : 'open' },
- );
+ const handleToggle = () => {
+ const state = issue.state === 'open' ? 'closed' : 'open';
+ ctrl.fetch(IssueResource.partialUpdate, { id }, { state });
+ typical.update(id, { state });
+ };
return (
-
-
- {issues.map(item => (
-
setId(item.id)}
- >
- {item.id === id ?
-
{item.title}
- : item.title}
-
{item.state}
+
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
+
+
+
+ {issues.map(item => (
+ setId(item.id)}
+ />
+ ))}
+
+
+
+
+
+
+
+ {typical.list.map(item => (
+ setId(item.id)}
+ />
+ ))}
+
+
+
{typical.view(id).title}
+
+
- ))}
-
-
-
{issue.title}
-
- {issue.state}
-
-
- {issue.state === 'open' ? 'Close' : 'Reopen'}
-
-
+
+
);
}
diff --git a/docs/core/shared/_acidValidate.mdx b/docs/core/shared/_acidValidate.mdx
index 71eb596d4213..5a0c85ca3e6e 100644
--- a/docs/core/shared/_acidValidate.mdx
+++ b/docs/core/shared/_acidValidate.mdx
@@ -20,9 +20,27 @@ args: [{ id: 3 }],
response: { id: '3', title: { complex: 'second', object: 5 } },
delay: 150,
},
+{
+endpoint: new RestEndpoint({path: '/raw-article/:id'}),
+args: [{ id: 1 }],
+response: { id: '1', title: 'first' },
+delay: 150,
+},
+{
+endpoint: new RestEndpoint({path: '/raw-article/:id'}),
+args: [{ id: 2 }],
+response: { id: '2' },
+delay: 150,
+},
+{
+endpoint: new RestEndpoint({path: '/raw-article/:id'}),
+args: [{ id: 3 }],
+response: { id: '3', title: { complex: 'second', object: 5 } },
+delay: 150,
+},
]}>
-```ts title="api/Article" {7-10}
+```ts title="api/Article" {5-8}
export class Article extends Entity {
id = '';
title = '';
@@ -39,17 +57,65 @@ export const getArticle = new RestEndpoint({
});
```
+```ts title="api/RawArticle" collapsed
+export class RawArticle extends Entity {
+ id = '';
+ title: string | object = '';
+}
+
+export const getRawArticle = new RestEndpoint({
+ path: '/raw-article/:id',
+ schema: RawArticle,
+});
+```
+
```tsx title="ArticlePage" collapsed
-import { getArticle } from './api/Article';
+import { useQuery, useSuspense } from '@data-client/react';
+import { Article, getArticle } from './api/Article';
+import { getRawArticle } from './api/RawArticle';
-export default function ArticlePage({ id }: { id: string }) {
+export function ValidatedPane({ id }: { id: string }) {
+ const lastGood = useQuery(Article, { id: '1' });
+ return (
+
+
+
+ Rejected — not committed
+
+ {lastGood ?
+ Still in store: {lastGood.title}
+ : null}
+
+ }
+ >
+
+
+
+ );
+}
+
+function ArticleBody({ id }: { id: string }) {
const article = useSuspense(getArticle, { id });
return
{article.title}
;
}
+
+export function RawArticlePage({ id }: { id: string }) {
+ const article = useSuspense(getRawArticle, { id });
+ return (
+
+ title:{' '}
+ {typeof article.title === 'string' ?
+ article.title || '""'
+ : JSON.stringify(article.title)}
+
+ );
+}
```
-```tsx title="Navigator"
-import ArticlePage from './ArticlePage';
+```tsx title="Navigator" collapsed
+import { ValidatedPane, RawArticlePage } from './ArticlePage';
function Navigator() {
const [id, setId] = React.useState('1');
@@ -64,9 +130,14 @@ function Navigator() {
setId(e.currentTarget.value)}>
Wrong type
-
}>
-
-
+
+
+
+
+
+
+
+
);
}
diff --git a/docs/rest/shared/_optimisticTransform.mdx b/docs/rest/shared/_optimisticTransform.mdx
index 14a1b9331a78..94f4e667d8ab 100644
--- a/docs/rest/shared/_optimisticTransform.mdx
+++ b/docs/rest/shared/_optimisticTransform.mdx
@@ -69,6 +69,20 @@ function CounterPage() {
const { count } = useSuspense(getCount);
const [stateCount, setStateCount] = React.useState(0);
const [responseCount, setResponseCount] = React.useState(0);
+ const [dcTrail, setDcTrail] = React.useState([0]);
+ const [otherTrail, setOtherTrail] = React.useState([0]);
+ React.useEffect(() => {
+ setDcTrail(trail =>
+ trail[trail.length - 1] === count ? trail : [...trail, count],
+ );
+ }, [count]);
+ React.useEffect(() => {
+ setOtherTrail(trail =>
+ trail[trail.length - 1] === responseCount ?
+ trail
+ : [...trail, responseCount],
+ );
+ }, [responseCount]);
const [clickHandler, loading, error] = useLoading(async () => {
setStateCount(stateCount + 1);
const val = await ctrl.fetch(increment);
@@ -100,6 +114,11 @@ function CounterPage() {
+
+ Data Client: {dcTrail.join(' → ')}
+
+ Other: {otherTrail.join(' → ')}
+
diff --git a/website/src/components/Playground/DesignSystem/Acid.tsx b/website/src/components/Playground/DesignSystem/Acid.tsx
new file mode 100644
index 000000000000..ea65a9b6c498
--- /dev/null
+++ b/website/src/components/Playground/DesignSystem/Acid.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+
+export function AcidCompare({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
+
+export function AcidPane({
+ title,
+ torn,
+ children,
+}: {
+ title: string;
+ torn?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+ {torn ?
+ Views disagree
+ : null}
+
+ {children}
+
+ );
+}
+
+export function IssueState({
+ state,
+ stale,
+}: {
+ state: 'open' | 'closed';
+ stale?: boolean;
+}) {
+ const cls = [
+ 'issueState',
+ `issueState--${state}`,
+ stale ? 'issueState--stale' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+ return
{state} ;
+}
+
+export function IssueRow({
+ title,
+ state,
+ selected,
+ ghost,
+ stale,
+ onClick,
+ children,
+}: {
+ title: string;
+ state: 'open' | 'closed';
+ selected?: boolean;
+ ghost?: boolean;
+ stale?: boolean;
+ onClick?: () => void;
+ children?: React.ReactNode;
+}) {
+ return (
+
+ {selected ?
+ {title}
+ : {title} }
+
+ {children}
+
+ );
+}
diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css
index 6319a164ff26..a24a81082322 100644
--- a/website/src/components/Playground/DesignSystem/design-system.css
+++ b/website/src/components/Playground/DesignSystem/design-system.css
@@ -168,3 +168,119 @@ html[data-theme='dark'] .small.rt-TextFieldRoot {
.rt-TextAreaRoot:focus-within {
border-color: var(--ifm-color-formfield-active);
}
+
+.acidCompare {
+ display: grid;
+ gap: 0.65rem;
+ container-type: inline-size;
+ container-name: acid;
+}
+.acidPane {
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: var(--ifm-global-radius);
+ padding: 0.5rem 0.65rem 0.65rem;
+ background: var(--ifm-background-color);
+}
+.acidPane--torn {
+ border-color: var(--ifm-color-danger);
+ background: repeating-linear-gradient(
+ -45deg,
+ transparent,
+ transparent 7px,
+ color-mix(in srgb, var(--ifm-color-danger) 10%, transparent) 7px,
+ color-mix(in srgb, var(--ifm-color-danger) 10%, transparent) 14px
+ );
+}
+.acidPaneHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 0.5rem;
+ margin-bottom: 0.45rem;
+}
+.acidPaneHeader > small {
+ font-weight: 650;
+}
+.acidTornLabel {
+ color: var(--ifm-color-danger);
+ font-size: 0.68rem;
+ font-weight: 750;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+.acidSplit {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5rem;
+}
+@container acid (max-width: 420px) {
+ .acidSplit {
+ grid-template-columns: 1fr;
+ }
+}
+.acidReadout {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ margin-bottom: 0.35rem;
+}
+.acidReadout > strong {
+ font-size: 1.05rem;
+}
+.acidReadout .issueState {
+ margin-left: 0;
+ align-self: flex-start;
+ font-size: 0.8rem;
+}
+.acidHistory {
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.45;
+}
+.acidHistory-mixed {
+ color: var(--ifm-color-danger);
+ font-weight: 700;
+}
+.issueState {
+ font-size: 0.65rem;
+ font-weight: 750;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ border-radius: 999px;
+ padding: 0.12em 0.55em;
+ line-height: 1.4;
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+.issueState--open {
+ color: var(--ifm-color-warning-darkest);
+ border: 1px solid var(--ifm-color-warning);
+ background: transparent;
+}
+html[data-theme='dark'] .issueState--open {
+ color: var(--ifm-color-warning-lightest);
+}
+.issueState--closed {
+ color: var(--ifm-color-gray-100);
+ background: var(--ifm-color-info-darkest);
+ border: 1px solid var(--ifm-color-info-darkest);
+}
+html[data-theme='dark'] .issueState--closed {
+ color: var(--ifm-color-gray-900);
+ background: var(--ifm-color-info-light);
+ border-color: var(--ifm-color-info-light);
+}
+.issueState--stale {
+ color: var(--ifm-color-danger-darkest);
+ background: var(--ifm-color-danger-contrast-background);
+ border-color: var(--ifm-color-danger);
+}
+html[data-theme='dark'] .issueState--stale {
+ color: var(--ifm-color-danger-lightest);
+}
+.issueRow--ghost {
+ opacity: 0.6;
+ outline: 1px dashed var(--ifm-color-danger);
+ outline-offset: -1px;
+ border-radius: 3px;
+}
diff --git a/website/src/components/Playground/DesignSystem/index.ts b/website/src/components/Playground/DesignSystem/index.ts
index 2756e0caa366..c8413f2c113c 100644
--- a/website/src/components/Playground/DesignSystem/index.ts
+++ b/website/src/components/Playground/DesignSystem/index.ts
@@ -2,6 +2,7 @@ export { CancelButton } from './CancelButton';
export { CurrentTime } from './CurrentTime';
export { Avatar } from './Avatar';
export { Formatted } from './Formatted';
+export { AcidCompare, AcidPane, IssueState, IssueRow } from './Acid';
import './design-system.css';
export { TextInput } from './TextInput';
export { TextArea } from './TextArea';
diff --git a/website/src/components/Playground/monaco-init.ts b/website/src/components/Playground/monaco-init.ts
index 44b8d3b17919..581237073861 100644
--- a/website/src/components/Playground/monaco-init.ts
+++ b/website/src/components/Playground/monaco-init.ts
@@ -263,6 +263,10 @@ if (
declare function uuid(): string;
declare function CurrentTime(props: {}):JSX.Element;
declare function CancelButton(props: { onClick?: () => void }):JSX.Element;
+ declare function AcidCompare(props: { children: React.ReactNode }):JSX.Element;
+ declare function AcidPane(props: { title: string; torn?: boolean; children: React.ReactNode }):JSX.Element;
+ declare function IssueState(props: { state: 'open' | 'closed'; stale?: boolean }):JSX.Element;
+ declare function IssueRow(props: { title: string; state: 'open' | 'closed'; selected?: boolean; ghost?: boolean; stale?: boolean; onClick?: () => void; children?: React.ReactNode }):JSX.Element;
declare function Avatar(props: { src: string }):JSX.Element;
declare function Formatted({ downColor, formatter, formatterFn, timeout, transition, transitionLength, upColor, value, stylePrefix, }: NumberProps):JSX.Element
declare function ResetableErrorBoundary(props: { children: React.ReactNode }):JSX.Element;
From 85455d58aa58335a79fd5847f06165d1d4c86cdb Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 13 Aug 2026 12:23:26 -0400
Subject: [PATCH 06/12] docs: Restyle ACID playgrounds as stacked instrument
panes
Hazard stripes and raw buttons made tearing look like a wireframe; a
quiet left-rule, chip, and toolbar make the contrast readable.
Co-authored-by: Cursor
---
docs/core/shared/_acidCollections.mdx | 5 +-
docs/core/shared/_acidCreate.mdx | 13 +-
docs/core/shared/_acidDelete.mdx | 19 +-
docs/core/shared/_acidIdentity.mdx | 13 +-
docs/core/shared/_acidQuery.mdx | 28 +-
docs/core/shared/_acidRest.mdx | 8 +-
docs/core/shared/_acidRollback.mdx | 21 +-
docs/core/shared/_acidSideEffects.mdx | 69 ++--
docs/core/shared/_acidSnapshot.mdx | 12 +-
docs/core/shared/_acidTransports.mdx | 25 +-
docs/core/shared/_acidUpdate.mdx | 21 +-
docs/core/shared/_acidValidate.mdx | 24 +-
.../Playground/DesignSystem/Acid.tsx | 27 +-
.../Playground/DesignSystem/design-system.css | 352 +++++++++++++++---
.../src/components/Playground/monaco-init.ts | 2 +-
15 files changed, 484 insertions(+), 155 deletions(-)
diff --git a/docs/core/shared/_acidCollections.mdx b/docs/core/shared/_acidCollections.mdx
index 1aaa1cba9206..6c38b39dee3a 100644
--- a/docs/core/shared/_acidCollections.mdx
+++ b/docs/core/shared/_acidCollections.mdx
@@ -111,7 +111,7 @@ function IssuePage() {
))}
-
+
Repo page
@@ -136,7 +136,8 @@ function IssuePage() {
diff --git a/docs/core/shared/_acidCreate.mdx b/docs/core/shared/_acidCreate.mdx
index b947be2b886d..6bbe29e66126 100644
--- a/docs/core/shared/_acidCreate.mdx
+++ b/docs/core/shared/_acidCreate.mdx
@@ -56,7 +56,7 @@ function IssuePage() {
};
return (
-
+
-
+
+ List
{issues.map(item => (
))}
-
+
Newest
{issue.title}
@@ -83,11 +84,13 @@ function IssuePage() {
+ List
{typical.list.map(item => (
))}
-
+
Newest
{typical.orphan ?
<>
diff --git a/docs/core/shared/_acidDelete.mdx b/docs/core/shared/_acidDelete.mdx
index 2a2f5d958bab..2b2682f25317 100644
--- a/docs/core/shared/_acidDelete.mdx
+++ b/docs/core/shared/_acidDelete.mdx
@@ -55,9 +55,10 @@ function IssuePage() {
};
return (
-
+
+ List
{issues.map(item => (
))}
-
+
+
Detail
{issue ?
{issue.title}
@@ -78,12 +80,14 @@ function IssuePage() {
-
0}
- >
+ 0}
+ >
+ List
{typical.list.map(item => (
))}
-
+
+
Detail
{typical.deleted[id] ?
Deleted
:
diff --git a/docs/core/shared/_acidIdentity.mdx b/docs/core/shared/_acidIdentity.mdx
index 0b489798a505..faea21ca7ea3 100644
--- a/docs/core/shared/_acidIdentity.mdx
+++ b/docs/core/shared/_acidIdentity.mdx
@@ -70,11 +70,13 @@ function IssuePage() {
onClick={() => setId(item.id)}
/>
))}
-
- {issue.state === 'open' ? 'Close' : 'Reopen'}
-
+
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
-
+
getList
@@ -87,7 +89,8 @@ function IssuePage() {
diff --git a/docs/core/shared/_acidQuery.mdx b/docs/core/shared/_acidQuery.mdx
index 2afe7944b6eb..a103ba2f3ff4 100644
--- a/docs/core/shared/_acidQuery.mdx
+++ b/docs/core/shared/_acidQuery.mdx
@@ -76,17 +76,22 @@ function IssuePage() {
))}
-
-
-
+
+
+ Open issues
+
- {' '}
- open
-
+
+
-
-
-
+
+
+ Open issues
+
{typical.open}
- {' '}
- open
-
+
+
diff --git a/docs/core/shared/_acidRest.mdx b/docs/core/shared/_acidRest.mdx
index aa52f1ca6c29..afa9136fe919 100644
--- a/docs/core/shared/_acidRest.mdx
+++ b/docs/core/shared/_acidRest.mdx
@@ -60,7 +60,7 @@ export default function Session({ draft, setDraft }) {
};
return (
-
+
ctrl.fetch — survives crash
{issues.map(issue => (
setDraft(e.currentTarget.value)}
/>
-
+
useState — lost on crash
{typical.list.map(issue => (
- Simulate crash
+
+ Simulate crash
+
}>
-
- {issue.state === 'open' ? 'Close' : 'Reopen'}
-
+
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
-
+
+ List
{issues.map(item => (
))}
-
+ List
{typical.list.map(item => (
))}
-
+
+
Detail
{typical.view(id).title}
diff --git a/docs/core/shared/_acidSideEffects.mdx b/docs/core/shared/_acidSideEffects.mdx
index 78e9ab114165..ebdb68ebe1aa 100644
--- a/docs/core/shared/_acidSideEffects.mdx
+++ b/docs/core/shared/_acidSideEffects.mdx
@@ -83,41 +83,54 @@ function TradePage() {
};
return (
-
Buy 10 DOGE
+
+ Buy 10 DOGE
+
-
-
- Balance:{' '}
-
-
-
-
- {trades.map(trade => (
-
- {trade.amount} {trade.coin}
+
+
+
+ Balance
+
+
+
- ))}
+
+
Trades
+ {trades.map(trade => (
+
+ {trade.amount} {trade.coin}
+
+ ))}
+
+
-
- Balance:{' '}
-
-
- {typical.balance} USD
-
-
-
- {typical.trades.map(trade => (
-
- {trade.amount} {trade.coin}
+
+
+ Balance
+
+
+ {typical.balance} USD
+
+
+
+
+
Trades
+ {typical.trades.map(trade => (
+
+ {trade.amount} {trade.coin}
+
+ ))}
- ))}
+
diff --git a/docs/core/shared/_acidSnapshot.mdx b/docs/core/shared/_acidSnapshot.mdx
index ddb468ac1005..63f8b1a63353 100644
--- a/docs/core/shared/_acidSnapshot.mdx
+++ b/docs/core/shared/_acidSnapshot.mdx
@@ -73,8 +73,8 @@ export default function IssueRow({
onToggle();
};
return (
-
- {fromList.title}
+
+
{fromList.title}
{fromList.state === 'open' ? 'Close' : 'Reopen'}
@@ -111,7 +111,7 @@ function IssueList() {
/>
))}
-
+
list
@@ -123,7 +123,11 @@ function IssueList() {
-
+
list
diff --git a/docs/core/shared/_acidTransports.mdx b/docs/core/shared/_acidTransports.mdx
index e8253aedb950..1d3dfaf8f2d5 100644
--- a/docs/core/shared/_acidTransports.mdx
+++ b/docs/core/shared/_acidTransports.mdx
@@ -60,15 +60,18 @@ function IssuePage() {
};
return (
-
- {issue.state === 'open' ?
- 'Alice closed this'
- : 'Alice reopened this'}
-
+
+
+ {issue.state === 'open' ?
+ 'Alice closed this'
+ : 'Alice reopened this'}
+
+
-
+
+ List
{issues.map(item => (
))}
-
+ List
{typical.list.map(item => (
))}
-
+
+
Detail
{typical.view(id).title}
diff --git a/docs/core/shared/_acidUpdate.mdx b/docs/core/shared/_acidUpdate.mdx
index 3b193c56b8a9..247acc04e5cb 100644
--- a/docs/core/shared/_acidUpdate.mdx
+++ b/docs/core/shared/_acidUpdate.mdx
@@ -60,13 +60,16 @@ function IssuePage() {
};
return (
-
- {issue.state === 'open' ? 'Close' : 'Reopen'}
-
+
+
+ {issue.state === 'open' ? 'Close' : 'Reopen'}
+
+
-
+
+ List
{issues.map(item => (
))}
-
+ List
{typical.list.map(item => (
))}
-
+
+
Detail
{typical.view(id).title}
diff --git a/docs/core/shared/_acidValidate.mdx b/docs/core/shared/_acidValidate.mdx
index 5a0c85ca3e6e..14cbe5a3b64e 100644
--- a/docs/core/shared/_acidValidate.mdx
+++ b/docs/core/shared/_acidValidate.mdx
@@ -121,20 +121,22 @@ function Navigator() {
const [id, setId] = React.useState('1');
return (
-
setId(e.currentTarget.value)}>
- Valid
-
-
setId(e.currentTarget.value)}>
- Missing title
-
-
setId(e.currentTarget.value)}>
- Wrong type
-
+
+ setId(e.currentTarget.value)}>
+ Valid
+
+ setId(e.currentTarget.value)}>
+ Missing title
+
+ setId(e.currentTarget.value)}>
+ Wrong type
+
+
-
+
-
+
diff --git a/website/src/components/Playground/DesignSystem/Acid.tsx b/website/src/components/Playground/DesignSystem/Acid.tsx
index ea65a9b6c498..b0b8a7fd9dfa 100644
--- a/website/src/components/Playground/DesignSystem/Acid.tsx
+++ b/website/src/components/Playground/DesignSystem/Acid.tsx
@@ -6,17 +6,24 @@ export function AcidCompare({ children }: { children: React.ReactNode }) {
export function AcidPane({
title,
+ subtitle,
torn,
children,
}: {
title: string;
+ subtitle?: string;
torn?: boolean;
children: React.ReactNode;
}) {
return (
-
{title}
+
+ {title}
+ {subtitle ?
+ {subtitle}
+ : null}
+
{torn ?
Views disagree
: null}
@@ -60,15 +67,17 @@ export function IssueRow({
onClick?: () => void;
children?: React.ReactNode;
}) {
+ const cls = [
+ 'issueRow',
+ selected ? 'issueRow--selected' : '',
+ ghost ? 'issueRow--ghost' : '',
+ onClick ? 'issueRow--clickable' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
return (
-
- {selected ?
-
{title}
- :
{title} }
+
+ {title}
{children}
diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css
index a24a81082322..48c1ab6299cb 100644
--- a/website/src/components/Playground/DesignSystem/design-system.css
+++ b/website/src/components/Playground/DesignSystem/design-system.css
@@ -169,118 +169,380 @@ html[data-theme='dark'] .small.rt-TextFieldRoot {
border-color: var(--ifm-color-formfield-active);
}
+/* ==========================================================================
+ ACID comparison playgrounds — "editorial lab" system.
+ Two stacked panes: Data Client (calm, primary rule) vs Typical caches
+ (cool, muted) that shifts to a quiet danger treatment when views tear.
+ Everything derives from Infima variables so both themes stay correct.
+ ========================================================================== */
+
.acidCompare {
display: grid;
- gap: 0.65rem;
+ gap: 0.7rem;
container-type: inline-size;
container-name: acid;
}
+
+/* --- Toolbar: groups the demo's action controls above the compare -------- */
+div.acidToolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+ margin-bottom: 0.65rem;
+}
+div.acidToolbar > button {
+ margin: 0;
+ padding: 0.24rem 0.7rem;
+ font: inherit;
+ font-size: 0.78rem;
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--ifm-font-color-base);
+ background: var(--ifm-background-surface-color);
+ border: 1px solid var(--ifm-color-emphasis-400);
+ border-radius: calc(var(--ifm-global-radius) * 0.75);
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease;
+}
+div.acidToolbar > button:hover {
+ border-color: var(--ifm-color-emphasis-600);
+ background: var(--ifm-hover-overlay);
+}
+div.acidToolbar > button:active {
+ transform: translateY(1px);
+}
+div.acidToolbar .rt-TextFieldRoot {
+ flex: 1 1 12rem;
+ width: auto;
+ margin: 0;
+}
+
+/* --- Panes ---------------------------------------------------------------- */
.acidPane {
- border: 1px solid var(--ifm-color-emphasis-300);
+ position: relative;
+ overflow: hidden;
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-left: 3px solid var(--ifm-color-emphasis-400);
border-radius: var(--ifm-global-radius);
padding: 0.5rem 0.65rem 0.65rem;
background: var(--ifm-background-color);
+ transition:
+ border-color 0.25s ease,
+ background-color 0.25s ease;
+}
+/* First pane = Data Client: calm, carries the brand rule. */
+.acidCompare > .acidPane:first-child {
+ border-left-color: var(--ifm-color-primary);
+}
+/* Later panes = typical caches: cooler and slightly receded. */
+.acidCompare > .acidPane:not(:first-child) {
+ background: color-mix(
+ in srgb,
+ var(--ifm-color-emphasis-100) 45%,
+ var(--ifm-background-color)
+ );
}
-.acidPane--torn {
- border-color: var(--ifm-color-danger);
- background: repeating-linear-gradient(
- -45deg,
- transparent,
- transparent 7px,
- color-mix(in srgb, var(--ifm-color-danger) 10%, transparent) 7px,
- color-mix(in srgb, var(--ifm-color-danger) 10%, transparent) 14px
+/* Torn: left danger rule + soft wash. Quiet, but unmistakable. */
+.acidCompare > .acidPane.acidPane--torn {
+ border-color: color-mix(
+ in srgb,
+ var(--ifm-color-danger) 40%,
+ var(--ifm-color-emphasis-300)
+ );
+ border-left-color: var(--ifm-color-danger);
+ background: color-mix(
+ in srgb,
+ var(--ifm-color-danger) 6%,
+ var(--ifm-background-color)
);
}
+
+/* --- Pane header: full-bleed strip with hairline rule --------------------- */
.acidPaneHeader {
display: flex;
justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.5rem;
+ margin: -0.5rem -0.65rem 0.55rem;
+ padding: 0.35rem 0.65rem;
+ border-bottom: 1px solid var(--ifm-color-emphasis-200);
+ background: color-mix(
+ in srgb,
+ var(--ifm-color-emphasis-100) 50%,
+ transparent
+ );
+}
+.acidPane--torn .acidPaneHeader {
+ border-bottom-color: color-mix(
+ in srgb,
+ var(--ifm-color-danger) 30%,
+ var(--ifm-color-emphasis-200)
+ );
+ background: color-mix(in srgb, var(--ifm-color-danger) 7%, transparent);
+}
+.acidPaneTitle {
+ display: flex;
align-items: baseline;
- gap: 0.5rem;
- margin-bottom: 0.45rem;
+ flex-wrap: wrap;
+ gap: 0.45em;
+ min-width: 0;
}
-.acidPaneHeader > small {
- font-weight: 650;
+.acidPaneTitle > small {
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ifm-color-emphasis-800);
+}
+.acidPaneSubtitle {
+ font-size: 0.66rem;
+ color: var(--ifm-color-emphasis-600);
}
+
+/* --- Torn chip ------------------------------------------------------------ */
.acidTornLabel {
- color: var(--ifm-color-danger);
- font-size: 0.68rem;
- font-weight: 750;
- letter-spacing: 0.05em;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35em;
+ flex: 0 0 auto;
+ font-size: 0.6rem;
+ font-weight: 700;
+ letter-spacing: 0.07em;
text-transform: uppercase;
+ line-height: 1.5;
+ padding: 0.1em 0.6em;
+ border-radius: 999px;
+ color: var(--ifm-color-danger-darker);
+ border: 1px solid color-mix(in srgb, var(--ifm-color-danger) 50%, transparent);
+ background: color-mix(in srgb, var(--ifm-color-danger) 10%, transparent);
+ animation: acidTornIn 0.25s ease-out;
+}
+.acidTornLabel::before {
+ content: '';
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--ifm-color-danger);
+}
+html[data-theme='dark'] .acidTornLabel {
+ color: var(--ifm-color-danger-lightest);
+}
+@keyframes acidTornIn {
+ from {
+ opacity: 0;
+ transform: translateY(-2px);
+ }
}
+
+/* --- Hint line directly inside a pane (e.g. "survives crash") ------------ */
+.acidPane > small {
+ display: block;
+ font-size: 0.68rem;
+ color: var(--ifm-color-emphasis-600);
+ margin-bottom: 0.35rem;
+}
+
+/* --- List / detail split inside a pane ------------------------------------ */
.acidSplit {
display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 0.5rem;
+ grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
+ gap: 0.6rem;
+ align-items: start;
}
@container acid (max-width: 420px) {
.acidSplit {
- grid-template-columns: 1fr;
+ grid-template-columns: minmax(0, 1fr);
}
}
+/* Column headers: first
in a split column reads as a label. */
+.acidSplit > div > small:first-child,
+.acidReadout > small:first-child {
+ display: block;
+ font-size: 0.6rem;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--ifm-color-emphasis-600);
+ margin-bottom: 0.3rem;
+}
+
+/* --- Readout: framed value block (detail view, counters, balances) -------- */
.acidReadout {
display: flex;
flex-direction: column;
- gap: 0.2rem;
- margin-bottom: 0.35rem;
+ align-items: flex-start;
+ gap: 0.3rem;
+ padding: 0.45rem 0.55rem 0.55rem;
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-radius: calc(var(--ifm-global-radius) * 0.75);
+ background: color-mix(
+ in srgb,
+ var(--ifm-color-emphasis-100) 40%,
+ transparent
+ );
+}
+.acidReadout > div {
+ font-size: 0.88rem;
+ font-weight: 600;
+ line-height: 1.3;
+ align-self: stretch;
}
.acidReadout > strong {
- font-size: 1.05rem;
+ font-size: 1.15rem;
+ line-height: 1.2;
+ font-variant-numeric: tabular-nums;
}
.acidReadout .issueState {
margin-left: 0;
align-self: flex-start;
- font-size: 0.8rem;
+ font-size: 0.72rem;
+}
+.acidReadout .listItem {
+ align-self: stretch;
+ align-items: center;
}
+
+/* --- History strip (optimistic increment demo) ----------------------------- */
.acidHistory {
- font-size: 0.78rem;
+ font-family: var(--ifm-font-family-monospace);
+ font-size: 0.72rem;
font-variant-numeric: tabular-nums;
- line-height: 1.45;
+ line-height: 1.7;
+ text-align: left;
+ padding: 0.35rem 0.55rem;
+ margin: 0.4rem 0 0.6rem;
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-radius: calc(var(--ifm-global-radius) * 0.75);
+ background: color-mix(
+ in srgb,
+ var(--ifm-color-emphasis-100) 40%,
+ transparent
+ );
+ color: var(--ifm-color-emphasis-700);
}
.acidHistory-mixed {
color: var(--ifm-color-danger);
font-weight: 700;
}
+
+/* --- State badges ---------------------------------------------------------- */
.issueState {
- font-size: 0.65rem;
- font-weight: 750;
- letter-spacing: 0.04em;
+ font-size: 0.62rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
text-transform: uppercase;
border-radius: 999px;
- padding: 0.12em 0.55em;
- line-height: 1.4;
+ border: 1px solid transparent;
+ padding: 0.12em 0.6em;
+ line-height: 1.5;
flex: 0 0 auto;
margin-left: auto;
+ transition:
+ color 0.25s ease,
+ background-color 0.25s ease,
+ border-color 0.25s ease;
}
+/* Open: outlined amber pill. */
.issueState--open {
color: var(--ifm-color-warning-darkest);
- border: 1px solid var(--ifm-color-warning);
- background: transparent;
+ border-color: color-mix(in srgb, var(--ifm-color-warning-dark) 75%, transparent);
+ background: color-mix(in srgb, var(--ifm-color-warning) 8%, transparent);
}
html[data-theme='dark'] .issueState--open {
- color: var(--ifm-color-warning-lightest);
+ color: var(--ifm-color-warning-light);
+ border-color: color-mix(in srgb, var(--ifm-color-warning) 55%, transparent);
}
+/* Closed: solid muted info pill. */
.issueState--closed {
- color: var(--ifm-color-gray-100);
+ color: var(--ifm-color-white);
background: var(--ifm-color-info-darkest);
- border: 1px solid var(--ifm-color-info-darkest);
+ border-color: var(--ifm-color-info-darkest);
}
html[data-theme='dark'] .issueState--closed {
- color: var(--ifm-color-gray-900);
+ color: var(--ifm-color-black);
background: var(--ifm-color-info-light);
border-color: var(--ifm-color-info-light);
}
-.issueState--stale {
+/* Stale: danger wash overrides either state. */
+.issueState--stale,
+html[data-theme='dark'] .issueState--stale {
color: var(--ifm-color-danger-darkest);
- background: var(--ifm-color-danger-contrast-background);
+ background: color-mix(in srgb, var(--ifm-color-danger) 14%, transparent);
border-color: var(--ifm-color-danger);
}
html[data-theme='dark'] .issueState--stale {
color: var(--ifm-color-danger-lightest);
}
-.issueRow--ghost {
- opacity: 0.6;
- outline: 1px dashed var(--ifm-color-danger);
- outline-offset: -1px;
- border-radius: 3px;
+
+/* --- Issue rows: selectable list rows -------------------------------------- */
+div.issueRow {
+ display: flex;
+ align-items: center;
+ gap: 0.6em;
+ padding: 0.26rem 0.5rem;
+ border: 1px solid transparent;
+ border-radius: calc(var(--ifm-global-radius) * 0.75);
+ line-height: 1.35;
+ font-size: 0.85rem;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease;
+}
+.issueRow + .issueRow {
+ margin-top: 2px;
+}
+.issueRow--clickable {
+ cursor: pointer;
+}
+.issueRow--clickable:hover {
+ background: var(--ifm-hover-overlay);
+}
+div.issueRow--selected {
+ background: var(--ifm-menu-color-background-active);
+ border-color: var(--ifm-color-emphasis-200);
+}
+.issueRowTitle {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.issueRow--selected .issueRowTitle {
+ font-weight: 650;
+}
+/* Ghost: deleted in one view but still present in another. */
+div.issueRow--ghost {
+ opacity: 0.55;
+ border: 1px dashed color-mix(in srgb, var(--ifm-color-danger) 55%, transparent);
+}
+.issueRow--ghost .issueRowTitle {
+ text-decoration: line-through;
+ text-decoration-color: color-mix(in srgb, var(--ifm-color-danger) 65%, transparent);
+}
+/* Inline actions inside a row (Close/Reopen in list demos). */
+div.issueRow button {
+ margin: 0;
+ padding: 0.05rem 0.5rem;
+ font: inherit;
+ font-size: 0.72rem;
+ font-weight: 600;
+ line-height: 1.5;
+ color: var(--ifm-color-emphasis-800);
+ background: var(--ifm-background-surface-color);
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: calc(var(--ifm-global-radius) * 0.75);
+ cursor: pointer;
+ flex: 0 0 auto;
+ transition:
+ border-color 0.15s ease,
+ background-color 0.15s ease;
+}
+div.issueRow button:hover {
+ border-color: var(--ifm-color-emphasis-600);
+ background: var(--ifm-hover-overlay);
}
diff --git a/website/src/components/Playground/monaco-init.ts b/website/src/components/Playground/monaco-init.ts
index 581237073861..5bb9dd783270 100644
--- a/website/src/components/Playground/monaco-init.ts
+++ b/website/src/components/Playground/monaco-init.ts
@@ -264,7 +264,7 @@ if (
declare function CurrentTime(props: {}):JSX.Element;
declare function CancelButton(props: { onClick?: () => void }):JSX.Element;
declare function AcidCompare(props: { children: React.ReactNode }):JSX.Element;
- declare function AcidPane(props: { title: string; torn?: boolean; children: React.ReactNode }):JSX.Element;
+ declare function AcidPane(props: { title: string; subtitle?: string; torn?: boolean; children: React.ReactNode }):JSX.Element;
declare function IssueState(props: { state: 'open' | 'closed'; stale?: boolean }):JSX.Element;
declare function IssueRow(props: { title: string; state: 'open' | 'closed'; selected?: boolean; ghost?: boolean; stale?: boolean; onClick?: () => void; children?: React.ReactNode }):JSX.Element;
declare function Avatar(props: { src: string }):JSX.Element;
From e7d08b23d71155fe597f1a2cedda97bd8c48545c Mon Sep 17 00:00:00 2001
From: root
Date: Fri, 14 Aug 2026 11:45:34 -0400
Subject: [PATCH 07/12] docs: Call the ACID contrast a flat cache
Matches the intro's flat-file analogy: a store with no data model, not a vague "typical" cache.
Co-authored-by: Cursor
---
docs/core/shared/_acidCollections.mdx | 2 +-
docs/core/shared/_acidCreate.mdx | 2 +-
docs/core/shared/_acidDelete.mdx | 2 +-
docs/core/shared/_acidIdentity.mdx | 2 +-
docs/core/shared/_acidQuery.mdx | 2 +-
docs/core/shared/_acidRest.mdx | 2 +-
docs/core/shared/_acidRollback.mdx | 2 +-
docs/core/shared/_acidSideEffects.mdx | 2 +-
docs/core/shared/_acidSnapshot.mdx | 2 +-
docs/core/shared/_acidTransports.mdx | 2 +-
docs/core/shared/_acidUpdate.mdx | 2 +-
docs/core/shared/_acidValidate.mdx | 2 +-
.../src/components/Playground/DesignSystem/design-system.css | 4 ++--
13 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/docs/core/shared/_acidCollections.mdx b/docs/core/shared/_acidCollections.mdx
index 6c38b39dee3a..5b0e6e14eaae 100644
--- a/docs/core/shared/_acidCollections.mdx
+++ b/docs/core/shared/_acidCollections.mdx
@@ -136,7 +136,7 @@ function IssuePage() {
diff --git a/docs/core/shared/_acidCreate.mdx b/docs/core/shared/_acidCreate.mdx
index 6bbe29e66126..5f51b5335b5b 100644
--- a/docs/core/shared/_acidCreate.mdx
+++ b/docs/core/shared/_acidCreate.mdx
@@ -84,7 +84,7 @@ function IssuePage() {
diff --git a/docs/core/shared/_acidDelete.mdx b/docs/core/shared/_acidDelete.mdx
index 2b2682f25317..e02beed30814 100644
--- a/docs/core/shared/_acidDelete.mdx
+++ b/docs/core/shared/_acidDelete.mdx
@@ -81,7 +81,7 @@ function IssuePage() {
0}
>
diff --git a/docs/core/shared/_acidIdentity.mdx b/docs/core/shared/_acidIdentity.mdx
index faea21ca7ea3..25ae35dd8161 100644
--- a/docs/core/shared/_acidIdentity.mdx
+++ b/docs/core/shared/_acidIdentity.mdx
@@ -89,7 +89,7 @@ function IssuePage() {
diff --git a/docs/core/shared/_acidQuery.mdx b/docs/core/shared/_acidQuery.mdx
index a103ba2f3ff4..c941df0e8168 100644
--- a/docs/core/shared/_acidQuery.mdx
+++ b/docs/core/shared/_acidQuery.mdx
@@ -85,7 +85,7 @@ function IssuePage() {
diff --git a/docs/core/shared/_acidRest.mdx b/docs/core/shared/_acidRest.mdx
index afa9136fe919..3e4d05391cde 100644
--- a/docs/core/shared/_acidRest.mdx
+++ b/docs/core/shared/_acidRest.mdx
@@ -79,7 +79,7 @@ export default function Session({ draft, setDraft }) {
onChange={e => setDraft(e.currentTarget.value)}
/>
-
+
useState — lost on crash
{typical.list.map(issue => (
diff --git a/docs/core/shared/_acidSideEffects.mdx b/docs/core/shared/_acidSideEffects.mdx
index ebdb68ebe1aa..01d78d9e9f4a 100644
--- a/docs/core/shared/_acidSideEffects.mdx
+++ b/docs/core/shared/_acidSideEffects.mdx
@@ -109,7 +109,7 @@ function TradePage() {
diff --git a/docs/core/shared/_acidSnapshot.mdx b/docs/core/shared/_acidSnapshot.mdx
index 63f8b1a63353..580c4d91f53b 100644
--- a/docs/core/shared/_acidSnapshot.mdx
+++ b/docs/core/shared/_acidSnapshot.mdx
@@ -124,7 +124,7 @@ function IssueList() {
diff --git a/docs/core/shared/_acidTransports.mdx b/docs/core/shared/_acidTransports.mdx
index 1d3dfaf8f2d5..eea10e788dad 100644
--- a/docs/core/shared/_acidTransports.mdx
+++ b/docs/core/shared/_acidTransports.mdx
@@ -90,7 +90,7 @@ function IssuePage() {
diff --git a/docs/core/shared/_acidUpdate.mdx b/docs/core/shared/_acidUpdate.mdx
index 247acc04e5cb..02b2a649daa6 100644
--- a/docs/core/shared/_acidUpdate.mdx
+++ b/docs/core/shared/_acidUpdate.mdx
@@ -88,7 +88,7 @@ function IssuePage() {
diff --git a/docs/core/shared/_acidValidate.mdx b/docs/core/shared/_acidValidate.mdx
index 14cbe5a3b64e..9be4ece40a26 100644
--- a/docs/core/shared/_acidValidate.mdx
+++ b/docs/core/shared/_acidValidate.mdx
@@ -136,7 +136,7 @@ function Navigator() {
-
+
diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css
index 48c1ab6299cb..26b88bbd7b29 100644
--- a/website/src/components/Playground/DesignSystem/design-system.css
+++ b/website/src/components/Playground/DesignSystem/design-system.css
@@ -171,7 +171,7 @@ html[data-theme='dark'] .small.rt-TextFieldRoot {
/* ==========================================================================
ACID comparison playgrounds — "editorial lab" system.
- Two stacked panes: Data Client (calm, primary rule) vs Typical caches
+ Two stacked panes: Data Client (calm, primary rule) vs Flat caches
(cool, muted) that shifts to a quiet danger treatment when views tear.
Everything derives from Infima variables so both themes stay correct.
========================================================================== */
@@ -237,7 +237,7 @@ div.acidToolbar .rt-TextFieldRoot {
.acidCompare > .acidPane:first-child {
border-left-color: var(--ifm-color-primary);
}
-/* Later panes = typical caches: cooler and slightly receded. */
+/* Later panes = flat caches: cooler and slightly receded. */
.acidCompare > .acidPane:not(:first-child) {
background: color-mix(
in srgb,
From c526c8e3ded03db5b4cd31099438cb7bfd5e4eff Mon Sep 17 00:00:00 2001
From: root
Date: Fri, 14 Aug 2026 11:56:23 -0400
Subject: [PATCH 08/12] docs: Isolate ACID validate raw pane suspense
Keep the navigator and Data Client pane mounted when switching payloads instead of suspending the whole playground.
Co-authored-by: Cursor
---
docs/core/shared/_acidValidate.mdx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/docs/core/shared/_acidValidate.mdx b/docs/core/shared/_acidValidate.mdx
index 9be4ece40a26..e06b23541ba0 100644
--- a/docs/core/shared/_acidValidate.mdx
+++ b/docs/core/shared/_acidValidate.mdx
@@ -102,6 +102,14 @@ function ArticleBody({ id }: { id: string }) {
}
export function RawArticlePage({ id }: { id: string }) {
+ return (
+ }>
+
+
+ );
+}
+
+function RawArticleBody({ id }: { id: string }) {
const article = useSuspense(getRawArticle, { id });
return (
From bf40862fb092e73977a767cb44a65c7b5d579906 Mon Sep 17 00:00:00 2001
From: root
Date: Fri, 14 Aug 2026 12:06:27 -0400
Subject: [PATCH 09/12] docs: Fix invalid table markup that caused hydration
warnings
thead cells need a tr/th, and MDX table rows must use className.
Co-authored-by: Cursor
---
docs/core/concepts/overview.md | 4 ++--
docs/rest/shared/_optimisticTransform.mdx | 8 +++++---
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/docs/core/concepts/overview.md b/docs/core/concepts/overview.md
index 7d00b1246f5b..2eb18928f6fe 100644
--- a/docs/core/concepts/overview.md
+++ b/docs/core/concepts/overview.md
@@ -26,7 +26,7 @@ import Link from '@docusaurus/Link';
- Expiry Status
+ Expiry Status
Fresh
Data can always be used and needs no updates.
@@ -41,7 +41,7 @@ import Link from '@docusaurus/Link';
- Error Policy
+ Error Policy
Soft
Transient errors that should not invalidate existing data.
diff --git a/docs/rest/shared/_optimisticTransform.mdx b/docs/rest/shared/_optimisticTransform.mdx
index 94f4e667d8ab..cdb51fb36dac 100644
--- a/docs/rest/shared/_optimisticTransform.mdx
+++ b/docs/rest/shared/_optimisticTransform.mdx
@@ -97,9 +97,11 @@ function CounterPage() {
-
- Optimistic
- Normal
+
+
+ Optimistic
+ Normal
+
From f4ba460557e714c60b12905e4eb158aa7597d035 Mon Sep 17 00:00:00 2001
From: root
Date: Mon, 17 Aug 2026 12:02:59 -0400
Subject: [PATCH 10/12] docs: Start the ACID update playground as a video demo
Readers see the mutation first, then click into the live playground and can reveal the code that builds it.
Co-authored-by: Cursor
---
docs/core/shared/_acidUpdate.mdx | 17 +-
jest.config.js | 10 +-
.../DemoVideo/__tests__/attachSources.test.ts | 40 +++
.../__tests__/reducePlayback.test.ts | 75 ++++++
.../__tests__/useAutoplayInView.test.tsx | 92 +++++++
.../src/components/DemoVideo/attachSources.ts | 15 ++
website/src/components/DemoVideo/index.tsx | 196 ++++++++++++++
.../src/components/DemoVideo/mediaPrefs.ts | 23 ++
.../components/DemoVideo/reducePlayback.ts | 35 +++
.../components/DemoVideo/styles.module.css | 108 ++++++++
.../components/DemoVideo/trackDemoEvent.ts | 6 +
website/src/components/DemoVideo/types.ts | 65 +++++
.../components/DemoVideo/useAutoplayInView.ts | 132 ++++++++++
website/src/components/HooksPlayground.tsx | 18 +-
.../components/Playground/PreviewWrapper.tsx | 61 ++++-
.../Playground/editor/EditorSurface.tsx | 41 ++-
website/src/components/Playground/index.tsx | 239 +++++++++++++++---
.../Playground/preview/LivePreview.tsx | 8 +-
.../components/Playground/styles.module.css | 137 ++++++++++
website/static/img/demos/acid-update.dark.jpg | Bin 0 -> 39677 bytes
website/static/img/demos/acid-update.jpg | Bin 0 -> 28412 bytes
website/static/videos/demos/acid-update.mp4 | Bin 0 -> 242987 bytes
22 files changed, 1238 insertions(+), 80 deletions(-)
create mode 100644 website/src/components/DemoVideo/__tests__/attachSources.test.ts
create mode 100644 website/src/components/DemoVideo/__tests__/reducePlayback.test.ts
create mode 100644 website/src/components/DemoVideo/__tests__/useAutoplayInView.test.tsx
create mode 100644 website/src/components/DemoVideo/attachSources.ts
create mode 100644 website/src/components/DemoVideo/index.tsx
create mode 100644 website/src/components/DemoVideo/mediaPrefs.ts
create mode 100644 website/src/components/DemoVideo/reducePlayback.ts
create mode 100644 website/src/components/DemoVideo/styles.module.css
create mode 100644 website/src/components/DemoVideo/trackDemoEvent.ts
create mode 100644 website/src/components/DemoVideo/types.ts
create mode 100644 website/src/components/DemoVideo/useAutoplayInView.ts
create mode 100644 website/static/img/demos/acid-update.dark.jpg
create mode 100644 website/static/img/demos/acid-update.jpg
create mode 100644 website/static/videos/demos/acid-update.mp4
diff --git a/docs/core/shared/_acidUpdate.mdx b/docs/core/shared/_acidUpdate.mdx
index 02b2a649daa6..bfa194fa7252 100644
--- a/docs/core/shared/_acidUpdate.mdx
+++ b/docs/core/shared/_acidUpdate.mdx
@@ -4,7 +4,22 @@ import {
getAcidIssueData,
} from '@site/src/fixtures/acid';
-
+
```ts title="IssueResource" collapsed
import { Entity, resource } from '@data-client/rest';
diff --git a/jest.config.js b/jest.config.js
index 1dab51d128b7..1ef8aa41019b 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -59,15 +59,13 @@ const packages = [
// CircleCI persist_to_workspace omits most of website/; only include this root
// when the tree is present (full checkout / when CI persists Playground).
-const playgroundRoot = path.join(
- __dirname,
+const websiteComponentRoots = [
'website/src/components/Playground',
-);
+ 'website/src/components/DemoVideo',
+].filter(rel => fs.existsSync(path.join(__dirname, rel)));
const reactDomRoots = [
...packages.map(pkgName => `/packages/${pkgName}/src`),
- ...(fs.existsSync(playgroundRoot) ?
- ['/website/src/components/Playground']
- : []),
+ ...websiteComponentRoots.map(rel => `/${rel}`),
];
const projects = [
diff --git a/website/src/components/DemoVideo/__tests__/attachSources.test.ts b/website/src/components/DemoVideo/__tests__/attachSources.test.ts
new file mode 100644
index 000000000000..224e64b34300
--- /dev/null
+++ b/website/src/components/DemoVideo/__tests__/attachSources.test.ts
@@ -0,0 +1,40 @@
+///
+
+import { shouldAttachSources } from '../attachSources';
+
+describe('shouldAttachSources', () => {
+ const ready = {
+ prefsReady: true,
+ isBot: false,
+ nearViewport: true,
+ reducedData: false,
+ forceAttach: false,
+ };
+
+ test('SSR and first hydration stay poster-only until prefs resolve', () => {
+ expect(shouldAttachSources({ ...ready, prefsReady: false })).toBe(false);
+ });
+
+ test('bots never receive media sources', () => {
+ expect(shouldAttachSources({ ...ready, isBot: true })).toBe(false);
+ expect(
+ shouldAttachSources({ ...ready, isBot: true, forceAttach: true }),
+ ).toBe(false);
+ });
+
+ test('reduced-data waits for an explicit Play', () => {
+ expect(shouldAttachSources({ ...ready, reducedData: true })).toBe(false);
+ expect(
+ shouldAttachSources({
+ ...ready,
+ reducedData: true,
+ forceAttach: true,
+ }),
+ ).toBe(true);
+ });
+
+ test('off-screen facades do not attach until near the viewport', () => {
+ expect(shouldAttachSources({ ...ready, nearViewport: false })).toBe(false);
+ expect(shouldAttachSources(ready)).toBe(true);
+ });
+});
diff --git a/website/src/components/DemoVideo/__tests__/reducePlayback.test.ts b/website/src/components/DemoVideo/__tests__/reducePlayback.test.ts
new file mode 100644
index 000000000000..28a855b46eed
--- /dev/null
+++ b/website/src/components/DemoVideo/__tests__/reducePlayback.test.ts
@@ -0,0 +1,75 @@
+///
+
+import { reducePlayback } from '../reducePlayback';
+import type { DemoPlaybackState } from '../types';
+
+const dormant: DemoPlaybackState = { status: 'dormant' };
+
+describe('reducePlayback', () => {
+ test('entering view starts loading from dormant or out-of-view pause', () => {
+ expect(
+ reducePlayback(dormant, { type: 'visibility', inView: true }),
+ ).toEqual({ status: 'loading' });
+ expect(
+ reducePlayback(
+ { status: 'paused', reason: 'out-of-view' },
+ { type: 'visibility', inView: true },
+ ),
+ ).toEqual({ status: 'loading' });
+ });
+
+ test('user pause does not resume on re-entry', () => {
+ expect(
+ reducePlayback(
+ { status: 'paused', reason: 'user' },
+ { type: 'visibility', inView: true },
+ ),
+ ).toEqual({ status: 'paused', reason: 'user' });
+ });
+
+ test('leaving view pauses playing or loading media as out-of-view', () => {
+ expect(
+ reducePlayback(
+ { status: 'playing' },
+ { type: 'visibility', inView: false },
+ ),
+ ).toEqual({ status: 'paused', reason: 'out-of-view' });
+ expect(
+ reducePlayback(
+ { status: 'loading' },
+ { type: 'visibility', inView: false },
+ ),
+ ).toEqual({ status: 'paused', reason: 'out-of-view' });
+ expect(
+ reducePlayback(
+ { status: 'paused', reason: 'user' },
+ { type: 'visibility', inView: false },
+ ),
+ ).toEqual({ status: 'paused', reason: 'user' });
+ });
+
+ test('user play loads, user pause sticks, playing and errors land', () => {
+ expect(reducePlayback(dormant, { type: 'user-play' })).toEqual({
+ status: 'loading',
+ });
+ expect(
+ reducePlayback({ status: 'playing' }, { type: 'user-pause' }),
+ ).toEqual({ status: 'paused', reason: 'user' });
+ expect(reducePlayback({ status: 'loading' }, { type: 'playing' })).toEqual({
+ status: 'playing',
+ });
+ expect(reducePlayback({ status: 'loading' }, { type: 'blocked' })).toEqual({
+ status: 'blocked',
+ });
+ expect(reducePlayback({ status: 'playing' }, { type: 'error' })).toEqual({
+ status: 'error',
+ });
+ });
+
+ test('waiting only demotes an already-playing stream', () => {
+ expect(reducePlayback({ status: 'playing' }, { type: 'waiting' })).toEqual({
+ status: 'loading',
+ });
+ expect(reducePlayback(dormant, { type: 'waiting' })).toEqual(dormant);
+ });
+});
diff --git a/website/src/components/DemoVideo/__tests__/useAutoplayInView.test.tsx b/website/src/components/DemoVideo/__tests__/useAutoplayInView.test.tsx
new file mode 100644
index 000000000000..fb3308045c52
--- /dev/null
+++ b/website/src/components/DemoVideo/__tests__/useAutoplayInView.test.tsx
@@ -0,0 +1,92 @@
+///
+
+import { act, renderHook } from '@testing-library/react';
+import { useRef } from 'react';
+
+import { useAutoplayInView } from '../useAutoplayInView';
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+describe('useAutoplayInView', () => {
+ test('ignores a stale play() settlement after pause', async () => {
+ const playCall = deferred();
+ const video = {
+ play: jest.fn(() => playCall.promise),
+ pause: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ } as unknown as HTMLVideoElement;
+
+ const { result } = renderHook(() => {
+ const ref = useRef(video);
+ return useAutoplayInView(ref, { enabled: false });
+ });
+
+ await act(async () => {
+ void result.current.play();
+ });
+ expect(result.current.state.status).toBe('loading');
+
+ act(() => {
+ result.current.pause();
+ });
+ expect(result.current.state).toEqual({ status: 'paused', reason: 'user' });
+
+ await act(async () => {
+ playCall.resolve();
+ await playCall.promise;
+ });
+ expect(result.current.state).toEqual({ status: 'paused', reason: 'user' });
+ });
+
+ test('disabling pauses in-view playback and updates state', () => {
+ const video = {
+ play: jest.fn(() => Promise.resolve()),
+ pause: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ } as unknown as HTMLVideoElement;
+ const observe = jest.fn();
+ const disconnect = jest.fn();
+ let ioCallback: IntersectionObserverCallback | undefined;
+ const OriginalIO = globalThis.IntersectionObserver;
+ globalThis.IntersectionObserver = jest.fn(callback => {
+ ioCallback = callback;
+ return { observe, disconnect, unobserve: jest.fn() };
+ }) as unknown as typeof IntersectionObserver;
+
+ const { result, rerender } = renderHook(
+ ({ enabled }) => {
+ const ref = useRef(video);
+ return useAutoplayInView(ref, { enabled });
+ },
+ { initialProps: { enabled: true } },
+ );
+
+ act(() => {
+ ioCallback?.(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ );
+ });
+ expect(video.play).toHaveBeenCalled();
+
+ rerender({ enabled: false });
+ expect(video.pause).toHaveBeenCalled();
+ expect(disconnect).toHaveBeenCalled();
+ expect(result.current.state).toEqual({
+ status: 'paused',
+ reason: 'out-of-view',
+ });
+
+ globalThis.IntersectionObserver = OriginalIO;
+ });
+});
diff --git a/website/src/components/DemoVideo/attachSources.ts b/website/src/components/DemoVideo/attachSources.ts
new file mode 100644
index 000000000000..1b470e9255d2
--- /dev/null
+++ b/website/src/components/DemoVideo/attachSources.ts
@@ -0,0 +1,15 @@
+export function shouldAttachSources({
+ prefsReady,
+ isBot,
+ nearViewport,
+ reducedData,
+ forceAttach,
+}: {
+ prefsReady: boolean;
+ isBot: boolean;
+ nearViewport: boolean;
+ reducedData: boolean;
+ forceAttach: boolean;
+}): boolean {
+ return prefsReady && !isBot && nearViewport && (!reducedData || forceAttach);
+}
diff --git a/website/src/components/DemoVideo/index.tsx b/website/src/components/DemoVideo/index.tsx
new file mode 100644
index 000000000000..36df95763145
--- /dev/null
+++ b/website/src/components/DemoVideo/index.tsx
@@ -0,0 +1,196 @@
+import useBaseUrl from '@docusaurus/useBaseUrl';
+import ThemedImage from '@theme/ThemedImage';
+import clsx from 'clsx';
+import React, { useEffect, useRef, useState } from 'react';
+
+import { shouldAttachSources } from './attachSources';
+import { prefersReducedData, prefersReducedMotion } from './mediaPrefs';
+import styles from './styles.module.css';
+import { trackDemoEvent } from './trackDemoEvent';
+import type { DemoVideoProps } from './types';
+import { useAutoplayInView } from './useAutoplayInView';
+import { isGoogleBot } from '../Playground/isMobileOrBot';
+import { useHasIntersected } from '../useHasIntersected';
+
+/**
+ * Autoplay video facade for MDX demos.
+ *
+ * Asset convention (site-root paths; resolved internally):
+ * - videos: `/videos/demos/.mp4` (optional `.webm`)
+ * - posters: `/img/demos/.jpg` (optional `.dark.jpg`)
+ *
+ * SSR and the first hydration render are poster-only. Media sources attach
+ * after mount, once near the viewport, unless the user has a data-saving
+ * preference (explicit Play then fetches).
+ */
+export default function DemoVideo({ source, onActivate }: DemoVideoProps) {
+ const videoRef = useRef(null);
+ // Generous margin so sources attach (and buffer) before scrolling into view.
+ const [rootRef, hasIntersected] = useHasIntersected({
+ threshold: 0,
+ rootMargin: '400px 0px',
+ });
+ const [prefs, setPrefs] = useState(null);
+ const [forceAttach, setForceAttach] = useState(false);
+ const pendingUserPlay = useRef(false);
+
+ useEffect(() => {
+ setPrefs({
+ reducedMotion: prefersReducedMotion(),
+ reducedData: prefersReducedData(),
+ });
+ }, []);
+
+ const attachSources = shouldAttachSources({
+ prefsReady: prefs !== null,
+ isBot: isGoogleBot,
+ nearViewport: hasIntersected || typeof IntersectionObserver !== 'function',
+ reducedData: prefs?.reducedData ?? false,
+ forceAttach,
+ });
+
+ const { state, play, pause } = useAutoplayInView(videoRef, {
+ enabled: attachSources && (forceAttach || !prefs?.reducedMotion),
+ threshold: 0.25,
+ });
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!attachSources || !video) return;
+ video.load();
+ if (pendingUserPlay.current) {
+ pendingUserPlay.current = false;
+ void play();
+ }
+ }, [attachSources, play]);
+
+ const handlePlay = () => {
+ trackDemoEvent('demo_play', source.id);
+ setForceAttach(true);
+ if (attachSources) {
+ void play();
+ } else {
+ pendingUserPlay.current = true;
+ }
+ };
+
+ const handlePause = () => {
+ trackDemoEvent('demo_pause', source.id);
+ pause();
+ };
+
+ const { light, dark } = source.poster;
+ const posterLight = useBaseUrl(light);
+ const posterDark = useBaseUrl(dark ?? light);
+ const mp4 = useBaseUrl(source.mp4);
+ const webm = useBaseUrl(source.webm ?? '');
+
+ const playing = state.status === 'playing';
+ const failed = state.status === 'error';
+
+ return (
+
+
+
+ {attachSources ?
+ <>
+ {source.webm ?
+
+ : null}
+
+ >
+ : null}
+
+ {failed ?
+
Video unavailable
+ : null}
+
+ {playing ?
+
{
+ event.stopPropagation();
+ handlePause();
+ }}
+ aria-label="Pause demo video"
+ >
+
+
+ :
{
+ event.stopPropagation();
+ handlePlay();
+ }}
+ aria-label="Play demo video"
+ >
+
+
+ }
+ {onActivate ?
+
{
+ event.stopPropagation();
+ onActivate();
+ }}
+ >
+ Try it
+
+ : null}
+
+
+ );
+}
+
+interface MediaPrefs {
+ reducedMotion: boolean;
+ reducedData: boolean;
+}
+
+function PlayIcon() {
+ return (
+
+
+
+ );
+}
+
+function PauseIcon() {
+ return (
+
+
+
+ );
+}
diff --git a/website/src/components/DemoVideo/mediaPrefs.ts b/website/src/components/DemoVideo/mediaPrefs.ts
new file mode 100644
index 000000000000..48775147d2bc
--- /dev/null
+++ b/website/src/components/DemoVideo/mediaPrefs.ts
@@ -0,0 +1,23 @@
+interface NavigatorConnection {
+ saveData?: boolean;
+}
+
+export function prefersReducedMotion(): boolean {
+ return (
+ typeof matchMedia === 'function' &&
+ matchMedia('(prefers-reduced-motion: reduce)').matches
+ );
+}
+
+export function prefersReducedData(): boolean {
+ if (typeof navigator === 'object') {
+ const connection = (
+ navigator as Navigator & { connection?: NavigatorConnection }
+ ).connection;
+ if (connection?.saveData) return true;
+ }
+ return (
+ typeof matchMedia === 'function' &&
+ matchMedia('(prefers-reduced-data: reduce)').matches
+ );
+}
diff --git a/website/src/components/DemoVideo/reducePlayback.ts b/website/src/components/DemoVideo/reducePlayback.ts
new file mode 100644
index 000000000000..b52e46c48d71
--- /dev/null
+++ b/website/src/components/DemoVideo/reducePlayback.ts
@@ -0,0 +1,35 @@
+import type { DemoPlaybackState, PlaybackEvent } from './types';
+
+export function reducePlayback(
+ state: DemoPlaybackState,
+ event: PlaybackEvent,
+): DemoPlaybackState {
+ switch (event.type) {
+ case 'visibility':
+ if (!event.inView) {
+ if (state.status === 'playing' || state.status === 'loading') {
+ return { status: 'paused', reason: 'out-of-view' };
+ }
+ return state;
+ }
+ if (state.status === 'paused' && state.reason === 'out-of-view') {
+ return { status: 'loading' };
+ }
+ if (state.status === 'dormant') {
+ return { status: 'loading' };
+ }
+ return state;
+ case 'user-play':
+ return { status: 'loading' };
+ case 'user-pause':
+ return { status: 'paused', reason: 'user' };
+ case 'playing':
+ return state.status === 'playing' ? state : { status: 'playing' };
+ case 'waiting':
+ return state.status === 'playing' ? { status: 'loading' } : state;
+ case 'blocked':
+ return { status: 'blocked' };
+ case 'error':
+ return { status: 'error' };
+ }
+}
diff --git a/website/src/components/DemoVideo/styles.module.css b/website/src/components/DemoVideo/styles.module.css
new file mode 100644
index 000000000000..f19d0022b53d
--- /dev/null
+++ b/website/src/components/DemoVideo/styles.module.css
@@ -0,0 +1,108 @@
+.root {
+ position: relative;
+ width: 100%;
+ aspect-ratio: var(--demo-width) / var(--demo-height);
+ overflow: hidden;
+ background: var(--ifm-pre-background);
+ isolation: isolate;
+}
+.root.activatable {
+ cursor: pointer;
+}
+
+.poster,
+.video {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.poster :global(img) {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.poster {
+ z-index: 1;
+ transition: opacity 160ms ease;
+}
+.posterHidden {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.unavailable {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ text-align: center;
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--ifm-color-content);
+ background: color-mix(in srgb, var(--ifm-background-color) 72%, transparent);
+}
+
+.controls {
+ position: absolute;
+ inset: auto 0 0;
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.85rem 1rem;
+ background: linear-gradient(
+ to top,
+ color-mix(in srgb, #0b1220 72%, transparent),
+ transparent
+ );
+}
+
+.playback,
+.activate {
+ appearance: none;
+ border: 0;
+ cursor: pointer;
+ font: inherit;
+ line-height: 1;
+ border-radius: 999px;
+}
+.playback:focus-visible,
+.activate:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 3px;
+}
+
+.playback {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.4rem;
+ height: 2.4rem;
+ color: #fff;
+ background: color-mix(in srgb, #0b1220 55%, transparent);
+ border: 1px solid color-mix(in srgb, #fff 35%, transparent);
+}
+.playback:hover {
+ background: color-mix(in srgb, #0b1220 75%, transparent);
+}
+
+.activate {
+ margin-left: auto;
+ padding: 0.55rem 1rem;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #fff;
+ background: var(--ifm-color-primary);
+ box-shadow: 0 8px 20px color-mix(in srgb, var(--ifm-color-primary) 35%, transparent);
+}
+.activate:hover {
+ background: var(--ifm-color-primary-dark);
+}
diff --git a/website/src/components/DemoVideo/trackDemoEvent.ts b/website/src/components/DemoVideo/trackDemoEvent.ts
new file mode 100644
index 000000000000..32dfa8a33af8
--- /dev/null
+++ b/website/src/components/DemoVideo/trackDemoEvent.ts
@@ -0,0 +1,6 @@
+export type DemoEventName =
+ 'demo_play' | 'demo_pause' | 'demo_activate' | 'demo_show_code';
+
+export function trackDemoEvent(action: DemoEventName, id: string) {
+ window.gtag?.('event', action, { demo_id: id });
+}
diff --git a/website/src/components/DemoVideo/types.ts b/website/src/components/DemoVideo/types.ts
new file mode 100644
index 000000000000..5682d3c07b37
--- /dev/null
+++ b/website/src/components/DemoVideo/types.ts
@@ -0,0 +1,65 @@
+export interface DemoPoster {
+ light: string;
+ dark?: string;
+}
+
+/**
+ * Recorded facade assets and their intrinsic presentation viewport.
+ *
+ * Site-root paths are resolved internally — authors never call `useBaseUrl`.
+ *
+ * Asset convention:
+ * - videos: `/videos/demos/.mp4` (optional `/videos/demos/.webm`)
+ * - posters: `/img/demos/.jpg` (optional `/img/demos/.dark.jpg`)
+ */
+export interface DemoSource {
+ id: string;
+ /** Required universal fallback. Site-root path. */
+ mp4: string;
+ /** Optional preferred encoding. */
+ webm?: string;
+ poster: DemoPoster;
+ /**
+ * Intrinsic dimensions; aspect ratio is derived, never separately
+ * configured. Required so SSR reserves space and activation causes zero
+ * container-size change.
+ */
+ width: number;
+ height: number;
+}
+
+/** Sizing subset of DemoSource keeping the live preview stable vs the recording. */
+export type DemoViewport = Pick;
+
+export interface DemoVideoProps {
+ source: DemoSource;
+ /** Omit for a standalone video without live handoff. */
+ onActivate?: () => void;
+}
+
+/** Media status only; activation is owned by the playground. */
+export type DemoPlaybackState =
+ | { status: 'dormant' | 'loading' | 'playing' }
+ | { status: 'paused'; reason: 'user' | 'out-of-view' }
+ | { status: 'blocked' | 'error' };
+
+export type PlaybackEvent =
+ | { type: 'visibility'; inView: boolean }
+ | { type: 'user-play' }
+ | { type: 'user-pause' }
+ | { type: 'playing' }
+ | { type: 'waiting' }
+ | { type: 'blocked' }
+ | { type: 'error' };
+
+export interface AutoplayInViewOptions {
+ enabled: boolean;
+ threshold?: number;
+}
+
+export interface AutoplayInViewController {
+ state: DemoPlaybackState;
+ /** Explicit user action may override reduced-data/motion autoplay policy. */
+ play(): Promise;
+ pause(): void;
+}
diff --git a/website/src/components/DemoVideo/useAutoplayInView.ts b/website/src/components/DemoVideo/useAutoplayInView.ts
new file mode 100644
index 000000000000..df9e1dccb730
--- /dev/null
+++ b/website/src/components/DemoVideo/useAutoplayInView.ts
@@ -0,0 +1,132 @@
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type RefObject,
+} from 'react';
+
+import { reducePlayback } from './reducePlayback';
+import type {
+ AutoplayInViewController,
+ AutoplayInViewOptions,
+ DemoPlaybackState,
+ PlaybackEvent,
+} from './types';
+
+export function useAutoplayInView(
+ ref: RefObject,
+ { enabled, threshold = 0.25 }: AutoplayInViewOptions,
+): AutoplayInViewController {
+ const [state, setState] = useState({ status: 'dormant' });
+ const userPausedRef = useRef(false);
+ const attemptRef = useRef(0);
+ const playPromiseRef = useRef | null>(null);
+
+ const dispatch = useCallback((event: PlaybackEvent) => {
+ setState(current => reducePlayback(current, event));
+ }, []);
+
+ const invalidate = useCallback(() => {
+ attemptRef.current += 1;
+ }, []);
+
+ const attemptPlay = useCallback(() => {
+ const video = ref.current;
+ if (
+ !video ||
+ userPausedRef.current ||
+ video.paused === false ||
+ playPromiseRef.current
+ ) {
+ return playPromiseRef.current ?? Promise.resolve();
+ }
+ const attempt = ++attemptRef.current;
+ const promise = video.play().then(undefined, () => {
+ if (attempt !== attemptRef.current) return;
+ dispatch({ type: 'blocked' });
+ });
+ playPromiseRef.current = promise;
+ void promise.finally(() => {
+ if (playPromiseRef.current === promise) playPromiseRef.current = null;
+ });
+ return promise;
+ }, [dispatch, ref]);
+
+ const play = useCallback(() => {
+ userPausedRef.current = false;
+ dispatch({ type: 'user-play' });
+ return attemptPlay();
+ }, [attemptPlay, dispatch]);
+
+ const pause = useCallback(() => {
+ userPausedRef.current = true;
+ invalidate();
+ playPromiseRef.current = null;
+ ref.current?.pause();
+ dispatch({ type: 'user-pause' });
+ }, [dispatch, invalidate, ref]);
+
+ useEffect(() => {
+ const video = ref.current;
+ if (!video || !enabled) return undefined;
+
+ if (typeof IntersectionObserver !== 'function') {
+ dispatch({ type: 'visibility', inView: true });
+ void attemptPlay();
+ return () => {
+ invalidate();
+ video.pause();
+ dispatch({ type: 'visibility', inView: false });
+ };
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ const inView = entry.isIntersecting;
+ if (inView) {
+ dispatch({ type: 'visibility', inView: true });
+ void attemptPlay();
+ } else {
+ invalidate();
+ video.pause();
+ dispatch({ type: 'visibility', inView: false });
+ }
+ },
+ { threshold, root: null, rootMargin: '0px' },
+ );
+ observer.observe(video);
+ return () => {
+ invalidate();
+ observer.disconnect();
+ video.pause();
+ dispatch({ type: 'visibility', inView: false });
+ };
+ }, [attemptPlay, dispatch, enabled, invalidate, ref, threshold]);
+
+ useEffect(() => {
+ const video = ref.current;
+ if (!video || !enabled) return undefined;
+
+ const onPlaying = () => {
+ if (userPausedRef.current) return;
+ dispatch({ type: 'playing' });
+ };
+ const onWaiting = () => {
+ dispatch({ type: 'waiting' });
+ };
+ const onError = () => {
+ dispatch({ type: 'error' });
+ };
+ video.addEventListener('playing', onPlaying);
+ video.addEventListener('waiting', onWaiting);
+ video.addEventListener('error', onError);
+ return () => {
+ video.removeEventListener('playing', onPlaying);
+ video.removeEventListener('waiting', onWaiting);
+ video.removeEventListener('error', onError);
+ };
+ }, [dispatch, enabled, ref]);
+
+ return { state, play, pause };
+}
diff --git a/website/src/components/HooksPlayground.tsx b/website/src/components/HooksPlayground.tsx
index 756ac53125cf..ec2eca75552f 100644
--- a/website/src/components/HooksPlayground.tsx
+++ b/website/src/components/HooksPlayground.tsx
@@ -1,7 +1,7 @@
-import type { Fixture, Interceptor } from '@data-client/test';
import React, { memo } from 'react';
import Playground from './Playground';
+import type { PlaygroundProps as BasePlaygroundProps } from './Playground';
const HooksPlayground = ({
children,
@@ -13,6 +13,7 @@ const HooksPlayground = ({
defaultTab,
headerControls,
getInitialInterceptorData = () => ({}),
+ demo,
}: PlaygroundProps) => (
{typeof children === 'string' ?
children
@@ -35,14 +37,10 @@ const HooksPlayground = ({
);
export default memo(HooksPlayground);
-interface PlaygroundProps {
+type PlaygroundProps = Omit<
+ BasePlaygroundProps,
+ 'groupId' | 'row'
+> & {
groupId: string;
- defaultOpen?: 'y' | 'n';
row: boolean;
- hidden?: boolean;
- fixtures?: (Fixture | Interceptor)[];
- getInitialInterceptorData?: () => T;
- children: React.ReactNode;
- defaultTab?: string;
- headerControls?: React.ReactNode;
-}
+};
diff --git a/website/src/components/Playground/PreviewWrapper.tsx b/website/src/components/Playground/PreviewWrapper.tsx
index db5d2dc189ab..90115ae450a4 100644
--- a/website/src/components/Playground/PreviewWrapper.tsx
+++ b/website/src/components/Playground/PreviewWrapper.tsx
@@ -1,24 +1,65 @@
import Translate from '@docusaurus/Translate';
+import clsx from 'clsx';
import React from 'react';
import Header from './Header';
import styles from './styles.module.css';
+import type { DemoViewport } from '../DemoVideo/types';
-export default function PreviewWrapper({ children }: Props) {
+export default function PreviewWrapper({
+ children,
+ viewport,
+ headerControls,
+}: Props) {
return (
-
-
-
- 🔴 Live Preview
-
+
+
+ {headerControls ?
+
+
+
+ : }
+ {headerControls}
-
{children}
+
+ {children}
+
);
}
+
+function LivePreviewLabel() {
+ return (
+
+ 🔴 Live Preview
+
+ );
+}
+
interface Props {
children: React.ReactNode;
+ viewport?: DemoViewport;
+ headerControls?: React.ReactNode;
}
diff --git a/website/src/components/Playground/editor/EditorSurface.tsx b/website/src/components/Playground/editor/EditorSurface.tsx
index 48a7d4a58a6d..0f8e8a5ee8c4 100644
--- a/website/src/components/Playground/editor/EditorSurface.tsx
+++ b/website/src/components/Playground/editor/EditorSurface.tsx
@@ -3,6 +3,8 @@ import Translate from '@docusaurus/Translate';
import clsx from 'clsx';
import React, {
type ComponentProps,
+ lazy,
+ Suspense,
useCallback,
useMemo,
useRef,
@@ -11,11 +13,13 @@ import React, {
import { LiveEditor } from 'react-live';
import Header from '../Header';
-import Editor from '../PlaygroundEditor';
+import type PlaygroundEditor from '../PlaygroundEditor';
import styles from '../styles.module.css';
import TabList from '../TabList';
import type { CodeDocument, CodeModel } from './codeModel';
+const Editor = lazy(() => import('../PlaygroundEditor'));
+
export interface EditorSurfaceProps extends CodeModel {
layout: 'row' | 'stacked';
variant: 'playground' | 'standalone';
@@ -23,6 +27,10 @@ export interface EditorSurfaceProps extends CodeModel {
interactive?: boolean;
fixtureContent?: React.ReactNode;
headerControls?: React.ReactNode;
+ /** Trailing control on the editor tab/title row (e.g. Hide code). */
+ paneToggle?: React.ReactNode;
+ paneId?: string;
+ className?: string;
}
export default function EditorSurface({
@@ -33,6 +41,9 @@ export default function EditorSurface({
interactive = true,
fixtureContent,
headerControls,
+ paneToggle,
+ paneId,
+ className,
}: EditorSurfaceProps) {
const id = useNumericId();
const row = layout === 'row';
@@ -78,11 +89,12 @@ export default function EditorSurface({
);
return (
-
+
{row && documents.length > 1 ?
: null}
{documents.map((document, index) => (
@@ -133,7 +146,7 @@ function TextEditTab({
language,
tabIndex,
...rest
-}: ComponentProps & {
+}: ComponentProps & {
hidden: boolean;
interactive: boolean;
}) {
@@ -164,12 +177,14 @@ function TextEditTab({
>
{() => (
-
+
+
+
)}
@@ -204,12 +219,14 @@ function EditorTabs({
onClick,
compact,
hasHeaderControls,
+ paneToggle,
}: {
documents: readonly CodeDocument[];
closedList: readonly boolean[];
onClick: (index: number) => void;
compact: boolean;
hasHeaderControls: boolean;
+ paneToggle?: React.ReactNode;
}) {
const tabs = documents
.map((document, index) => ({ document, index }))
@@ -232,6 +249,7 @@ function EditorTabs({
onSelect: () => onClick(index),
}))}
/>
+ {paneToggle}
);
}
@@ -247,10 +265,12 @@ function EditorHeader({
),
fixtureContent,
controls,
+ paneToggle,
}: {
title?: React.ReactNode;
fixtureContent?: React.ReactNode;
controls?: React.ReactNode;
+ paneToggle?: React.ReactNode;
}) {
return (
<>
@@ -260,10 +280,11 @@ function EditorHeader({
{fixtureContent}
>
: null}
- {controls != null ?
+ {controls != null || paneToggle != null ?
{title}
{controls}
+ {paneToggle}
: null}
>
diff --git a/website/src/components/Playground/index.tsx b/website/src/components/Playground/index.tsx
index 4ec2a4d9cae9..73bd4bcb0d4a 100644
--- a/website/src/components/Playground/index.tsx
+++ b/website/src/components/Playground/index.tsx
@@ -1,19 +1,24 @@
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect';
import clsx from 'clsx';
-import React, { lazy, useDeferredValue, useState } from 'react';
+import React, { lazy, useDeferredValue, useId, useRef, useState } from 'react';
import Boundary from './Boundary';
import { useCodeDocuments } from './editor/codeModel';
import EditorShell from './editor/EditorShell';
import EditorSurface from './editor/EditorSurface';
import FixturePreview from './FixturePreview';
+import Header from './Header';
import { isGoogleBot } from './isMobileOrBot';
+import type { LivePreviewProps } from './preview/LivePreview';
import type LivePreviewType from './preview/LivePreview';
import PreviewWrapper from './PreviewWrapper';
import { StoreToggle } from './StoreInspector';
import styles from './styles.module.css';
import type { FixtureOrInterceptor, PreviewProps } from './types';
+import DemoVideo from '../DemoVideo';
+import { trackDemoEvent } from '../DemoVideo/trackDemoEvent';
+import type { DemoSource } from '../DemoVideo/types';
export interface PlaygroundProps
{
children: React.ReactNode;
@@ -25,6 +30,7 @@ export interface PlaygroundProps {
getInitialInterceptorData?: () => T;
defaultTab?: string;
headerControls?: React.ReactNode;
+ demo?: DemoSource;
}
export default function Playground({
@@ -37,6 +43,7 @@ export default function Playground({
getInitialInterceptorData,
defaultTab,
headerControls,
+ demo,
}: PlaygroundProps) {
const {
liveCodeBlock: { playgroundPosition },
@@ -48,25 +55,20 @@ export default function Playground({
[styles.hidden]: hidden,
})}
>
-
+ {children}
+
);
}
@@ -82,23 +84,89 @@ function PlaygroundContent({
defaultTab,
getInitialInterceptorData,
headerControls,
+ demo,
}: ContentProps) {
const model = useCodeDocuments(children, defaultTab);
// Defer preview transpilation so editor input remains responsive.
const code = useDeferredValue(
model.documents.map(document => document.value).join('\n'),
);
+ // `demo` is a fresh MDX object literal each render; effects key off this.
+ const hasDemo = demo !== undefined;
+ const [activated, setActivated] = useState(!hasDemo);
+ const [codeOpen, setCodeOpen] = useState(!hasDemo);
+ const liveRegionRef = useRef(null);
+ const instanceId = useId().replace(/:/g, '');
+ const editorPaneId = `${groupId}-${instanceId}-editor`;
// Hydrate Monaco on first show and keep it (preserves undo / go-to-def).
- const [editorInteractive, setEditorInteractive] = useState(!hidden);
+ const [editorInteractive, setEditorInteractive] = useState(
+ !hidden && !hasDemo,
+ );
+ useIsomorphicLayoutEffect(() => {
+ if (!hidden && (!hasDemo || codeOpen)) setEditorInteractive(true);
+ }, [hidden, hasDemo, codeOpen]);
+
+ // Move focus to the live preview once, when the facade hands off.
useIsomorphicLayoutEffect(() => {
- if (!hidden) setEditorInteractive(true);
- }, [hidden]);
+ if (hasDemo && activated) liveRegionRef.current?.focus();
+ }, [activated, hasDemo]);
+
+ const activate = () => {
+ if (demo) trackDemoEvent('demo_activate', demo.id);
+ setActivated(true);
+ };
+
+ const toggleCode = () => {
+ setCodeOpen(open => {
+ const next = !open;
+ if (next && demo) trackDemoEvent('demo_show_code', demo.id);
+ return next;
+ });
+ };
+
+ const hideCodeControl = (
+
+
+
+ );
+ const showCodeControl = (
+
+ );
+ const editorToggle = hasDemo && codeOpen && row ? hideCodeControl : undefined;
+ const previewToggle =
+ !hasDemo ? undefined
+ : !codeOpen ? showCodeControl
+ : !row ? hideCodeControl
+ : undefined;
const editor = (
({
fixtures.length ? : undefined
}
headerControls={headerControls}
+ paneToggle={editorToggle}
/>
);
+
+ const viewport =
+ demo && !codeOpen ? { width: demo.width, height: demo.height } : undefined;
+
+ const livePreview = (
+
+ }
+ >
+
+
+ );
+
+ if (demo && !activated) {
+ return (
+ <>
+
+ {/* Keep code in the SSR HTML for indexing while the facade shows. */}
+ {editor}
+ >
+ );
+ }
+
// Live preview only while visible — unmounts when hidden (resets store).
+ const previewInner =
+ hidden ?
+
+ : livePreview;
+
const preview =
- hidden ? previewLoading : (
-
-
-
- );
+ hasDemo ?
+
+ {previewInner}
+
+ : previewInner;
- return <>{reverse ? [preview, editor] : [editor, preview]}>;
+ return (
+
+ {reverse ? [preview, editor] : [editor, preview]}
+
+ );
}
interface ContentProps extends PreviewProps {
@@ -133,18 +258,48 @@ interface ContentProps extends PreviewProps {
hidden: boolean;
defaultTab?: string;
headerControls?: React.ReactNode;
+ demo?: DemoSource;
}
-const previewLoading = (
-
-
-
-
-);
+function PanelLeftIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function PreviewFallback({
+ headerControls,
+ viewport,
+}: Pick, 'headerControls' | 'viewport'>) {
+ return (
+
+
+
+
+ );
+}
const PreviewWithScopeLazy = lazy(() =>
isGoogleBot ?
- Promise.resolve({ default: () => previewLoading })
+ Promise.resolve({ default: PreviewFallback })
: import(
/* webpackChunkName: 'PreviewWithScope', webpackPrefetch: true */ './preview/LivePreview'
),
diff --git a/website/src/components/Playground/preview/LivePreview.tsx b/website/src/components/Playground/preview/LivePreview.tsx
index bee9dcac8a80..16245b95267e 100644
--- a/website/src/components/Playground/preview/LivePreview.tsx
+++ b/website/src/components/Playground/preview/LivePreview.tsx
@@ -1,7 +1,9 @@
+import React from 'react';
import { LiveProvider } from 'react-live';
import { previewScope } from './scope';
import { usePlaygroundConsoleDemotion } from './usePlaygroundConsoleDemotion';
+import type { DemoViewport } from '../../DemoVideo/types';
import Preview from '../Preview';
import PreviewWrapper from '../PreviewWrapper';
import transformCode from '../transformCode';
@@ -9,6 +11,8 @@ import type { PreviewProps } from '../types';
export interface LivePreviewProps extends PreviewProps {
code: string;
+ viewport?: DemoViewport;
+ headerControls?: React.ReactNode;
}
export default function LivePreview({
@@ -18,6 +22,8 @@ export default function LivePreview({
row,
fixtures,
getInitialInterceptorData,
+ viewport,
+ headerControls,
}: LivePreviewProps) {
usePlaygroundConsoleDemotion();
@@ -30,7 +36,7 @@ export default function LivePreview({
noInline
scope={previewScope}
>
-
+
.playgroundHeader.subtabs {
display: flex;
height: 100%;
}
+.demoViewport {
+ aspect-ratio: var(--demo-width) / var(--demo-height);
+ width: 100%;
+ height: auto;
+ overflow: hidden;
+}
+.demoViewport .playgroundPreview {
+ flex: 1 1 auto;
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+}
+.headerActions {
+ display: flex;
+ align-items: stretch;
+ flex: 0 0 auto;
+}
+.headerAction,
+.paneToggle {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+}
+.headerAction:focus-visible,
+.paneToggle:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: -2px;
+}
+.headerAction {
+ font: inherit;
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0;
+ text-transform: none;
+ white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0 0.5rem;
+ border-radius: var(--ifm-global-radius);
+}
+.headerAction:hover {
+ background: var(--ifm-color-emphasis-300);
+}
+.headerActionIcon {
+ flex: 0 0 auto;
+ display: block;
+}
+/* Icon-only Hide code: last cell of the editor tab row, same height as tabs. */
+.paneToggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ align-self: stretch;
+ margin-inline-start: auto;
+ padding: 0 0.55rem;
+}
+.paneToggle:hover {
+ background-color: var(--ifm-color-emphasis-700);
+}
+/* Stock Live Preview header: same box as a tab (emoji cannot grow it). */
+.previewHeader {
+ line-height: 1.25;
+}
+.previewHeader:not(.tabControls) {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ height: calc(1.5rem + 1.25em);
+}
+.previewHeader.tabControls .title {
+ flex: 1 1 auto;
+ min-width: 0;
+ line-height: 1.25;
+ white-space: nowrap;
+}
+.previewHeader .paneToggle:hover {
+ background-color: var(--ifm-color-emphasis-300);
+}
+/* Collapsed: conventional top-right Show code. */
+.playgroundContainer.hideCode .headerActions {
+ margin-inline-start: auto;
+ padding-inline-end: 0.5rem;
+}
+.codeCollapsed {
+ display: none;
+}
+/* Focusable live-demo pane (receives focus on facade handoff). */
+.previewPane {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+}
+.previewPane > * {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+.previewPane:focus {
+ outline: none;
+}
+.previewPane:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 2px;
+}
+.playgroundContainer.hideCode .previewPane {
+ border-top-left-radius: var(--ifm-global-radius);
+ border-top-right-radius: var(--ifm-global-radius);
+}
+.stageEnter {
+ animation: stageFade 180ms ease-out;
+}
+@keyframes stageFade {
+ from {
+ opacity: 0;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .stageEnter {
+ animation: none;
+ }
+}
.debugToggle {
writing-mode: vertical-rl;
@@ -204,8 +331,10 @@ div > .playgroundHeader.subtabs {
/* tabs */
.playgroundHeader.tabControls {
display: flex;
+ align-items: stretch;
justify-content: space-between;
padding: 0;
+ line-height: 1.25;
}
.playgroundHeader.tabControls .title {
padding: 0.75rem;
@@ -218,6 +347,7 @@ div > .playgroundHeader.subtabs {
.tabs {
display: flex;
+ align-self: stretch;
overflow: auto;
}
@@ -227,6 +357,7 @@ div > .playgroundHeader.subtabs {
cursor: pointer;
display: flex;
align-items: center;
+ line-height: 1.25;
}
.tab:hover {
background-color: var(--ifm-color-emphasis-700);
@@ -460,4 +591,10 @@ div.fixtureJson {
.playgroundContainer.row .playgroundHeader.tabControls:first-of-type {
border-top-right-radius: 0;
}
+ .playgroundContainer.row.hideCode > .previewPane {
+ flex: 1 1 auto;
+ min-width: 0;
+ background-color: var(--ifm-background-color);
+ border-radius: var(--ifm-global-radius);
+ }
}
diff --git a/website/static/img/demos/acid-update.dark.jpg b/website/static/img/demos/acid-update.dark.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..4469290aa2913d61682a9585c124ed2222c74fff
GIT binary patch
literal 39677
zcmce-1yo#1(aOa(pSr&T9?KZI8gsHRvN5tSGXwXtz;gfr85tEB747E_
z;|T^PCME{t2YF#(Vd3H7L7opuTwFXn0zv|Od^~(YLSka#r%%brX=&-$*aYu8flmMc
zf(3zqfBax!prJr;@StD6e^h{=pkZLq04NYN6chpsEEFs>3=|YJ2nu+D4h=)bf?>$&
zAQGtvORn_E53~G>=r;D>yK|=K9RFIE*~n18r`=6=m?-CsISnVCjitF0Qw1VKLa2`Ly(|D
zqXR;~A+G$(H3VVbcZmLIh-84j2{0W2Z6*wb1|$Lq!YJ&jhPA
z1OO!w;QR&u@74d=;QzVgPyY}7{MG9(lK!RZzc4*=(F(0|Z$p&Osk%tMi@XOKFj0)t
zv~FbYftUBdJh@+$(|~?Z6a3v4CQ4?>Kgx?-`jx+ggKaJn0gmK$z=RYg!WV`Vszry_
zTBvhw-;IH|TvBTOd5E*JYLReBiNHiG?*@%=FnZqrZvBMicc>1_yga6w2FohMpvO5T
zaC38%QLALg6P>*Q{jIfqvLC^ZVD!WnK1CMP@LLTQtySxt_XZ#(msh`i0+iMB34hk#
zN_?b{XwPHTDNr=aXq%awQIUQX_&R3HDvW+=+Bd_y&I&2XUTb18=)e~fAI?me&5(8}
z)?Oz#A1L<6se5X#t?fA@mzFctMHBcWn=^y(t;bvJJOR%LMkd*SVu0>eAnK-DdMqWx
zu-9j~2wSkd204XkKf-i!t^YJu*kZ5|+RB_!yS1V66^QN1R@JC!kU^StjVx&@Uumrw
zZ1a+8R&8a@YP
zls3d`;PTqzwT!%ywE_8ZXC8J$#yUw!vK1@h
z0{l`dOPS+=|DfhhZ)?#Dx3b5@uNv~GK$5n0i
zp7m_=uo&qWqK#I~#czkarBW+kQkc)LClE#=k?qa(s*@^ZZSt+=9wvu|J5!OJwt30b
zGWu+C4Ft~NQnxPJwyborsH-y&`v{dqumJGk6H4d@$_`fWuF#)1Z+8;qlgU5vt|f5m
zW+-yXlNElUChSWMmrM>rbiTyD2JC*vp^ZXK4sX
zP^K!1QoyW_d30Y)Phm(i=sLLwE)YmTAz$e!ZEf|ka+>62z-?R6$t^+G)%1j}$>*8l
z)~H&mS0c^%uXpXgJd3h1oCOGPgYe+kl3{PZ6zS%ftYnK~Q}su*9kh()
zdoShwQKJ%$GUJqSwIGXeP5U(n<2wnK&Rjx7A@4hS%f10lqqJF96%l?I)}2`hUGy?t
zDTQd0J%d~Zp9_i3v7kiwUSeu)*hw3vj-kGA#$(Oi?_9;bUF+x``znCR@NZ0I{{Z^@}j
zL3)|Z6zHV=-~{{_&El&d7=N*4sPTcVq!g)~q&|aeMV*Rrt$e|+sBQb%zU7hI9G5QG
zb%>LVyfxD0)ul%3>RHqT{h*PPl_>{i`$P@17OFa@e!c+AUD0zE9R8<|N}W6N2f7E}
zlFH;1OeRkVSV}Hhy|rbA3jZ{m<=fQGN@!q=+?B7;6$5gtFdEo3wrW7*t8|L^|ms
zhj463gk6^}viM}A2t$jjq;MT%=lBKC^O!zRiQ_sf^nv_l(DS
zJTf3Q`c%1^lmMk|0T3zp))KQ?98)+4k2SMxJ&9bfFnf;D^9Vav$>r&jk8!O~d-YtD
zmw4d@1PCzHCMOek>G-CdZKmAiarC*A!c4-LyJB5^6?1;>ireyv#=(y`j@3(d`-Ne9
z8e7^%ZEK<0#S~HFr5ZvsHP&=w!h=K{MehMJ1CfgI=C<0n^H60zV+mnUad|ye=;6Ww
zhaC_AcXFl*n@o%^Qk>eSby*rho&5+VR?w*Z^NV=mCNp#VkFTtf9j7NU6DxH5B5~1l
z{ikMTncpeuv?NR&-UIa^Cdzrp0pYC?ZtK%F6?4}gTAgz;!P<;!E()+(A4OKJ&*9EE
z>)@?K{dMlrFq7~UF`G46dCF9A>k6_DlzkaI7k#Vd-ej<{G1`B^73e}tn
z1r+RAE3Po-P_~RFGwEOvYYruY*_jity4Nx}GZ|X3$-^Tas~m*I$h7}xoc^K
z`mVu(8)4QX?c1KcxQ+w+qnyRwWk*Wp5q0okVIX
z#tf|DP#QnloSWn)^4R-?uq$*{N0k$zdPRGSR-0Pdt#$ShD)P5Xe9DsY?z%ySqH8dj
zY?w$!{6XX^OH!qIYlNAi*gY@vQqq=!rTnYEa;^x&%qZXsu(26$&>5Sq)NFW0H6Zyb
zAgRv1v_hJB!Ep(P*(H^x*WcOPKB>zdh~U<;Zf9(P`HAhy?1j+&5-qCmK3;ye1G<8p
z>4@e}~^ytn^3)X6chuHoNnot3
zES}vN;nvmOwkG&KX;&aZKe?fx9=rXrNrM!LDrEDWUYs*;Tm;+FfAK?;wYM~x!A%o1
z<+y3aWK=9IZKH@?04u7|baBdJp<6~kotoDDb*cTZ
z1dBGsy9^UE8NXQn9~mWU(cSc)gW0{|J~5|sGC+r>YK-Y(?aF>_tzurK0$-(htFb0R
z*-F+GY2YxuRJdtWkvkc;DTM{$n|y{ln6w?c7<^OdGv|W`D4{_efloewG(9|C09n-n
z&PEGd?#<7*Sezl-~f>bZd*487k`JBpeq+*epQN4JhgTfy0W+&3fI2
zC_Oex3j1tp3+*?Uq=$z@2$1y`@ao@Ke?jvbz&J|%gsJ|CP*VQop)dNt*h7C1Jb(2E
zK?I>KDlKvqpon^)2FCFOLbBwuhgK30N)LaM`UmO1(!l#b!>_gwcK$);Khgi9ll&)7
zzsUS2%D=gSboyVJ`X}^%690e3{-5v=X8%oSzwkr8#UI)HR~i5BYy{K17NTM!o&E@)Y#*MGE7n1u67JmwY8a
zOmQCY7fjp(jDpzHjFZ>l3u2Ig0D+45^$Pg)`h!S8;sGcK6TRTwLC_*tf8uiVI;OT}SL%b;@kz4)OP<=%%{`sx?G2aNczU+NB;j`++2ZOi(TL>wOttf?uZKxAMn$7~}s(n}EmpU+Q12f&empd5gCW0y)CK3f~Ky4|`8Cp79$}L&=Ae
zFkgaD6O89*nv}0iAVhEcAv*kDi2f$M7r$Z`b0pwH%QL{B$SN@K-p^k07((>=*j{i@
zjmj{FukSmc_m6MrQ5p14Pto6=q5tl-`OAOvp9yvcG{P5v?;$h4ytOYY8nhw6m})o+
zvx7PXdMyP~mIn)+leEc>xjeq@`GGM#dgVPFKei7>(-^a;U+;4WWhr(i^tZ09X|t8&
zC$G&mp>Al#gy5;O+Amp&Pdwqx9Kr+{Inz
z;=5|o%v$TJK`_@F1&fWAP6qKrbR15?ltGy;TeWyKsFSie`vY_*FeAIhc=98|H1KjY
z?mZA*xHogV>C_Xl$&%gSZrxwkw76G0c8E4~H|s5g&Od$2cHTL%mEJ-hCI0RQa<~?}
zXhXW>$@$4G#p;9wd-Xbkx&o^ij1!y~zexFOG>?$6CHwt6{H=$?B?FiSI4J?^eM%-aNxDyeU`oJg*pf*D~(`fi^O`sMw~J))K2&
z=b&JUC0*v_aEdUilt2nW-QBLB#fDLtYH75g1`=uTs_x^wke2j}zG#sE#2OhaKAxfH
zPO^GC`k$RH1$0xJCEpWT+zNvtpeCfb9hIw_Zq`!I;|lGby{onQeit98`+4w^{iEYm
zoUG`QbGN&Po*HYv%GA+##Wn1R^D2C)mBVd9+nyi$hmUoKjqiF@w;3Yz26fWiTyiKa
zz43ECC5wDd!kqL-OO{Pq#+yxm>kP3RR3Fq!7WeUq<=E7H$B3M=nyBigVq`1aiZvHZ
zyk-89+JB&4KwhFmIjEUW-mlx*$M(wQb4K0Hv&rmZx{U7@yBwb
ztHW%o!;Xb!^n)Szoj5r|Rs;z5K%bsDR;_WkbbxbWI%@hT?OkqBm$x@@{4eX?Oe)4J21QhlSCp~u%MHAQZVF@7m5){#
ze>Kz$|75G|>W%^Q_4^{K%jo!EI}#bM!I1b8!>sGn+%O$gsU@pNg~z9^@PzEg^w=3+
zV|?aY@xpKlRkJy|l2;^8QC^Ol)3aZBGfQXVV#MVPdUxu~HyBw}TMzmuQgtq9C}RDO
zXD!5CjD`kADSfFLf1Rx$xqi*fNABrob{ev#~dj#mM-zqv|lH=at3SM0~tSrwB^#7l5?FMsJ9@6TWZv9tj8enYXm5VPfX6!%8_=b_xOKkKQ
zC@IAhvpf2fm|U^2>s#O4j-?Yb#x{E4(s(Q&YmkTyhzZ}NXZM1GRd#c71gM$@wgrr_
z3*q?Py@jL>!l4Xw)+>?y6f_9nbM662itqyUq(0m1W{M|QSVER>)}0HtNgM|F3Qf7b
z=oZ(|ad4#^-2-b4i_JEYiv%?CRi#w3N5g`;AiFuPR)WEWV%KUdHdCF3If-w|>bG2D
z>Xfo-vMdzx255%Jc;2%DBLufpgjZ)aP_JGo1m)QXTP2nBK1rto_)Ll|*IPK7@aQ}k
zk&yzy9QEatnY(3~#vRel)`^Y^1f)c!dYnr}T)8_(9&aQ}W!%fxyB*9rVxA;Ar0!aa
zzz}^Qc+195W?@iq#Q9iv{5!MpC}lFIOpGNeNDimRbE57%I~H1X&aCwAc{BzUh4fpw
zgtCpU0G7!c*%ZxVHCfrHSt!|e$(l2b*$qSTnHDZBxm)!(GBdKAg(>yS@3@CfviqKzYpKne0vZVNO@-IaX!uqHabYC{RR_DS6OJ_Y!Cbpg{={REilp0$D6>T~VZs{<7?m>x`J@sULIGb@!B2I7kdjMW`>#7MU3nhY1
zahux-_I$(0b`YyJ3-RTQlksG)=s@OZPJS5?&v>5wO+{7o07^fqy3s`9YVn<{vdnB)
zB0K5SA7lNWw)el5SOf6rpiW_g#~8EFooBre1J8*DwKG9fCReuSi}Nl6%X$9!J>YNy
z^A=7xBcF3&X~bN5>)dH;9X+f15o+aFrE#YdU74IEth#A22>EfdTSFRrztc_UMSQns
zc~UDE@r`?jeb{WQ_a$*s#|r*?m3ttkHD<*<=#c)@YVF8vbr}DGb<1VC{74zWTBd`{
zsZYy+G$$hC(0D;+qdw@6DdfbIS9xSHoLf`e>>jXbN&gUG)?YP>a)5V>ao#n+zf!;<
z`81i+U0ih3!>N(h$>Esk1FvRjh#4sj&c
z6JOa{-zPqD3`U9ST7wBO&uIH@eDm{ESh1cyDMBb0
z7JHi=!TX+Dd&_8ZqXilWCCkdqWMK0(hJ%IXnj9rC`Hx-XnO++h(vKta;zW!YS`(f@WzAZazgTvqOFMnEC?u!dwo;2hR+yR4z+jv1R$v?20mLT(jDdbe2
zrtWxtZNDdlW4|L0Z0TG;A(N3mV1AbDL^n0*-EtKj75aC9TqJWmd(
z!~*)vd7EH-NthsEp1bIz@72`LVzsuH6)LcaSE|a&NMoBrN=TGyyfj(Kb!F$eU+Vq(=BZo&t!Lzh|2WsPHPQaf*wRVODf6gz`KV!W{4S=>=2he+b
zq3u!t=d^U^emeCb@h5d%!kMPMzB-YTXEkSnqo(kAYl;=dsN$rKzT;`iG|wBAeO@)c
zzdfR+6pZU@G*uNY>6&IOuDAZ=tMn;c1B+Cn+npo#nQKodow!;~<%Fq`_h8S*U{t_O
z_>t51cvR#t$M-IMp(?c1jEO80_-ocL7vEr$_7Z69hlS)lcP#RrR1Ttsv0G>)m~$5f
zIlo&~=W!^k%V)taa4ATu&WDMxezfrQx_h10@eo@?%9L~>SXGT+temp5xa-XKu}@AE
zxWrmkn%6||2TKD558O^2nbsg@U0lbu+JN$EcgC@9I~}o^dS@>ZRF73HY#9zSRKhL)
z8Vs3C)37?j?apMc!u892()X>dE6;DAlh4K_9Kx=T7T^Y~*ZY7wlM<3jUjwUGa#9U<
z{Yc~e18T!)VXA~fK`_Z$sjH@wO3U5Id`KX#TDXFO$AcUCjm1_ruYkDrIP#^bNQbuB
zCj0n-$VwBh&%`5A$3|y
zfKB)9Jum~9^glF*p(S}d44^@f3+?^J`_R&7=AUKhXa9iX21GyoxLJZ5{MClj6oU62
zcm=A2U>doGoTT{G+`2z7ei(o}ANcy+`2%Ph$^#*g27t^@jvksp;J&u)S$P`(+#M}|
zC`1Jr%*k6sz?J?{Lme#-5`y5vpe5G-DUx5fAy2JeNPkM
z`P9$ae}U0jfqd}uOD#a{AJAkP|C9))8iap{wgaZr1W6#GAz&^4-^t@I$^MI!ev1Xt
z@IjEEhfnVd^Yd%`D)}i|?w*wkWpmkGXV&{PMad8su70lZ>EHiN;O=+
z@OIPy`gLb-jEH(Fk7HmId||e#W(e_4t{e~g&3J<&Wi@H64Ha-qg}pn2Bi|4jz_ajX
zoy2%xn)WI7iHnH(;55gmHT8d)vNOs#YXa8IrE^cJ27-yzhX&MNQ038>_eH|%g0Vo1
zK8nr0zJy?%k+PYz=*oeQyz1SX
z%BGvwljG~BzNP@H0MCc!lCd0mlLA6>;zB{FNA#
zkcYDEBN-I(JE#xBkT+6V!L5Nu;i;b(#4l6$`CF)_=bB26FQf3&Mqlm|{T)NpTfy|f
zW+U2GO>ELw?Ds)LteAb;B_`bf@c>_}l6F(FzD+KC0k0wpN9>5RaZM~+bEYbcM$?Qe
z!xub97`!m#fEWw~99UrAmf*)hntFwYh7TBQI?L;DbZS~a+M|{79M!AtOPz!%%psX<
zZ5rosPc2QDQh!prfv_kDhE(C-raL4E^4-7)^bGsjw{u~^6Z-fgeXXZ^1SQ|6u70O*
zo)h;~0MT=@!YNVr2{e7o_+MCvC3}4nL3ec{vQ{{7F36C<;h&H4q%)mw8Bb+?g5{Ex1bt_mD6s;xM5v^qqCdAK*)Mpwy-X_bq5u)
zdjS0E(#GH*J7-_mUrstSC_BN9$ATTy2$XHesc`XP;mWz)(#Y1+NBK?*`2iZ_{{7V$
zo;qPuppoH_D~;^lerZN2#G_EK%I6~gf~Zp+YjxWyVAK40_-Nt||A;NuaFOW24SldZ
zQeGnt!4rl}fM@!v$;pBm{m8IRAO@N^i$WPGoDu$x0vGXUbLRpk0=kJ&M<8nAA_?<4
zk7o0325l1N6DCs>S4Xaz$tQq#DrKSUJI2ho48?HyL`{tfv@>-Ts*fsd`GR{Nn91ww
zAR7$wpQDfZ9m40olL*2{X?c4>zJXgrxj)dTvq(Xh>`D3!=2!(v@mOe#k|6QhakR(Q
ze9k`(5+}$rG^o0IzOE|f=Q7v?wC6z^<&p|(*6A$zE?ac%Txv}CG;HF1_^{k;8~h2C
z$@JUSXrZY+iz#j;wt`zZhcUFLuGG{C!3f24yfi}VyihIUTuu}EtGC3KKSBjF8J|6U
zX9VJxx5*71j5P^eH{1@NrgDry2ZW{zO6kn*gQ4F`?o;J4b>Hl~X
z_TAAbWreajT_R10=aBu{?BejE_HKGAUB2xgUOp_VFQA;Je@Lm7IpApq@%+$e=e)Vz
z_ha|Q9CE63c)LG?@{L<=X4s#$;MJ2BS3yg2Ic;j{UFv#{U}iH+UxkBK*RipAVml!>
zM2{8|$AN?S&Hem>@d5AppFK`B=6$zifC3)BfQLLa0#@=DdIB$y;+^U?BTUW$sbHm@pWWYkOw((#Wp$Y_fsU~
zut-o6zMda*l4f1Fv9@`Gfm#_#K1%3OERF`pU*cz325+cZF#G~e*4u=c+Q83f^9CnMAx)y^T83-pW2DPWh_hnECmFAX_+U~a
z6)rT3mRTDLNaOIc1m`_5Wy(xSl^4bpmDT5seCbXs{Bmusk32AXFYW=0q0va0%$Z+Q
zk^IEvhmmQ4IgdX)c!>SQ&Sf9AIR+7`Bm7ru}fLz5_k3U(|~z
z3eZ}(OgsC&ThibnA(hy8{Pjis_pMXMt-WreH;;KcM@cA4DLH!K))xuDuitlf`5nzA
zbi0IQb;>v;Bp(Mp6HT)*=bY$F=waS4$PU$f{N@Hef{+P=DwDC)MU5q_c}&MVdAKI%
z*kseRFKVJVv3OmvISWJ6bUtQ;r$$)T7w