@shivaedev/react 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +578 -565
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -8,11 +8,11 @@ A refresh should not blank a useful screen. A save response should not erase wha
8
8
 
9
9
  ```ts
10
10
  const editor = useEditor({
11
- query: api.get.query({ id }),
12
- fields: InvoiceLineFields,
13
- values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
14
- save: (values) => api.save.run({ id, ...values }),
15
- runtime: InvoiceLinesClient.runtime,
11
+ query: api.get.query({ id }),
12
+ fields: InvoiceLineFields,
13
+ values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
14
+ save: (values) => api.save.run({ id, ...values }),
15
+ runtime: InvoiceLinesClient.runtime,
16
16
  });
17
17
  ```
18
18
 
@@ -26,26 +26,17 @@ State belongs to an **atom in a registry**. A query hook reads that atom's nativ
26
26
 
27
27
  `useQuery` and `useAction` turn native results into `data`, `cause`, `pending` and `refreshing`. A query's same-identity refresh always keeps the last successful value, including after a typed failure, so a component can show data and an error together. An action uses the native result's retained success. Keep the atom's identity stable for the same work: declare it outside render or use the native family's identity, such as `AtomRpc.Service.query(...)`.
28
28
 
29
- An **editor** joins a query atom, an form field schema and a save Effect. The query provides the row; `values(row)` converts it to input values; the schema decodes those values before the save runs. form owns the draft and the per-field merge. A **create form** uses the same submission rules without a query and starts a fresh form after success.
29
+ An **editor** joins a query atom, a form field schema and a save Effect. The query provides the row; `values(row)` converts it to input values; the schema decodes those values before the save runs. form owns the draft and the per-field merge. A **create form** uses the same submission rules without a query and starts a fresh form after success.
30
30
 
31
31
  A **session generation** is the string returned by `identify(session)`. `SessionBoundary` owns its client, registry and React subtree. Your auth owner supplies the session and decides when to replace or end it. The boundary automatically mounts live connections on bound contract APIs returned by `connect`, directly or as client properties such as `api`. Use a Platform database host and NDJSON transport; the application needs no subscribe handler or `live()` call. Live changed hints coalesce over 20 ms by default. Set `liveCoalesceWindow` on `SessionBoundary` to a native Duration to choose the window for that session, or `0` for immediate per-hint refreshes. A bound query can override that default with `useQuery(atom, { coalesceWindow: "2 seconds" })`. Changing this option applies to subsequent windows without reconnecting or refetching any query; a window already started keeps its deadline. A refetch in flight runs once more after settling when further hints arrive. Cache policy, RPC declarations and transport stay with native Effect and the application; this package supplies their React state and lifecycle boundary.
32
32
 
33
33
  `useEditor` and `useCreate` opt into persisted input with `persistedDraft: { key }`. Choose a stable key for the record or create request, independent of its server revision. The adapters use form's browser storage and inherit ownership from `SessionBoundary`: reload and query refresh preserve edits, successful submission clears accepted input, and a committed session replacement drops tracked drafts. A suspended replacement that is canceled keeps the visible session's draft and future writes. Later edits made during a save remain persisted. Optional `storage`, `maxBytes` and `maxAgeMs` customize the provider and bounds; defaults are `localStorage`, 256 KiB of UTF-8 JSON and seven days. See [form's persisted input API](https://github.com/ShivaeDev/platform/tree/main/packages/form#persist-input-across-reloads) for direct form hooks and limits.
34
34
 
35
- ### Read the current time
35
+ ### 1. Once per application
36
36
 
37
- `useNow(interval, runtime?)` from `@shivaedev/react/client/useNow.ts` returns epoch milliseconds from Effect's `Clock` and re-renders at the given interval in milliseconds. Pass the component's atom runtime to use its clock:
38
-
39
- ```ts
40
- import { useNow } from "@shivaedev/react/client/useNow.ts";
41
-
42
- const now = useNow(60_000, client.runtime);
43
- const minutesAgo = Math.floor((now - savedAt) / 60_000);
44
- ```
45
-
46
- Without a runtime it uses a native empty atom runtime. Stories supply a runtime with their TestClock service so advancing that clock updates the rendered time.
37
+ Set up the registry or session boundary at the application root, bind each session's contract APIs there and mount the notice host inside it.
47
38
 
48
- ### 1. Once per application: own the registry
39
+ #### Own the registry and the session
49
40
 
50
41
  For a screen without a session boundary, wrap it in the native provider:
51
42
 
@@ -53,7 +44,11 @@ For a screen without a session boundary, wrap it in the native provider:
53
44
  import { RegistryProvider } from "@effect/atom-react";
54
45
 
55
46
  export function App() {
56
- return <RegistryProvider><Orders /></RegistryProvider>;
47
+ return (
48
+ <RegistryProvider>
49
+ <Orders />
50
+ </RegistryProvider>
51
+ );
57
52
  }
58
53
  ```
59
54
 
@@ -64,148 +59,74 @@ import { SessionBoundary } from "@shivaedev/react/client/sessionBoundary.ts";
64
59
  import { makeOrderClient, OrderScreens } from "./orders.tsx";
65
60
 
66
61
  interface Session {
67
- readonly id: string;
68
- readonly credentialGeneration: number;
69
- readonly token: string;
62
+ readonly id: string;
63
+ readonly credentialGeneration: number;
64
+ readonly token: string;
70
65
  }
71
66
 
72
- export function App({ session, recheck }: {
73
- readonly session: Session | undefined;
74
- readonly recheck: () => void;
75
- }) {
76
- return (
77
- <SessionBoundary
78
- session={session}
79
- identify={(current) => `${current.id}:${current.credentialGeneration}`}
80
- connect={(current) => makeOrderClient(current.token)}
81
- recheck={recheck}
82
- signedOut={<p>Signed out</p>}
83
- >
84
- {(client) => <OrderScreens client={client} />}
85
- </SessionBoundary>
86
- );
67
+ export function App({ session, recheck }: { readonly session: Session | undefined; readonly recheck: () => void }) {
68
+ return (
69
+ <SessionBoundary
70
+ session={session}
71
+ identify={(current) => `${current.id}:${current.credentialGeneration}`}
72
+ connect={(current) => makeOrderClient(current.token)}
73
+ recheck={recheck}
74
+ signedOut={<p>Signed out</p>}
75
+ >
76
+ {(client) => <OrderScreens client={client} />}
77
+ </SessionBoundary>
78
+ );
87
79
  }
88
80
  ```
89
81
 
90
82
  `makeOrderClient` and `OrderScreens` belong to the application. A changed generation creates a new client and registry, remounts the screens and disposes the outgoing registry. `undefined` renders `signedOut`. Updating the session object with the same generation keeps the existing client: `connect` does not receive a rotated token until the identity changes. Include the credential generation when the client captures its token, as above.
91
83
 
92
- For a native shell, pass its resume source when binding the API returned by `connect`:
93
-
94
- ```ts
95
- import { bind } from "@shivaedev/contract/bind.ts";
96
- import type { ResumeSource } from "@shivaedev/react/resumeSignal.ts";
97
- import { onAppResume, onNetworkReturn } from "./native-lifecycle.ts";
98
-
99
- const resume: ResumeSource = (notify) => {
100
- const stopApp = onAppResume(notify);
101
- const stopNetwork = onNetworkReturn(notify);
102
- return () => { stopApp(); stopNetwork(); };
103
- };
104
- const connect = () => bind(Notes, Client, { resume });
105
- ```
106
-
107
- `Notes` and `Client` are the application's contract and native AtomRpc client. The application implements `onAppResume` and `onNetworkReturn`, each returning its unsubscribe function. `SessionBoundary` mounts the bound connection, and native notifications reopen the subscription and reconcile its tracked queries alongside the browser visibility and online defaults. Disposing the session's registry releases the native listeners. Omitting `resume` keeps the browser defaults.
108
-
109
84
  A query that fails with a tagged `Unauthorized` asks `recheck` to run and keeps its retained data. An action does the same once per failed result, retaining its last successful `data` and the typed failure in `cause`; rerendering that result does not repeat the recheck. The auth owner can retry the same session or remove it; the hook does not sign the user out. The boundary also handles React StrictMode's effect replay and preserves component state across hiding and revealing an `<Activity>` subtree, supplying a live registry when revealed.
110
85
 
111
- ### Migrate a self-only feature to session keys
112
-
113
- Before migration, an owner field exists only to give client reads and invalidations a key. The server already chooses the account from `Identity`:
114
-
115
- ```ts
116
- const preferences = collection("preferences", Schema.String);
117
- const Preferences = contract("preferences", {
118
- queries: [query("self", {
119
- payload: { ownerId: Schema.String },
120
- reads: ({ ownerId }) => [preferences.item(ownerId)],
121
- success: Schema.String,
122
- })],
123
- commands: [command("save", {
124
- payload: { ownerId: Schema.String, title: Schema.String },
125
- invalidates: ({ ownerId }) => [preferences.item(ownerId)],
126
- success: Schema.String,
127
- })],
128
- }).middleware(Authenticated);
129
-
130
- const api = bind(Preferences, Client);
131
- const self = api.self.query({ ownerId: session.userId });
132
- const save = api.save.run({ ownerId: session.userId, title: "My preferences" });
133
- ```
134
-
135
- After migration, remove `ownerId` from both payload schemas and every call site. Add `keyScope: "session"` and keep a logical item key for the self-only record:
136
-
137
- ```ts
138
- const preferences = collection("preferences", Schema.String);
139
- const Preferences = contract("preferences", {
140
- keyScope: "session",
141
- queries: [query("self", {
142
- reads: () => [preferences.item("self")],
143
- success: Schema.String,
144
- })],
145
- commands: [command("save", {
146
- payload: { title: Schema.String },
147
- invalidates: () => [preferences.item("self")],
148
- success: Schema.String,
149
- })],
150
- }).middleware(Authenticated);
151
-
152
- const api = bind(Preferences, Client, { registry });
153
- const self = api.self.query(undefined);
154
- const save = api.save.run({ title: "My preferences" });
155
- ```
86
+ #### Session-scoped bindings
156
87
 
157
- The examples use `Schema` and `Effect` from `effect`, `contract`, `query`, `command`, `collection` and `bind` from contract's defining modules, and `Authenticated` from `@shivaedev/identity/middleware.ts`. `Client` is the application's native AtomRpc service. In React, obtain `registry` from the second argument to `SessionBoundary.connect`:
88
+ A contract declared with `keyScope: "session"` keys its reads and invalidations by the signed-in account, and its binding requires the registry that owns the session's queries. The [contract guide](https://github.com/ShivaeDev/platform/blob/main/packages/contract/README.md#session-owned-keys) describes the declaration and its handlers. `SessionBoundary` passes that registry to `connect` as its second argument:
158
89
 
159
90
  ```tsx
91
+ import { bind } from "@shivaedev/contract/bind.ts";
160
92
  import { SessionBoundary } from "@shivaedev/react/client/sessionBoundary.ts";
161
93
 
162
94
  <SessionBoundary
163
- session={session}
164
- identify={(current) => current.generation}
165
- connect={(current, registry) => bind(Preferences, makePreferencesClient(current.token), { registry })}
166
- recheck={recheck}
167
- signedOut={<p>Signed out</p>}
95
+ session={session}
96
+ identify={(current) => current.generation}
97
+ connect={(current, registry) => bind(Preferences, makePreferencesClient(current.token), { registry })}
98
+ recheck={recheck}
99
+ signedOut={<p>Signed out</p>}
168
100
  >
169
- {(api) => <PreferencesScreen api={api} />}
170
- </SessionBoundary>
101
+ {(api) => <PreferencesScreen api={api} />}
102
+ </SessionBoundary>;
171
103
  ```
172
104
 
173
- `makePreferencesClient(token)` creates the application's native AtomRpc service and configures its transport credentials. Existing one-argument `connect` callbacks continue to work. The boundary allocates the registry before calling `connect`; an unchanged generation keeps its binding and state. Account replacement, sign-out and same-account re-entry dispose the old registry, private query tracking, live registrations and pending refreshes. Choose a generation that changes whenever the auth owner starts a new session.
105
+ `Preferences` is the application's session-scoped contract, and `makePreferencesClient(token)` creates its native AtomRpc service with the transport credentials. A one-argument `connect` callback also works. The boundary allocates the registry before calling `connect`; an unchanged generation keeps its binding and state. Account replacement, sign-out and same-account re-entry dispose the old registry, private query tracking, live registrations and pending refreshes. Choose a generation that changes whenever the auth owner starts a new session.
174
106
 
175
- Handlers continue reading the account from request-local `Identity`, imported from `@shivaedev/identity/identity.ts`, before and after migration:
107
+ #### Resume from a native shell
176
108
 
177
- ```ts
178
- Preferences.application.toLayer({
179
- "preferences.self": () => Effect.flatMap(Identity, ({ id }) => store.read(id)),
180
- "preferences.save": ({ title }) => Effect.flatMap(Identity, ({ id }) => store.save(id, title)),
181
- });
182
- ```
183
-
184
- `store` is the application's persistence service. No owner or client key scope is accepted from a request. The database app host of `@shivaedev/server` resolves the server key scope from that same request-local identity. `List` and `Item` remain the key shapes; scope is an additional dimension, and the client registry's opaque scope is never sent as an owner id.
185
-
186
- ### Trace an action across RPCs
187
-
188
- Editor saves and create submissions automatically run their save Effect in a fresh `ui.submit` trace. For custom actions, wrap the Effect that performs the action with `actionTrace` before giving it to a native atom:
109
+ For a native shell, pass its resume source when binding the API returned by `connect`:
189
110
 
190
111
  ```ts
191
- import { Effect } from "effect";
192
- import { actionTrace } from "@shivaedev/react/actionTrace.ts";
112
+ import { bind } from "@shivaedev/contract/bind.ts";
113
+ import type { ResumeSource } from "@shivaedev/react/resumeSignal.ts";
114
+ import { onAppResume, onNetworkReturn } from "./native-lifecycle.ts";
193
115
 
194
- const save = OrdersClient.runtime.fn((input: OrderInput) =>
195
- actionTrace(Effect.gen(function* () {
196
- yield* api.validate.run(input);
197
- return yield* api.save.run(input);
198
- }), "ui.saveOrder"),
199
- );
116
+ const resume: ResumeSource = (notify) => {
117
+ const stopApp = onAppResume(notify);
118
+ const stopNetwork = onNetworkReturn(notify);
119
+ return () => {
120
+ stopApp();
121
+ stopNetwork();
122
+ };
123
+ };
124
+ const connect = () => bind(Notes, Client, { resume });
200
125
  ```
201
126
 
202
- `OrdersClient`, `OrderInput` and `api` belong to the application. Dispatch `save` with `useAction(save)`. `actionTrace(effect, name?)` preserves the Effect's result, typed error and service requirements; its default span name is `ui.action`. Each execution starts a fresh trace, and RPCs run inside that Effect share it, including across asynchronous boundaries. Native atoms continue to own execution and cancellation. `useAction` also accepts ordinary native action atoms; wrap composite action Effects to group their RPCs.
203
-
204
- Use native `RpcClient.layerProtocolHttp` with `FetchHttpClient.layer` for the browser RPC protocol. Effect sends the trace in the W3C `traceparent` HTTP header. The `appHost` of `@shivaedev/server` carries it into the RPC span and its SQL, and annotates the RPC span with `request.id`. Enqueued pg-boss jobs retain that span context; worker attempts start separate traces linked back to the sender.
205
-
206
- ### One supported path for query, command and form feedback
127
+ `Notes` and `Client` are the application's contract and native AtomRpc client. The application implements `onAppResume` and `onNetworkReturn`, each returning its unsubscribe function. `SessionBoundary` mounts the bound connection, and native notifications reopen the subscription and reconcile its tracked queries alongside the browser visibility and online defaults. Disposing the session's registry releases the native listeners. Omitting `resume` keeps the browser defaults.
207
128
 
208
- Use `useQuery` with `QueryView` for reads, the Effect overload of `useAction` for commands, and `useCreate` or `useEditor` for form submissions. Give these owners notice descriptors and mount one `NoticeHost` inside the session boundary. contract still runs commands and invalidates queries; form still owns submission, fields and drafts. The app supplies wording, Undo payloads and expected versions.
129
+ #### Mount the notice host
209
130
 
210
131
  Mount the optional `NoticeHost` from `@shivaedev/react/notices.ts` inside `SessionBoundary`, above the screens. The host owns a native atom collection, dismissal, expiry and each notice action's native `AsyncResult`. Supply `render(notice)` to choose markup, wording for action outcomes and labels. Render `notice.action.run` as a button handler and disable that control with `notice.action.pending`; `notice.dismiss` removes the entry. No command requires a host.
211
132
 
@@ -215,73 +136,38 @@ import * as AsyncResult from "effect/reactivity/AsyncResult";
215
136
  import { NoticeHost, type Notice } from "@shivaedev/react/notices.ts";
216
137
 
217
138
  function renderNotice(notice: Notice) {
218
- const action = notice.action;
219
- return <article role="status">
220
- <p>{notice.message}</p>
221
- {action && <button disabled={action.pending} onClick={action.run}>{action.label}</button>}
222
- {action && AsyncResult.isSuccess(action.result) && <p>Undo completed</p>}
223
- {action && AsyncResult.isFailure(action.result) && <p>{Cause.pretty(action.result.cause)}</p>}
224
- <button onClick={notice.dismiss}>Dismiss</button>
225
- </article>;
139
+ const action = notice.action;
140
+ return (
141
+ <article role="status">
142
+ <p>{notice.message}</p>
143
+ {action && (
144
+ <button disabled={action.pending} onClick={action.run}>
145
+ {action.label}
146
+ </button>
147
+ )}
148
+ {action && AsyncResult.isSuccess(action.result) && <p>Undo completed</p>}
149
+ {action && AsyncResult.isFailure(action.result) && <p>{Cause.pretty(action.result.cause)}</p>}
150
+ <button onClick={notice.dismiss}>Dismiss</button>
151
+ </article>
152
+ );
226
153
  }
227
154
 
228
155
  function SessionScreens({ client }: { readonly client: AppClient }) {
229
- return <NoticeHost render={renderNotice}><OrderScreens client={client} /></NoticeHost>;
156
+ return (
157
+ <NoticeHost render={renderNotice}>
158
+ <OrderScreens client={client} />
159
+ </NoticeHost>
160
+ );
230
161
  }
231
162
  ```
232
163
 
233
164
  `AppClient` and `OrderScreens` belong to the application; return `SessionScreens` from the session boundary's child function. The renderer receives `id`, `message`, `dismiss` and an optional action with `label`, `run`, `pending` and `result`. Action results retain the native success value or failure cause as `unknown`; narrow domain errors in your renderer to choose their wording.
234
165
 
235
- The Effect overload of `useAction` accepts `notices.onSuccess(value, input)` and `notices.onFailure(error, input)`. Each callback returns a descriptor or `undefined`. Independent concurrent completions publish independently, preserving the invocation's input. `useCreate` and `useEditor` accept the same `notices` option around their existing save Effects. Use the descriptors instead of screen-local notice state, expiry timers or result-watching effects:
236
-
237
- ```ts
238
- const remove = useAction(OrdersClient.runtime, api.remove.run, {
239
- concurrent: true,
240
- notices: {
241
- onSuccess: (removed, input) => ({
242
- message: `Removed ${input.title}`,
243
- action: {
244
- label: "Undo",
245
- run: api.restore.run({
246
- id: input.id,
247
- title: input.title,
248
- expectedVersion: removed.version,
249
- }),
250
- },
251
- }),
252
- onFailure: () => ({ message: "Could not remove this order" }),
253
- },
254
- });
255
- ```
256
-
257
- `OrdersClient`, `api`, the restore payload and its expected version belong to the application. The host runs the supplied Effect with the operation's native runtime, disables repeated dispatch while it is pending and exposes the outcome on that action. A failed Undo stays on its original notice; it does not become another operation failure notice. Command execution and invalidation remain with contract, and form submission remains with form.
166
+ ### 2. Once per feature
258
167
 
259
- For query failures, pass `useQuery(atom, { notice: ({ cause, refreshing }) => descriptor })`. `useEditor` accepts the same callback as `queryNotice` for its query. `refreshing` distinguishes a failure with retained data from an initial load failure. Each settled failed result publishes once per hook; a rerender does not replay it. Omit the callback or return `undefined` for operations that need no notice.
168
+ Each feature reads queries, dispatches commands, binds its forms and reports their outcomes. Routes, permissions and auth screens use the same query and action state.
260
169
 
261
- The query and form in the same feature use their own feedback options:
262
-
263
- ```tsx
264
- const notes = useQuery(api.list.query(), {
265
- notice: ({ refreshing }) => ({
266
- message: refreshing ? "Refresh failed" : "Load failed",
267
- }),
268
- });
269
- const save = useCreate({
270
- fields: TitleFields,
271
- initialValues: { title: "" },
272
- create: api.save.run,
273
- runtime: NotesClient.runtime,
274
- notices: { onSuccess: (title) => ({ message: `Saved ${title}` }) },
275
- });
276
- ```
277
-
278
- Here `api`, `NotesClient` and `TitleFields` belong to the app. Render `notes` through `QueryView`: its loading renderer shows pending work, its failure renderer offers `notes.refresh` as Retry, and its content renderer holds the form and command controls. Bind fields with form's `useField`, submit with `save.save()` and disable Save with `save.saving`. A tagged field rejection stays beside that input. A held save keeps newer edits when it completes and publishes its success notice before the invalidated query finishes. A failed refresh keeps the last content and the draft while publishing the refresh notice.
279
-
280
- The [notes feature](https://github.com/ShivaeDev/platform/tree/main/packages/react/src/test-support/feedback-path) composes these owners with the existing in-memory RPC fixture. Its [feedback stories](https://github.com/ShivaeDev/platform/blob/main/packages/react/src/feedbackPath.dom.spec.tsx) cover loading and retry, rejected fields, a held save, concurrent command notices, Undo and its version conflict, and retained content after a refresh failure. The [session story](https://github.com/ShivaeDev/platform/blob/main/packages/react/src/feedbackSession.dom.spec.tsx) replaces a session during a command and Undo: the outgoing registry is disposed, late completion callbacks and outcomes do not publish, and retained notice action and dismiss handlers cannot change the new host. These are React render stories over test RPC handlers, not evidence of a deployed service.
281
-
282
- A descriptor has `message`, optional `expiresIn` as a native Duration, and optional `action: { label, run }`. Without an expiry it remains until dismissed or the host ends. Dismissal, expiry, host unmount and session replacement remove entries, clear expiry timers and interrupt pending actions. Native uninterruptible Effects may finish, but their old notice cannot affect the new host. Notices survive screen unmount while their host remains mounted.
283
-
284
- ### 2. Once per feature: native queries and actions
170
+ #### Queries and actions
285
171
 
286
172
  The hooks accept native atoms directly. This complete component module shows query and action state without RPC setup:
287
173
 
@@ -294,36 +180,43 @@ import * as Atom from "effect/reactivity/Atom";
294
180
 
295
181
  const report = Atom.make(Effect.succeed({ id: 1, title: "Quarterly report" }));
296
182
  const rename = Atom.fn<string>()((title) =>
297
- title.trim() === ""
298
- ? Effect.fail("Title required" as const)
299
- : Effect.succeed({ id: 1, title: title.trim() }),
183
+ title.trim() === "" ? Effect.fail("Title required" as const) : Effect.succeed({ id: 1, title: title.trim() }),
300
184
  );
301
185
 
302
186
  function Report() {
303
- const query = useQuery(report);
304
- const action = useAction(rename);
305
- return (
306
- <section>
307
- <QueryView
308
- query={query}
309
- content={(row) => <h1>{row.title}</h1>}
310
- loading={(state) => <p>{state.refreshing ? "Refreshing…" : "Loading…"}</p>}
311
- failure={(error, state) => (
312
- <p role="alert">{String(error)} <button disabled={state.pending} onClick={state.refresh}>Retry</button></p>
313
- )}
314
- />
315
- <button onClick={query.refresh}>Refresh</button>
316
- <button disabled={action.pending} onClick={() => action.dispatch("Annual report")}>
317
- Rename
318
- </button>
319
- {Option.isSome(action.data) && <output>{action.data.value.title}</output>}
320
- {Option.isSome(action.cause) && <p role="alert">Could not rename</p>}
321
- </section>
322
- );
187
+ const query = useQuery(report);
188
+ const action = useAction(rename);
189
+ return (
190
+ <section>
191
+ <QueryView
192
+ query={query}
193
+ content={(row) => <h1>{row.title}</h1>}
194
+ loading={(state) => <p>{state.refreshing ? "Refreshing…" : "Loading…"}</p>}
195
+ failure={(error, state) => (
196
+ <p role="alert">
197
+ {String(error)}{" "}
198
+ <button disabled={state.pending} onClick={state.refresh}>
199
+ Retry
200
+ </button>
201
+ </p>
202
+ )}
203
+ />
204
+ <button onClick={query.refresh}>Refresh</button>
205
+ <button disabled={action.pending} onClick={() => action.dispatch("Annual report")}>
206
+ Rename
207
+ </button>
208
+ {Option.isSome(action.data) && <output>{action.data.value.title}</output>}
209
+ {Option.isSome(action.cause) && <p role="alert">Could not rename</p>}
210
+ </section>
211
+ );
323
212
  }
324
213
 
325
214
  export function App() {
326
- return <RegistryProvider><Report /></RegistryProvider>;
215
+ return (
216
+ <RegistryProvider>
217
+ <Report />
218
+ </RegistryProvider>
219
+ );
327
220
  }
328
221
  ```
329
222
 
@@ -336,14 +229,16 @@ For both queries and actions, a successful `undefined` is `Option.some(undefined
336
229
  The example's action returns a value; it does not persist the report or invalidate its query. In an application, use the native RPC query and mutation atoms instead:
337
230
 
338
231
  ```ts
339
- const notes = useQuery(NotesClient.query("ListNotes", undefined, {
340
- reactivityKeys: ["notes"],
341
- }));
232
+ const notes = useQuery(
233
+ NotesClient.query("ListNotes", undefined, {
234
+ reactivityKeys: ["notes"],
235
+ }),
236
+ );
342
237
  const create = useAction(NotesClient.mutation("CreateNote"));
343
238
 
344
239
  create.dispatch({
345
- payload: { title: "Morning walk" },
346
- reactivityKeys: ["notes"],
240
+ payload: { title: "Morning walk" },
241
+ reactivityKeys: ["notes"],
347
242
  });
348
243
  ```
349
244
 
@@ -351,77 +246,275 @@ create.dispatch({
351
246
 
352
247
  An action's `dispatch(input)` returns `void`. Read the atom's latest result from `data`, `cause` or `result`. With the default `Atom.fn` action, a later dispatch interrupts the earlier work and the shared state reports the later result.
353
248
 
354
- ### Completion inside each command invocation
249
+ #### An editor and a create form
355
250
 
356
- Pass the native runtime and the original bound command Effect to `useAction` when the screen needs feedback for each invocation:
251
+ The next module assumes `./invoice-lines-client.ts` exports a contract-bound `api` with `get`, `save` and `create`, and its native `InvoiceLinesClient`. `get` returns `{ id, name, quantity }`; both commands return the saved row. The [contract guide](https://github.com/ShivaeDev/platform/blob/main/packages/contract/README.md) describes that binding.
357
252
 
358
253
  ```tsx
359
- const rename = useAction(ReportsClient.runtime, api.rename.run, {
360
- notices: {
361
- onSuccess: (saved, input) => ({ message: `Renamed ${input.id} to ${saved.title}` }),
362
- onFailure: (_error, input) => ({ message: `Could not rename ${input.id}` }),
363
- },
254
+ import { useCreate } from "@shivaedev/react/client/create.ts";
255
+ import { useEditor } from "@shivaedev/react/client/editor.ts";
256
+ import { QueryView } from "@shivaedev/react/client/QueryView.ts";
257
+ import { useField } from "@shivaedev/form/client/react.ts";
258
+ import type { Form } from "@shivaedev/form/shape.ts";
259
+ import { Option, Schema } from "effect";
260
+ import { api, InvoiceLinesClient } from "./invoice-lines-client.ts";
261
+
262
+ const InvoiceLineFields = Schema.Struct({
263
+ name: Schema.String,
264
+ quantity: Schema.NumberFromString,
364
265
  });
266
+ type Fields = typeof InvoiceLineFields.fields;
267
+
268
+ function Inputs<A, E, ER>({ form }: { readonly form: Form<Fields, A, E, ER> }) {
269
+ const name = useField(form, "name");
270
+ const quantity = useField(form, "quantity");
271
+ return (
272
+ <>
273
+ <label>
274
+ Name
275
+ <input name="name" value={name.value} onBlur={name.onBlur} onChange={(event) => name.onChange(event.target.value)} />
276
+ </label>
277
+ {name.error && <p role="alert">{name.error}</p>}
278
+ <label>
279
+ Quantity
280
+ <input name="quantity" value={quantity.value} onBlur={quantity.onBlur} onChange={(event) => quantity.onChange(event.target.value)} />
281
+ </label>
282
+ {quantity.error && <p role="alert">{quantity.error}</p>}
283
+ </>
284
+ );
285
+ }
286
+
287
+ export function InvoiceLineEditor({ id }: { readonly id: number }) {
288
+ const editor = useEditor({
289
+ query: api.get.query({ id }),
290
+ fields: InvoiceLineFields,
291
+ values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
292
+ save: (values) => api.save.run({ id, ...values }),
293
+ runtime: InvoiceLinesClient.runtime,
294
+ });
295
+ return (
296
+ <QueryView
297
+ query={editor.query}
298
+ loading={(query) => <p>{query.refreshing ? "Refreshing…" : "Loading…"}</p>}
299
+ failure={(_error, query) => (
300
+ <p role="alert">
301
+ Could not load{" "}
302
+ <button disabled={query.pending} onClick={query.refresh}>
303
+ Retry
304
+ </button>
305
+ </p>
306
+ )}
307
+ content={() =>
308
+ editor.form && (
309
+ <form
310
+ onSubmit={(event) => {
311
+ event.preventDefault();
312
+ editor.save();
313
+ }}
314
+ >
315
+ <Inputs form={editor.form} />
316
+ {Option.isSome(editor.failure) && <p role="alert">Could not save</p>}
317
+ <button type="submit" disabled={editor.saving}>
318
+ Save
319
+ </button>
320
+ <button type="button" onClick={editor.query.refresh}>
321
+ Refresh
322
+ </button>
323
+ <button type="button" onClick={editor.revert}>
324
+ Revert
325
+ </button>
326
+ <p>{editor.saving ? "Saving" : editor.dirty ? "Unsaved" : "Saved"}</p>
327
+ </form>
328
+ )
329
+ }
330
+ />
331
+ );
332
+ }
333
+
334
+ export function InvoiceLineCreate() {
335
+ const create = useCreate({
336
+ fields: InvoiceLineFields,
337
+ initialValues: { name: "", quantity: "" },
338
+ create: api.create.run,
339
+ runtime: InvoiceLinesClient.runtime,
340
+ });
341
+ return (
342
+ <form
343
+ onSubmit={(event) => {
344
+ event.preventDefault();
345
+ create.save();
346
+ }}
347
+ >
348
+ <Inputs form={create.form} />
349
+ {Option.isSome(create.failure) && <p role="alert">Could not create</p>}
350
+ <button type="submit" disabled={create.saving}>
351
+ Create
352
+ </button>
353
+ {Option.isSome(create.created) && <output>Created {create.created.value.name}</output>}
354
+ </form>
355
+ );
356
+ }
365
357
  ```
366
358
 
367
- `ReportsClient` and `api` belong to the application. Input, success, typed failure and runtime requirements are inferred from the command. Use `notices` for the feedback path above. The optional synchronous `onSuccess(value, input)` and `onFailure(error, input)` callbacks are also available for application completion work. They run inside each invocation using the latest committed callbacks and that invocation's input and outcome. Rerendering a result does not replay completion, and an abandoned render cannot replace the committed callback. A callback that throws becomes a native defect; it does not call `onFailure` as a domain rejection.
359
+ The form holds strings for both inputs. Its schema decodes `quantity` to a number before `save` or `create` receives it. `values(row)` converts server rows back to input values. The runtime supplies the services needed by schema decoding and the command Effect.
368
360
 
369
- The hook calls the supplied Effect once. A bound contract command still owns invalidation, and completion does not wait for invalidated queries to refetch. Undo payloads, expected versions and presentation text stay in the application.
361
+ The editor's form is absent until its first row arrives. A different query atom creates a new form, so another record starts without the previous draft. Refresh results adopt untouched fields and leave edited fields alone. Saved rows supply server normalization while edits made during the save remain dirty. A refresh failure keeps the draft, and `query.refresh()` retries.
370
362
 
371
- A newer dispatch interrupts the earlier command by default. Interrupted work produces no completion feedback. Pass `{ concurrent: true, onSuccess, onFailure }` to let invocations overlap and report their own outcomes. `result`, `data`, `cause` and `pending` remain the native action atom's aggregate state, including its native result selection during concurrent execution; they do not identify an invocation. Use notice descriptors for per-invocation messages and completion callbacks for application work. Keep the runtime and concurrency policy stable for the same owner.
363
+ `save()` ignores a second call while saving. Submitting through form's own `useSubmit(form).run()` or setting `form.submit` also settles the editor or create hook: an editor receives the saved row, and a create records its result and resets.
372
364
 
373
- A typed `Unauthorized` failure asks the session owner to recheck once per failed invocation, even with concurrent commands. The hook does not sign out. Session replacement discards outgoing callbacks and rechecks, including when outgoing work cannot be interrupted.
365
+ A successful create replaces its form with one at `initialValues`, keeping fields edited after submission began, including while an async schema decoded it. The next entry starts without the previous entry's displayed field errors. `created` holds the successful result as an `Option`.
374
366
 
375
- ### Better Auth actions
367
+ #### One-shot create and edit
376
368
 
377
- `authAction` from `@shivaedev/react/client/authAction.ts` turns a Better Auth client call into a native action atom. Create it with the client, then pass it to `useAction` for the same `pending`, `data`, `cause`, `result` and `dispatch` shape as commands:
369
+ Use `useCreate` when the app already has the opening values and needs one submission. Its `create` callback accepts any submission Effect, including an update command; `created` holds that Effect's successful result. Creation above and the query-free edit below use the same form lifecycle. No upsert or new form hook is needed: creation and update remain separate domain commands with explicit payloads.
370
+
371
+ For this versioned example, the app's `save` command requires `{ id, expectedVersion, name, quantity }` and returns the row with its new `version`. It rejects a stale version with a tagged `Conflict` error. Reuse `InvoiceLineFields` and `Inputs` from above:
378
372
 
379
373
  ```tsx
380
- const signIn = authAction((credentials: { email: string; password: string }, signal) =>
381
- authClient.signIn.email(credentials, { signal }),
382
- );
374
+ import { Option } from "effect";
375
+ import { useEffect, useState } from "react";
376
+ import { useCreate } from "@shivaedev/react/client/create.ts";
383
377
 
384
- function SignInButton({ email, password }: { email: string; password: string }) {
385
- const action = useAction(signIn);
386
- const failure = AsyncResult.error(action.result);
387
- return (
388
- <>
389
- <button disabled={action.pending} onClick={() => action.dispatch({ email, password })}>
390
- {action.pending ? "Signing in…" : "Sign in"}
391
- </button>
392
- {Option.isSome(failure) && <p role="alert">Sign-in failed</p>}
393
- </>
394
- );
378
+ type VersionedLine = {
379
+ readonly id: number;
380
+ readonly version: number;
381
+ readonly name: string;
382
+ readonly quantity: number;
383
+ };
384
+
385
+ export function OneShotEdit({ opening, onSaved }: { readonly opening: VersionedLine; readonly onSaved: (line: VersionedLine) => void }) {
386
+ const [original] = useState(opening);
387
+ const edit = useCreate({
388
+ fields: InvoiceLineFields,
389
+ initialValues: { name: original.name, quantity: String(original.quantity) },
390
+ create: (values) =>
391
+ api.save.run({
392
+ id: original.id,
393
+ expectedVersion: original.version,
394
+ ...values,
395
+ }),
396
+ runtime: InvoiceLinesClient.runtime,
397
+ });
398
+ useEffect(() => {
399
+ if (Option.isSome(edit.created)) onSaved(edit.created.value);
400
+ }, [edit.created, onSaved]);
401
+ return (
402
+ <form
403
+ onSubmit={(event) => {
404
+ event.preventDefault();
405
+ edit.save();
406
+ }}
407
+ >
408
+ <Inputs form={edit.form} />
409
+ {Option.isSome(edit.failure) && (
410
+ <p role="alert">{edit.failure.value._tag === "Conflict" ? "This line changed. Reopen it to use the latest version." : "Could not save"}</p>
411
+ )}
412
+ <button type="submit" disabled={edit.saving}>
413
+ Save
414
+ </button>
415
+ </form>
416
+ );
395
417
  }
396
418
  ```
397
419
 
398
- The example uses `authAction`, `useAction`, `effect/reactivity/AsyncResult` and `effect/Option`, with an application-owned `authClient`. Returned auth errors keep their fields in the typed failure channel. Rejected promises become `AuthActionUnavailable` with the original rejection in `cause`. The application chooses error text and what happens after success.
420
+ The app captures the opening record once. Pass a stable `onSaved` callback to close the dialog, navigate or record a completion message; mount a new form for another opening. Domain data, including expected versions and any Undo payload, stays in the app. A conflict exposes `failure` and keeps the input. Field rejections stay on the submitted form until corrected; stale-rejection matching belongs to form. Success uses `useCreate`'s normal reset to opening values while preserving input changed during submission, so `onSaved` owns what happens next rather than turning this into a continuing editor.
399
421
 
400
- `authQuery` from `@shivaedev/react/client/authQuery.ts` creates an input-keyed query atom family for Better Auth reads. Render it with `useQuery`, just like a contract query atom:
422
+ Use `useEditor` for a continuing editor that subscribes to a record, merges refreshes and receives saved rows as its new baseline. Its `loaded` option supplies the initial row, usually from a route loader; it still requires `query` because the query owns identity, refresh and invalidation. Supplying `loaded` does not make an editor query-free. In either pattern contract owns the command Effect and invalidation, form owns submission and field errors, and react exposes query state where a query exists.
423
+
424
+ #### Field rejections and other failures
425
+
426
+ A tagged error with string `_tag` and `message`, and a `field` naming a form field, becomes that field's message by default. Other save failures appear in `failure`. Use `rejectField` when the server uses a different shape or field name:
427
+
428
+ ```ts
429
+ const TitleFields = Schema.Struct({ title: Schema.String });
430
+
431
+ const editor = useEditor({
432
+ query,
433
+ fields: TitleFields,
434
+ values: (row) => ({ title: row.title }),
435
+ save,
436
+ runtime,
437
+ rejectField: (error) => (error._tag === "SubtitleRejected" ? { field: "title", message: error.message } : undefined),
438
+ });
439
+ ```
440
+
441
+ Here `save` has a tagged `SubtitleRejected` failure referring to the server's `subtitle`, while the form calls the input `title`. If a tagged error's field type includes names outside the form, `rejectField` is required at compile time. This includes a broad `string` field or an optional field whose type is broader than the form's names. The mapper can return `undefined` to leave an error as a non-field failure.
442
+
443
+ #### Query, command and form feedback
444
+
445
+ Use `useQuery` with `QueryView` for reads, the Effect overload of `useAction` for commands, and `useCreate` or `useEditor` for form submissions. Give these owners notice descriptors and mount one `NoticeHost` inside the session boundary. contract still runs commands and invalidates queries; form still owns submission, fields and drafts. The app supplies wording, Undo payloads and expected versions.
446
+
447
+ The Effect overload of `useAction` accepts `notices.onSuccess(value, input)` and `notices.onFailure(error, input)`. Each callback returns a descriptor or `undefined`. Independent concurrent completions publish independently, preserving the invocation's input. `useCreate` and `useEditor` accept the same `notices` option around their existing save Effects. Use the descriptors instead of screen-local notice state, expiry timers or result-watching effects:
448
+
449
+ ```ts
450
+ const remove = useAction(OrdersClient.runtime, api.remove.run, {
451
+ concurrent: true,
452
+ notices: {
453
+ onSuccess: (removed, input) => ({
454
+ message: `Removed ${input.title}`,
455
+ action: {
456
+ label: "Undo",
457
+ run: api.restore.run({
458
+ id: input.id,
459
+ title: input.title,
460
+ expectedVersion: removed.version,
461
+ }),
462
+ },
463
+ }),
464
+ onFailure: () => ({ message: "Could not remove this order" }),
465
+ },
466
+ });
467
+ ```
468
+
469
+ `OrdersClient`, `api`, the restore payload and its expected version belong to the application. The host runs the supplied Effect with the operation's native runtime, disables repeated dispatch while it is pending and exposes the outcome on that action. A failed Undo stays on its original notice; it does not become another operation failure notice. Command execution and invalidation remain with contract, and form submission remains with form.
470
+
471
+ For query failures, pass `useQuery(atom, { notice: ({ cause, refreshing }) => descriptor })`. `useEditor` accepts the same callback as `queryNotice` for its query. `refreshing` distinguishes a failure with retained data from an initial load failure. Each settled failed result publishes once per hook; a rerender does not replay it. Omit the callback or return `undefined` for operations that need no notice.
472
+
473
+ The query and form in the same feature use their own feedback options:
401
474
 
402
475
  ```tsx
403
- const authSession = authQuery((disableCookieCache: boolean, signal) =>
404
- authClient.getSession({ query: { disableCookieCache }, fetchOptions: { signal } }),
405
- );
476
+ const notes = useQuery(api.list.query(), {
477
+ notice: ({ refreshing }) => ({
478
+ message: refreshing ? "Refresh failed" : "Load failed",
479
+ }),
480
+ });
481
+ const save = useCreate({
482
+ fields: TitleFields,
483
+ initialValues: { title: "" },
484
+ create: api.save.run,
485
+ runtime: NotesClient.runtime,
486
+ notices: { onSuccess: (title) => ({ message: `Saved ${title}` }) },
487
+ });
488
+ ```
406
489
 
407
- function Account() {
408
- const query = useQuery(authSession(true));
409
- const failure = AsyncResult.error(query.result);
410
- return (
411
- <>
412
- {query.pending && <p>Loading…</p>}
413
- {Option.isSome(query.data) && <p>{query.data.value?.user.name ?? "Signed out"}</p>}
414
- {Option.isSome(failure) && <p role="alert">Could not read the session</p>}
415
- </>
416
- );
417
- }
490
+ Here `api`, `NotesClient` and `TitleFields` belong to the app. Render `notes` through `QueryView`: its loading renderer shows pending work, its failure renderer offers `notes.refresh` as Retry, and its content renderer holds the form and command controls. Bind fields with form's `useField`, submit with `save.save()` and disable Save with `save.saving`. A tagged field rejection stays beside that input. A held save keeps newer edits when it completes and publishes its success notice before the invalidated query finishes. A failed refresh keeps the last content and the draft while publishing the refresh notice.
491
+
492
+ Replacing the session during a command or Undo disposes the outgoing registry: late completion callbacks and outcomes do not publish, and retained notice action and dismiss handlers cannot change the new host.
493
+
494
+ A descriptor has `message`, optional `expiresIn` as a native Duration, and optional `action: { label, run }`. Without an expiry it remains until dismissed or the host ends. Dismissal, expiry, host unmount and session replacement remove entries, clear expiry timers and interrupt pending actions. Native uninterruptible Effects may finish, but their old notice cannot affect the new host. Notices survive screen unmount while their host remains mounted.
495
+
496
+ #### Completion inside each command invocation
497
+
498
+ Pass the native runtime and the original bound command Effect to `useAction` when the screen needs feedback for each invocation:
499
+
500
+ ```tsx
501
+ const rename = useAction(ReportsClient.runtime, api.rename.run, {
502
+ notices: {
503
+ onSuccess: (saved, input) => ({ message: `Renamed ${input.id} to ${saved.title}` }),
504
+ onFailure: (_error, input) => ({ message: `Could not rename ${input.id}` }),
505
+ },
506
+ });
418
507
  ```
419
508
 
420
- The example uses the application's Better Auth client. Reads preserve returned auth errors and use the same `AuthActionUnavailable` failure as actions for rejected promises. Releasing a pending read interrupts its Effect and aborts the supplied signal.
509
+ `ReportsClient` and `api` belong to the application. Input, success, typed failure and runtime requirements are inferred from the command. Use `notices` for the feedback path above. The optional synchronous `onSuccess(value, input)` and `onFailure(error, input)` callbacks are also available for application completion work. They run inside each invocation using the latest committed callbacks and that invocation's input and outcome. Rerendering a result does not replay completion, and an abandoned render cannot replace the committed callback. A callback that throws becomes a native defect; it does not call `onFailure` as a domain rejection.
421
510
 
422
- For an explicit sign-out in a TanStack route, use `useSignOut(signOut, { signIn: "/sign-in" })` from `@shivaedev/react/client/useSignOut.ts` in place of `useAction`. It exposes the same state and a `dispatch()` handler. The hook records sign-out intent before the request and navigates to sign-in after success. `permissionGuard` and `signInSearch(location)` omit `returnTo` for that intent, including when the auth owner's session refresh reruns guards first. Use `signInSearch` for module sign-in links too; ordinary visits retain the full requested location. Applications do not need history markers or Better Auth completion timing hooks.
511
+ The hook calls the supplied Effect once. A bound contract command still owns invalidation, and completion does not wait for invalidated queries to refetch. Undo payloads, expected versions and presentation text stay in the application.
512
+
513
+ A newer dispatch interrupts the earlier command by default. Interrupted work produces no completion feedback. Pass `{ concurrent: true, onSuccess, onFailure }` to let invocations overlap and report their own outcomes. `result`, `data`, `cause` and `pending` remain the native action atom's aggregate state, including its native result selection during concurrent execution; they do not identify an invocation. Use notice descriptors for per-invocation messages and completion callbacks for application work. Keep the runtime and concurrency policy stable for the same owner.
514
+
515
+ A typed `Unauthorized` failure asks the session owner to recheck once per failed invocation, even with concurrent commands. The hook does not sign out. Session replacement discards outgoing callbacks and rechecks, including when outgoing work cannot be interrupted.
423
516
 
424
- ### Commands with a pending display value
517
+ #### Commands with a pending display value
425
518
 
426
519
  Use `usePendingCommand` for a command acting on a query row, such as a list toggle. Give each row a stable action atom with the existing bound command, so the command's declared invalidation keys still drive refetches:
427
520
 
@@ -429,21 +522,20 @@ Use `usePendingCommand` for a command acting on a query row, such as a list togg
429
522
  import * as Atom from "effect/reactivity/Atom";
430
523
  import { usePendingCommand } from "@shivaedev/react/client/pendingCommand.ts";
431
524
 
432
- const toggle = Atom.family((id: number) =>
433
- ItemsClient.runtime.fn((input: { readonly enabled: boolean }) => api.toggle.run({ id, ...input })),
434
- );
525
+ const toggle = Atom.family((id: number) => ItemsClient.runtime.fn((input: { readonly enabled: boolean }) => api.toggle.run({ id, ...input })));
435
526
  const list = api.list.query();
436
527
 
437
528
  function EnabledToggle({ row }: { readonly row: { readonly id: number; readonly enabled: boolean } }) {
438
- const command = usePendingCommand(toggle(row.id), {
439
- queries: [list],
440
- value: row.enabled,
441
- wanted: (input) => input.enabled,
442
- });
443
- return <button aria-pressed={command.value} disabled={command.pending}
444
- onClick={() => command.dispatch({ enabled: !command.value })} type="button">
445
- Enabled
446
- </button>;
529
+ const command = usePendingCommand(toggle(row.id), {
530
+ queries: [list],
531
+ value: row.enabled,
532
+ wanted: (input) => input.enabled,
533
+ });
534
+ return (
535
+ <button aria-pressed={command.value} disabled={command.pending} onClick={() => command.dispatch({ enabled: !command.value })} type="button">
536
+ Enabled
537
+ </button>
538
+ );
447
539
  }
448
540
  ```
449
541
 
@@ -453,7 +545,30 @@ The example assumes a bound `api.toggle.run` accepting `{ id, enabled }`, a boun
453
545
 
454
546
  `command.value` changes on dispatch and stays at the requested value while the command or any watched query is waiting. `pending` covers that whole interval. After the queries settle, `value` follows the server again, even if it differs from the requested value. A command failure immediately returns to the server value and remains typed in `result` and `cause`, just as with `useAction`. The other action fields describe the native command result, so `result` can already be successful while `pending` still waits for refetches. This binding holds a display value; it does not write to the query cache.
455
547
 
456
- ### Route loaders with TanStack Router
548
+ #### Trace an action across RPCs
549
+
550
+ Editor saves and create submissions automatically run their save Effect in a fresh `ui.submit` trace. For custom actions, wrap the Effect that performs the action with `actionTrace` before giving it to a native atom:
551
+
552
+ ```ts
553
+ import { Effect } from "effect";
554
+ import { actionTrace } from "@shivaedev/react/actionTrace.ts";
555
+
556
+ const save = OrdersClient.runtime.fn((input: OrderInput) =>
557
+ actionTrace(
558
+ Effect.gen(function* () {
559
+ yield* api.validate.run(input);
560
+ return yield* api.save.run(input);
561
+ }),
562
+ "ui.saveOrder",
563
+ ),
564
+ );
565
+ ```
566
+
567
+ `OrdersClient`, `OrderInput` and `api` belong to the application. Dispatch `save` with `useAction(save)`. `actionTrace(effect, name?)` preserves the Effect's result, typed error and service requirements; its default span name is `ui.action`. Each execution starts a fresh trace, and RPCs run inside that Effect share it, including across asynchronous boundaries. Native atoms continue to own execution and cancellation. `useAction` also accepts ordinary native action atoms; wrap composite action Effects to group their RPCs.
568
+
569
+ With native `RpcClient.layerProtocolHttp` and `FetchHttpClient.layer` as the browser RPC protocol, Effect sends the trace in the W3C `traceparent` HTTP header on each request. On the `appHost` of `@shivaedev/server`, each RPC's server span joins that trace with a `request.id` attribute, and the SQL its handler runs joins it too. Wrap a handler's enqueue in an application-named producer span, such as `Effect.withSpan("Receipt.enqueue", { kind: "producer" })`, to show it in the action's trace. Each worker attempt runs in its own trace, linked back to that span; [jobs' tracing](https://github.com/ShivaeDev/platform/blob/main/packages/jobs/README.md#trace-jobs) describes the job span.
570
+
571
+ #### Route loaders with TanStack Router
457
572
 
458
573
  TanStack Router owns routes, navigation, search validation, loader data and screen components. Spread `queryRoute(buildQuery)` from `@shivaedev/react/queryRoute.ts` into `createRoute` to derive loader dependencies from validated search and load a contract query atom in the router context’s registry. A rejection tagged `NotFound` renders the route’s `notFoundComponent`; other typed failures reach `errorComponent` unchanged. The atom runs through its native RPC runtime and shares its execution with React.
459
574
 
@@ -473,48 +588,55 @@ import { api } from "./notes-client.ts";
473
588
  const registry = AtomRegistry.make();
474
589
  const root = createRootRouteWithContext<{ registry: AtomRegistry.AtomRegistry }>()();
475
590
  const Search = Schema.Struct({
476
- page: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)).pipe(
477
- Schema.catchDecoding(() => Effect.succeed(Option.some(1))),
478
- Schema.withDecodingDefaultKey(Effect.succeed(1)),
479
- ),
591
+ page: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)).pipe(
592
+ Schema.catchDecoding(() => Effect.succeed(Option.some(1))),
593
+ Schema.withDecodingDefaultKey(Effect.succeed(1)),
594
+ ),
480
595
  });
481
596
  const note = createRoute({
482
- getParentRoute: () => root,
483
- path: "/notes/$id",
484
- validateSearch: Schema.toStandardSchemaV1(Search),
485
- ...queryRoute(({ params, search }: { params: { id: string }; search: typeof Search.Type }) =>
486
- api.get.query({ id: params.id, page: search.page }),
487
- ),
488
- notFoundComponent: () => <h1>Note not found</h1>,
489
- component: Note,
490
- errorComponent: Failure,
597
+ getParentRoute: () => root,
598
+ path: "/notes/$id",
599
+ validateSearch: Schema.toStandardSchemaV1(Search),
600
+ ...queryRoute(({ params, search }: { params: { id: string }; search: typeof Search.Type }) => api.get.query({ id: params.id, page: search.page })),
601
+ notFoundComponent: () => <h1>Note not found</h1>,
602
+ component: Note,
603
+ errorComponent: Failure,
491
604
  });
492
605
  const router = createRouter({
493
- context: { registry },
494
- routeTree: root.addChildren([note]),
495
- defaultPendingMs: Infinity,
496
- InnerWrap: Shell,
606
+ context: { registry },
607
+ routeTree: root.addChildren([note]),
608
+ defaultPendingMs: Infinity,
609
+ InnerWrap: Shell,
497
610
  });
498
611
 
499
612
  declare module "@tanstack/react-router" {
500
- interface Register {
501
- router: typeof router;
502
- }
613
+ interface Register {
614
+ router: typeof router;
615
+ }
503
616
  }
504
617
 
505
618
  function Shell({ children }: { readonly children: ReactNode }) {
506
- const pending = useRouterState({ select: (state) => state.isLoading });
507
- return <main>{pending && <p>Loading…</p>}{children}</main>;
619
+ const pending = useRouterState({ select: (state) => state.isLoading });
620
+ return (
621
+ <main>
622
+ {pending && <p>Loading…</p>}
623
+ {children}
624
+ </main>
625
+ );
508
626
  }
509
627
  function Note() {
510
- const query = useLoaderQuery(api.get.query({ ...note.useParams(), ...note.useSearch() }), note.useLoaderData());
511
- return <h1>{query.data.title}</h1>;
628
+ const query = useLoaderQuery(api.get.query({ ...note.useParams(), ...note.useSearch() }), note.useLoaderData());
629
+ return <h1>{query.data.title}</h1>;
512
630
  }
513
631
  function Failure({ error }: ErrorComponentProps) {
514
- return <p role="alert">{Predicate.isTagged(error, "NoteMissing") ? "Note missing" : "Could not load"}</p>;
632
+ return <p role="alert">{Predicate.isTagged(error, "NoteMissing") ? "Note missing" : "Could not load"}</p>;
515
633
  }
516
634
  export function App() {
517
- return <RegistryContext.Provider value={registry}><RouterProvider router={router} /></RegistryContext.Provider>;
635
+ return (
636
+ <RegistryContext.Provider value={registry}>
637
+ <RouterProvider router={router} />
638
+ </RegistryContext.Provider>
639
+ );
518
640
  }
519
641
  ```
520
642
 
@@ -522,170 +644,139 @@ export function App() {
522
644
 
523
645
  For a loader that coordinates more work, use `loadQuery(registry, atom, loaderContext)` from `@shivaedev/react/loadQuery.ts` directly. It reads the atom in the supplied registry and throws typed failures unchanged, including `NotFound`; the loader decides how to handle them.
524
646
 
525
- `defaultPendingMs: Infinity` keeps the current route's data visible during navigation; the shell reads TanStack's `isLoading` to display pending state. The new route commits when its loader settles. This is TanStack's pending policy, separate from `useQuery`'s retained display. The loader and the component share one query execution even when the route delays mounting for 650 ms, beyond the registry’s idle window. A DOM screen story proves the first render has the loaded value and the loopback RPC handler runs once.
647
+ `defaultPendingMs: Infinity` keeps the current route's data visible during navigation; the shell reads TanStack's `isLoading` to display pending state. The new route commits when its loader settles. This is TanStack's pending policy, separate from `useQuery`'s retained display. The loader and the component share one query execution even when the route delays mounting for 650 ms, beyond the registry’s idle window: the first render has the loaded value and the RPC handler runs once.
526
648
 
527
649
  `useLoaderQuery(atom, route.useLoaderData())` subscribes to the same atom and exposes `data` as its success type, with no `Option` or Initial result. Refetches keep the last value visible. For a loader-backed editor, pass the loader value as `useEditor({ ...config, loaded: route.useLoaderData() })`; its form is defined on the first render and `editor.query.data` is the row directly.
528
650
 
529
- `SessionBoundary` gives idle atoms the default 400 ms window used by atom-react’s default registry. `loadQuery` also mounts its atom until a component commits its `useQuery` or `useLoaderQuery` subscription, or the loader’s abort signal ends its route ownership. No per-query `timeToLive` is needed. Leaving an unread route releases that mount; a DOM story verifies the atom is gone after the idle window. A new `useLoaderQuery` reader suspends until its active `loadQuery` completes, so revisiting a cached route after invalidation renders the changed data on its first frame. Already mounted readers keep their data during refreshes.
651
+ `SessionBoundary` gives idle atoms the default 400 ms window used by atom-react’s default registry. `loadQuery` also mounts its atom until a component commits its `useQuery` or `useLoaderQuery` subscription, or the loader’s abort signal ends its route ownership. No per-query `timeToLive` is needed. Leaving an unread route releases that mount, and the atom is gone after the idle window. A new `useLoaderQuery` reader suspends until its active `loadQuery` completes, so revisiting a cached route after invalidation renders the changed data on its first frame. Already mounted readers keep their data during refreshes.
530
652
 
531
653
  `loadQuery` waits through a native query refresh instead of returning its retained success. Typed failures are thrown directly, so the route can inspect `_tag` rather than an Effect fiber wrapper. Defects remain failed Promises. TanStack's loader `abortController` cancels the waiting subscription and rejects with `name: "AbortError"`; other readers of the same query can still complete. The application owns the registry's lifetime and disposes it when its client or session ends. Use the same registry for router context and `RegistryContext.Provider`.
532
654
 
533
- Install the optional router peer when using this entry point:
534
-
535
- ```sh
536
- pnpm add @tanstack/react-router
537
- ```
655
+ #### Session permissions and guarded routes
538
656
 
539
- ### 3. Once per feature: an editor and a create form
657
+ Permissions come from the session payload's `user.permissions`, populated by `@shivaedev/access` through the server's role policy. `can(session, permission)` reads exact name and strength grants without making a request. `useCan()` supplies the same check from the nearest `PermissionBoundary` for component controls. A signed-out session has no grants. These checks control visibility; the server still authorizes every request.
540
658
 
541
- The next module assumes `./invoice-lines-client.ts` exports a contract-bound `api` with `get`, `save` and `create`, and its native `InvoiceLinesClient`. `get` returns `{ id, name, quantity }`; both commands return the saved row. The [contract guide](https://github.com/ShivaeDev/platform/blob/main/packages/contract/README.md) describes that binding.
659
+ Declare a route's requirement in TanStack's native `staticData.permission`. Install `permissionGuard` once as the root route's `beforeLoad`; it checks the matched routes before their loaders run. Signed-out visitors go to the app's `signIn` path with the full requested location in the `returnTo` search field. A signed-in session without a required grant throws the shared `Forbidden` failure.
542
660
 
543
661
  ```tsx
544
- import { useCreate } from "@shivaedev/react/client/create.ts";
545
- import { useEditor } from "@shivaedev/react/client/editor.ts";
546
- import { QueryView } from "@shivaedev/react/client/QueryView.ts";
547
- import { useField } from "@shivaedev/form/client/react.ts";
548
- import type { Form } from "@shivaedev/form/shape.ts";
549
- import { Option, Schema } from "effect";
550
- import { api, InvoiceLinesClient } from "./invoice-lines-client.ts";
662
+ import { RegistryContext } from "@effect/atom-react";
663
+ import { createRootRouteWithContext, createRoute, createRouter, RouterProvider } from "@tanstack/react-router";
664
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
665
+ import { permission } from "@shivaedev/access/permission.ts";
666
+ import { PermissionBoundary, useCan } from "@shivaedev/react/client/permissionBoundary.ts";
667
+ import { useLoaderQuery } from "@shivaedev/react/client/useLoaderQuery.ts";
668
+ import { permissionGuard } from "@shivaedev/react/permissionGuard.ts";
669
+ import { can, type PermissionSession } from "@shivaedev/react/permissions.ts";
670
+ import { queryRoute } from "@shivaedev/react/queryRoute.ts";
671
+ import { api, registry } from "./notes-client.ts";
672
+ import { auth, SignIn } from "./auth.tsx";
551
673
 
552
- const InvoiceLineFields = Schema.Struct({
553
- name: Schema.String,
554
- quantity: Schema.NumberFromString,
674
+ const ReadNotes = permission("notes.read");
675
+ const root = createRootRouteWithContext<{ registry: AtomRegistry.AtomRegistry }>()({
676
+ beforeLoad: permissionGuard({ session: () => auth.session, signIn: "/login" }),
677
+ });
678
+ const note = createRoute({
679
+ getParentRoute: () => root,
680
+ path: "/notes/$id",
681
+ staticData: { permission: ReadNotes },
682
+ ...queryRoute(({ params }: { params: { id: string }; search: Record<string, never> }) => api.get.query({ id: params.id, page: 1 })),
683
+ component: () => {
684
+ const query = useLoaderQuery(api.get.query({ ...note.useParams(), page: 1 }), note.useLoaderData());
685
+ return <h1>{query.data.title}</h1>;
686
+ },
687
+ });
688
+ const login = createRoute({
689
+ getParentRoute: () => root,
690
+ path: "/login",
691
+ validateSearch: (search: Record<string, unknown>) => ({
692
+ returnTo: typeof search.returnTo === "string" ? search.returnTo : "/",
693
+ }),
694
+ component: () => {
695
+ const { returnTo } = login.useSearch();
696
+ return <SignIn onSignedIn={() => router.navigate({ href: returnTo })} />;
697
+ },
698
+ });
699
+ const router = createRouter({
700
+ context: { registry },
701
+ routeTree: root.addChildren([note, login]),
702
+ disableGlobalCatchBoundary: true,
555
703
  });
556
- type Fields = typeof InvoiceLineFields.fields;
557
704
 
558
- function Inputs<A, E, ER>({ form }: { readonly form: Form<Fields, A, E, ER> }) {
559
- const name = useField(form, "name");
560
- const quantity = useField(form, "quantity");
561
- return (
562
- <>
563
- <label>
564
- Name
565
- <input name="name" value={name.value} onBlur={name.onBlur}
566
- onChange={(event) => name.onChange(event.target.value)} />
567
- </label>
568
- {name.error && <p role="alert">{name.error}</p>}
569
- <label>
570
- Quantity
571
- <input name="quantity" value={quantity.value} onBlur={quantity.onBlur}
572
- onChange={(event) => quantity.onChange(event.target.value)} />
573
- </label>
574
- {quantity.error && <p role="alert">{quantity.error}</p>}
575
- </>
576
- );
705
+ function Controls() {
706
+ const can = useCan();
707
+ return can(ReadNotes) ? <button type="button">Read notes</button> : null;
577
708
  }
578
709
 
579
- export function InvoiceLineEditor({ id }: { readonly id: number }) {
580
- const editor = useEditor({
581
- query: api.get.query({ id }),
582
- fields: InvoiceLineFields,
583
- values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
584
- save: (values) => api.save.run({ id, ...values }),
585
- runtime: InvoiceLinesClient.runtime,
586
- });
587
- return (
588
- <QueryView
589
- query={editor.query}
590
- loading={(query) => <p>{query.refreshing ? "Refreshing…" : "Loading…"}</p>}
591
- failure={(_error, query) => (
592
- <p role="alert">Could not load <button disabled={query.pending} onClick={query.refresh}>Retry</button></p>
593
- )}
594
- content={() => editor.form && (
595
- <form onSubmit={(event) => { event.preventDefault(); editor.save(); }}>
596
- <Inputs form={editor.form} />
597
- {Option.isSome(editor.failure) && <p role="alert">Could not save</p>}
598
- <button type="submit" disabled={editor.saving}>Save</button>
599
- <button type="button" onClick={editor.query.refresh}>Refresh</button>
600
- <button type="button" onClick={editor.revert}>Revert</button>
601
- <p>{editor.saving ? "Saving" : editor.dirty ? "Unsaved" : "Saved"}</p>
602
- </form>
603
- )}
604
- />
605
- );
710
+ export function App({ session }: { readonly session: PermissionSession | undefined }) {
711
+ return (
712
+ <PermissionBoundary session={session} fallback={<h1>Permission denied</h1>}>
713
+ <RegistryContext.Provider value={registry}>
714
+ <Controls />
715
+ <RouterProvider router={router} />
716
+ </RegistryContext.Provider>
717
+ </PermissionBoundary>
718
+ );
606
719
  }
607
720
 
608
- export function InvoiceLineCreate() {
609
- const create = useCreate({
610
- fields: InvoiceLineFields,
611
- initialValues: { name: "", quantity: "" },
612
- create: api.create.run,
613
- runtime: InvoiceLinesClient.runtime,
614
- });
615
- return (
616
- <form onSubmit={(event) => { event.preventDefault(); create.save(); }}>
617
- <Inputs form={create.form} />
618
- {Option.isSome(create.failure) && <p role="alert">Could not create</p>}
619
- <button type="submit" disabled={create.saving}>Create</button>
620
- {Option.isSome(create.created) && <output>Created {create.created.value.name}</output>}
621
- </form>
622
- );
623
- }
721
+ const showNotes = can(auth.session, ReadNotes);
624
722
  ```
625
723
 
626
- The form holds strings for both inputs. Its schema decodes `quantity` to a number before `save` or `create` receives it. `values(row)` converts server rows back to input values. The runtime supplies the services needed by schema decoding and the command Effect.
724
+ `auth` owns the current session and passes it to `App`; `SignIn` updates that session before invoking `onSignedIn`. Use the session's registry for router context and React, as with other query routes. Importing `permissionGuard` augments TanStack's `StaticDataRouteOption`; route options and their inference remain native.
627
725
 
628
- The editor's form is absent until its first row arrives. A different query atom creates a new form, so another record starts without the previous draft. Refresh results adopt untouched fields and leave edited fields alone. Saved rows supply server normalization while edits made during the save remain dirty. A refresh failure keeps the draft, and `query.refresh()` retries.
726
+ `PermissionBoundary` renders the app's one fallback at the denied URL for a refused route, a loader's `Forbidden`, or a `useQuery`/`useLoaderQuery` result tagged `Forbidden`. Other errors propagate to an enclosing error boundary. Set TanStack's `disableGlobalCatchBoundary: true` and leave permission failures to this boundary; a route-specific `errorComponent` that consumes an error owns its rendering instead. Without a permission boundary, query failures keep their normal typed state. Like a React error boundary, remount it to clear a caught failure.
629
727
 
630
- `save()` ignores a second call while saving. Submitting through form's own `useSubmit(form).run()` or setting `form.submit` also settles the editor or create hook: an editor receives the saved row, and a create records its result and resets.
728
+ #### Better Auth actions
631
729
 
632
- A successful create replaces its form with one at `initialValues`, keeping fields edited after submission began, including while an async schema decoded it. The next entry starts without the previous entry's displayed field errors. `created` holds the successful result as an `Option`.
730
+ `authAction` from `@shivaedev/react/client/authAction.ts` turns a Better Auth client call into a native action atom. Create it with the client, then pass it to `useAction` for the same `pending`, `data`, `cause`, `result` and `dispatch` shape as commands:
633
731
 
634
- ### One-shot create and edit
732
+ ```tsx
733
+ const signIn = authAction((credentials: { email: string; password: string }, signal) => authClient.signIn.email(credentials, { signal }));
635
734
 
636
- Use `useCreate` when the app already has the opening values and needs one submission. Its `create` callback accepts any submission Effect, including an update command; `created` holds that Effect's successful result. Creation above and the query-free edit below use the same form lifecycle. No upsert or new form hook is needed: creation and update remain separate domain commands with explicit payloads.
735
+ function SignInButton({ email, password }: { email: string; password: string }) {
736
+ const action = useAction(signIn);
737
+ const failure = AsyncResult.error(action.result);
738
+ return (
739
+ <>
740
+ <button disabled={action.pending} onClick={() => action.dispatch({ email, password })}>
741
+ {action.pending ? "Signing in…" : "Sign in"}
742
+ </button>
743
+ {Option.isSome(failure) && <p role="alert">Sign-in failed</p>}
744
+ </>
745
+ );
746
+ }
747
+ ```
637
748
 
638
- For this versioned example, the app's `save` command requires `{ id, expectedVersion, name, quantity }` and returns the row with its new `version`. It rejects a stale version with a tagged `Conflict` error. Reuse `InvoiceLineFields` and `Inputs` from above:
749
+ The example uses `authAction`, `useAction`, `effect/reactivity/AsyncResult` and `effect/Option`, with an application-owned `authClient`. Returned auth errors keep their fields in the typed failure channel. Rejected promises become `AuthActionUnavailable` with the original rejection in `cause`. The application chooses error text and what happens after success.
639
750
 
640
- ```tsx
641
- import { Option } from "effect";
642
- import { useEffect, useState } from "react";
643
- import { useCreate } from "@shivaedev/react/client/create.ts";
751
+ `authQuery` from `@shivaedev/react/client/authQuery.ts` creates an input-keyed query atom family for Better Auth reads. Render it with `useQuery`, just like a contract query atom:
644
752
 
645
- type VersionedLine = {
646
- readonly id: number;
647
- readonly version: number;
648
- readonly name: string;
649
- readonly quantity: number;
650
- };
753
+ ```tsx
754
+ const authSession = authQuery((disableCookieCache: boolean, signal) =>
755
+ authClient.getSession({ query: { disableCookieCache }, fetchOptions: { signal } }),
756
+ );
651
757
 
652
- export function OneShotEdit({ opening, onSaved }: {
653
- readonly opening: VersionedLine;
654
- readonly onSaved: (line: VersionedLine) => void;
655
- }) {
656
- const [original] = useState(opening);
657
- const edit = useCreate({
658
- fields: InvoiceLineFields,
659
- initialValues: { name: original.name, quantity: String(original.quantity) },
660
- create: (values) => api.save.run({
661
- id: original.id,
662
- expectedVersion: original.version,
663
- ...values,
664
- }),
665
- runtime: InvoiceLinesClient.runtime,
666
- });
667
- useEffect(() => {
668
- if (Option.isSome(edit.created)) onSaved(edit.created.value);
669
- }, [edit.created, onSaved]);
670
- return (
671
- <form onSubmit={(event) => { event.preventDefault(); edit.save(); }}>
672
- <Inputs form={edit.form} />
673
- {Option.isSome(edit.failure) && <p role="alert">
674
- {edit.failure.value._tag === "Conflict"
675
- ? "This line changed. Reopen it to use the latest version."
676
- : "Could not save"}
677
- </p>}
678
- <button type="submit" disabled={edit.saving}>Save</button>
679
- </form>
680
- );
758
+ function Account() {
759
+ const query = useQuery(authSession(true));
760
+ const failure = AsyncResult.error(query.result);
761
+ return (
762
+ <>
763
+ {query.pending && <p>Loading…</p>}
764
+ {Option.isSome(query.data) && <p>{query.data.value?.user.name ?? "Signed out"}</p>}
765
+ {Option.isSome(failure) && <p role="alert">Could not read the session</p>}
766
+ </>
767
+ );
681
768
  }
682
769
  ```
683
770
 
684
- The app captures the opening record once. Pass a stable `onSaved` callback to close the dialog, navigate or record a completion message; mount a new form for another opening. Domain data, including expected versions and any Undo payload, stays in the app. A conflict exposes `failure` and keeps the input. Field rejections stay on the submitted form until corrected; stale-rejection matching belongs to form. Success uses `useCreate`'s normal reset to opening values while preserving input changed during submission, so `onSaved` owns what happens next rather than turning this into a continuing editor.
771
+ The example uses the application's Better Auth client. Reads preserve returned auth errors and use the same `AuthActionUnavailable` failure as actions for rejected promises. Releasing a pending read interrupts its Effect and aborts the supplied signal.
685
772
 
686
- Use `useEditor` for a continuing editor that subscribes to a record, merges refreshes and receives saved rows as its new baseline. Its `loaded` option supplies the initial row, usually from a route loader; it still requires `query` because the query owns identity, refresh and invalidation. Supplying `loaded` does not make an editor query-free. In either pattern contract owns the command Effect and invalidation, form owns submission and field errors, and react exposes query state where a query exists.
773
+ For an explicit sign-out in a TanStack route, use `useSignOut(signOut, { signIn: "/sign-in" })` from `@shivaedev/react/client/useSignOut.ts` in place of `useAction`. It exposes the same state and a `dispatch()` handler. The hook records sign-out intent before the request and navigates to sign-in after success. `permissionGuard` and `signInSearch(location)` omit `returnTo` for that intent, including when the auth owner's session refresh reruns guards first. Use `signInSearch` for module sign-in links too; ordinary visits retain the full requested location. Applications do not need history markers or Better Auth completion timing hooks.
774
+
775
+ ### 3. Every day
776
+
777
+ These rules apply to each component and test you write.
687
778
 
688
- ### Hook configuration between renders
779
+ #### Hook configuration between renders
689
780
 
690
781
  `useCreate` calls the latest committed render's `create`, and `useEditor` calls its latest `save`. Both hooks use the latest `rejectField` when a save fails, including when the mapper is removed to restore automatic field mapping. Inline callbacks may close over changing props or state without replacing the form or losing edits.
691
782
 
@@ -693,28 +784,7 @@ Changing the `fields` schema or `runtime` by reference replaces either hook's fo
693
784
 
694
785
  `initialValues` (create), `values` (editor) and `persistedDraft` are setup options, captured when the hook first creates a form and again when `fields`, `runtime` or editor `query` changes. Successful creates reuse the captured initial values and persistence options for the next entry. Changing setup options alone does not replace an existing form; remount the component with a different React `key` to apply them. An editor's `loaded` value remains a fallback for query data, as with `useLoaderQuery`.
695
786
 
696
- ### Field rejections and other failures
697
-
698
- A tagged error with string `_tag` and `message`, and a `field` naming a form field, becomes that field's message by default. Other save failures appear in `failure`. Use `rejectField` when the server uses a different shape or field name:
699
-
700
- ```ts
701
- const TitleFields = Schema.Struct({ title: Schema.String });
702
-
703
- const editor = useEditor({
704
- query,
705
- fields: TitleFields,
706
- values: (row) => ({ title: row.title }),
707
- save,
708
- runtime,
709
- rejectField: (error) => error._tag === "SubtitleRejected"
710
- ? { field: "title", message: error.message }
711
- : undefined,
712
- });
713
- ```
714
-
715
- Here `save` has a tagged `SubtitleRejected` failure referring to the server's `subtitle`, while the form calls the input `title`. If a tagged error's field type includes names outside the form, `rejectField` is required at compile time. This includes a broad `string` field or an optional field whose type is broader than the form's names. The mapper can return `undefined` to leave an error as a non-field failure.
716
-
717
- ### Resume and refresh policy
787
+ #### Resume and refresh policy
718
788
 
719
789
  Keep the signal and query identities stable:
720
790
 
@@ -733,114 +803,34 @@ Use native stale-while-revalidate policy when a resume should refresh only stale
733
803
 
734
804
  ```ts
735
805
  const query = Atom.swr(atom, {
736
- focusSignal: resume,
737
- revalidateOnFocus: true,
738
- staleTime: "1 hour",
806
+ focusSignal: resume,
807
+ revalidateOnFocus: true,
808
+ staleTime: "1 hour",
739
809
  });
740
810
  ```
741
811
 
742
812
  `atom` is the underlying native query. `Atom.swr` owns the staleness decision; the signal only reports that the application resumed.
743
813
 
744
- ### Session permissions and guarded routes
814
+ #### Read the current time
745
815
 
746
- Permissions come from the session payload's `user.permissions`, populated by `@shivaedev/access` through the server's role policy. `can(session, permission)` reads exact name and strength grants without making a request. `useCan()` supplies the same check from the nearest `PermissionBoundary` for component controls. A signed-out session has no grants. These checks control visibility; the server still authorizes every request.
747
-
748
- Declare a route's requirement in TanStack's native `staticData.permission`. Install `permissionGuard` once as the root route's `beforeLoad`; it checks the matched routes before their loaders run. Signed-out visitors go to the app's `signIn` path with the full requested location in the `returnTo` search field. A signed-in session without a required grant throws the shared `Forbidden` failure.
749
-
750
- ```tsx
751
- import { RegistryContext } from "@effect/atom-react";
752
- import { createRootRouteWithContext, createRoute, createRouter, RouterProvider } from "@tanstack/react-router";
753
- import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
754
- import { permission } from "@shivaedev/access/permission.ts";
755
- import { PermissionBoundary, useCan } from "@shivaedev/react/client/permissionBoundary.ts";
756
- import { useLoaderQuery } from "@shivaedev/react/client/useLoaderQuery.ts";
757
- import { permissionGuard } from "@shivaedev/react/permissionGuard.ts";
758
- import { can, type PermissionSession } from "@shivaedev/react/permissions.ts";
759
- import { queryRoute } from "@shivaedev/react/queryRoute.ts";
760
- import { api, registry } from "./notes-client.ts";
761
- import { auth, SignIn } from "./auth.tsx";
762
-
763
- const ReadNotes = permission("notes.read");
764
- const root = createRootRouteWithContext<{ registry: AtomRegistry.AtomRegistry }>()({
765
- beforeLoad: permissionGuard({ session: () => auth.session, signIn: "/login" }),
766
- });
767
- const note = createRoute({
768
- getParentRoute: () => root,
769
- path: "/notes/$id",
770
- staticData: { permission: ReadNotes },
771
- ...queryRoute(({ params }: { params: { id: string }; search: Record<string, never> }) =>
772
- api.get.query({ id: params.id, page: 1 }),
773
- ),
774
- component: () => {
775
- const query = useLoaderQuery(api.get.query({ ...note.useParams(), page: 1 }), note.useLoaderData());
776
- return <h1>{query.data.title}</h1>;
777
- },
778
- });
779
- const login = createRoute({
780
- getParentRoute: () => root,
781
- path: "/login",
782
- validateSearch: (search: Record<string, unknown>) => ({
783
- returnTo: typeof search.returnTo === "string" ? search.returnTo : "/",
784
- }),
785
- component: () => {
786
- const { returnTo } = login.useSearch();
787
- return <SignIn onSignedIn={() => router.navigate({ href: returnTo })} />;
788
- },
789
- });
790
- const router = createRouter({
791
- context: { registry },
792
- routeTree: root.addChildren([note, login]),
793
- disableGlobalCatchBoundary: true,
794
- });
795
-
796
- function Controls() {
797
- const can = useCan();
798
- return can(ReadNotes) ? <button type="button">Read notes</button> : null;
799
- }
816
+ `useNow(interval, runtime?)` from `@shivaedev/react/client/useNow.ts` returns epoch milliseconds from Effect's `Clock` and re-renders at the given interval in milliseconds. Pass the component's atom runtime to use its clock:
800
817
 
801
- export function App({ session }: { readonly session: PermissionSession | undefined }) {
802
- return (
803
- <PermissionBoundary session={session} fallback={<h1>Permission denied</h1>}>
804
- <RegistryContext.Provider value={registry}>
805
- <Controls />
806
- <RouterProvider router={router} />
807
- </RegistryContext.Provider>
808
- </PermissionBoundary>
809
- );
810
- }
818
+ ```ts
819
+ import { useNow } from "@shivaedev/react/client/useNow.ts";
811
820
 
812
- const showNotes = can(auth.session, ReadNotes);
821
+ const now = useNow(60_000, client.runtime);
822
+ const minutesAgo = Math.floor((now - savedAt) / 60_000);
813
823
  ```
814
824
 
815
- `auth` owns the current session and passes it to `App`; `SignIn` updates that session before invoking `onSignedIn`. Use the session's registry for router context and React, as with other query routes. Importing `permissionGuard` augments TanStack's `StaticDataRouteOption`; route options and their inference remain native.
816
-
817
- `PermissionBoundary` renders the app's one fallback at the denied URL for a refused route, a loader's `Forbidden`, or a `useQuery`/`useLoaderQuery` result tagged `Forbidden`. Other errors propagate to an enclosing error boundary. Set TanStack's `disableGlobalCatchBoundary: true` and leave permission failures to this boundary; a route-specific `errorComponent` that consumes an error owns its rendering instead. Without a permission boundary, query failures keep their normal typed state. Like a React error boundary, remount it to clear a caught failure.
818
-
819
- ### API
820
-
821
- `useQuery(atom)` and `useAction(atom)` return these shared fields:
822
-
823
- | Field | Meaning |
824
- | --- | --- |
825
- | `result` | Native `AsyncResult<A, E>`. |
826
- | `data` | `Option<A>` from the current or retained success. Queries can retain displayed data even when the native `result` has no value. |
827
- | `cause` | `Option<Cause<E>>` from a failed result. |
828
- | `pending` | The native result is waiting. |
829
- | `refreshing` | The result is waiting and successful data is available. |
830
-
831
- `useQuery(atom, options?)` adds `refresh(): void`; `useAction` adds `dispatch(input): void`. `QueryOptions.coalesceWindow` accepts a native Duration for live changed hints on contract-bound queries; it belongs to the query atom in its registry, so readers sharing that atom share the window. Omitting it uses the session default. `QueryOptions.keepPreviousData` defaults to `false` and controls retention across query atom changes. Same-identity refresh retention is always enabled. `result` and `cause` describe the current native query even when `data` displays a previous atom's success. Success, input and typed failure types are inferred from the supplied atom. The Effect overload is `useAction(runtime, run, options?)`, where `ActionOptions` supplies `concurrent`, `onSuccess(value, input)` and `onFailure(error, input)`; its shared state also includes the native runtime's typed failure.
832
-
833
- Import the defining `.ts` module, such as `@shivaedev/react/client/editor.ts`. There is no root import. `useSessionRecheck()` from `client/sessionBoundary.ts` reads the boundary's `recheck` callback.
834
-
835
- Prefer `useEditor` and `useCreate` when binding a form to a command. The lower-level modules expose the same composition pieces: `submission` from `submission.ts` makes a form and its submission snapshot reader, `rejecting` from `fieldRejection.ts` maps save failures through a submitter, and `useSaveState` from `client/saveState.ts` observes submission and invokes a success callback.
825
+ Without a runtime it uses a native empty atom runtime. Stories supply a runtime with their TestClock service so advancing that clock updates the rendered time.
836
826
 
837
- ### Render tests in happy-dom
827
+ #### Render tests in happy-dom
838
828
 
839
- Use `@shivaedev/react/client/testing.ts` with Vitest's `happy-dom` environment. Install contract for the example's query and command declarations, and the testing peers and environment in your app's dev dependencies:
829
+ Use `@shivaedev/react/client/testing.ts` with Vitest's `happy-dom` environment. Install contract and changes for the example's query, command and key declarations, and the testing peers and environment in your app's dev dependencies:
840
830
 
841
831
  ```sh
842
- pnpm add @shivaedev/contract
843
- pnpm add -D @shivaedev/test @effect/vitest@4.0.5 vitest happy-dom react-dom@19.3.0 @types/react-dom
832
+ pnpm add @shivaedev/changes @shivaedev/contract
833
+ pnpm add -D @shivaedev/test @effect/vitest vitest happy-dom react-dom @types/react-dom
844
834
  ```
845
835
 
846
836
  Configure Vitest with `test: { environment: "happy-dom" }`, or select that environment for your DOM test project. This complete `notes.dom.test.tsx` renders a component that runs a declared query and command against native RPC test handlers:
@@ -920,39 +910,62 @@ it.live("renders a query and refetches it after a command through a test RPC lay
920
910
 
921
911
  `render(node, options?)` returns the attached `container`, its fresh native `registry`, `render(nextNode)` to update the same root and registry, and `unmount()` to release the root, registry and container. The options are React's own `RootOptions`, passed to `createRoot`, including `onCaughtError`, `onUncaughtError` and `onRecoverableError`. Always unmount in `finally`. Keep component interactions inside React's `act`. `eventually(assert)` returns an Effect: yield it directly in a story or Effect test, keeping the expectation in the spec. It retries for up to three seconds in live time, allowing pending responses to settle inside `act` between attempts without advancing the story’s TestClock. A timeout fails with an Error whose message includes the elapsed milliseconds and last assertion message, and whose `cause` is that assertion error. In an async test, use `await Effect.runPromise(eventually(assert))`. `expectNoReactWarnings()` registers a Vitest hook that fails a test if it logged to `console.error`.
922
912
 
913
+ The RPC layer stays application-owned: replace these in-memory handlers with the service Layer your component needs.
914
+
923
915
  For a boundary fallback test, keep the warnings guard and declare the caught error inside the test. `expectCaughtError(predicate)` returns an `onCaughtError` handler that accepts matching errors without logging them. The guard fails if the handler receives any other error or the expected error never arrives. Other console errors and React warnings still fail the guard.
924
916
 
925
917
  ```tsx
926
918
  const warnings = expectNoReactWarnings();
927
919
 
928
920
  screenStories.it("shows the route failure", [], async () => {
929
- const onCaughtError = warnings.expectCaughtError((error) => error instanceof RouteFailure);
930
- const view = await render(<RouteFailureScreen />, { onCaughtError });
931
- try {
932
- expect(view.container.querySelector('[role="alert"]')?.textContent).toBe("Route unavailable");
933
- } finally {
934
- await view.unmount();
935
- }
921
+ const onCaughtError = warnings.expectCaughtError((error) => error instanceof RouteFailure);
922
+ const view = await render(<RouteFailureScreen />, { onCaughtError });
923
+ try {
924
+ expect(view.container.querySelector('[role="alert"]')?.textContent).toBe("Route unavailable");
925
+ } finally {
926
+ await view.unmount();
927
+ }
936
928
  });
937
929
  ```
938
930
 
939
- The RPC layer stays application-owned: replace these in-memory handlers with the service Layer your component needs.
940
-
941
931
  Story kits can acquire `captureReactWarnings` from `client/testing.ts` in their `create` effect with `const warnings = yield* captureReactWarnings`. The scope owns the capture and restores `console.error` when it closes, including after a failed story. Pass `warnings.onCaughtError` to `render` to capture boundary errors, or `warnings.expectCaughtError(predicate)` to exclude a declared error. Both handlers silence caught errors; the capture also silences and records `console.error` calls. `warnings.read()` returns `{ consoleErrors, caughtErrors }`, with each console call's arguments and the unexpected caught errors. It asserts nothing, leaving the story spec to state its expectation. The Vitest guard uses the same capture mechanism and additionally checks that every declared error occurred.
942
932
 
933
+ ### API
934
+
935
+ `useQuery(atom)` and `useAction(atom)` return these shared fields:
936
+
937
+ | Field | Meaning |
938
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------- |
939
+ | `result` | Native `AsyncResult<A, E>`. |
940
+ | `data` | `Option<A>` from the current or retained success. Queries can retain displayed data even when the native `result` has no value. |
941
+ | `cause` | `Option<Cause<E>>` from a failed result. |
942
+ | `pending` | The native result is waiting. |
943
+ | `refreshing` | The result is waiting and successful data is available. |
944
+
945
+ `useQuery(atom, options?)` adds `refresh(): void`; `useAction` adds `dispatch(input): void`. `QueryOptions.coalesceWindow` accepts a native Duration for live changed hints on contract-bound queries; it belongs to the query atom in its registry, so readers sharing that atom share the window. Omitting it uses the session default. `QueryOptions.notice` publishes a notice descriptor for a failed result. `QueryOptions.keepPreviousData` defaults to `false` and controls retention across query atom changes. Same-identity refresh retention is always enabled. `result` and `cause` describe the current native query even when `data` displays a previous atom's success. Success, input and typed failure types are inferred from the supplied atom. The Effect overload is `useAction(runtime, run, options?)`, where `ActionOptions` supplies `concurrent`, `notices`, `onSuccess(value, input)` and `onFailure(error, input)`; its shared state also includes the native runtime's typed failure.
946
+
947
+ Import the defining `.ts` module, such as `@shivaedev/react/client/editor.ts`. There is no root import. `useSessionRecheck()` from `client/sessionBoundary.ts` reads the boundary's `recheck` callback.
948
+
949
+ Prefer `useEditor` and `useCreate` when binding a form to a command. The lower-level modules expose the same composition pieces: `submission` from `submission.ts` makes a form and its submission snapshot reader, `rejecting` from `fieldRejection.ts` maps save failures through a submitter, and `useSaveState` from `client/saveState.ts` observes submission and invokes a success callback.
950
+
943
951
  ### Install, setup and limits
944
952
 
945
953
  ```sh
946
- pnpm add @shivaedev/react @effect/atom-react@4.0.5 effect@4.0.5 react@19.3.0
954
+ pnpm add @shivaedev/react @effect/atom-react effect react
947
955
  ```
948
956
 
949
- For editor, create or lower-level form modules, also install the optional form peer:
957
+ `@shivaedev/form` and `@shivaedev/contract` install with the package as dependencies. Add form directly when the application imports its modules, such as `useField` for editor inputs:
950
958
 
951
959
  ```sh
952
960
  pnpm add @shivaedev/form
953
961
  ```
954
962
 
955
- Query, action, session and resume modules do not require form. The package needs Node 24 or newer.
963
+ Add the optional router peer when using `queryRoute`, `loadQuery`, `permissionGuard`, `PermissionBoundary` or `useSignOut`:
964
+
965
+ ```sh
966
+ pnpm add @tanstack/react-router
967
+ ```
956
968
 
969
+ The package needs Node 24 or newer.
957
970
  - Query identity, TTL, invalidation and action concurrency remain native atom policies. Put the registry at the lifetime boundary those policies need.
958
971
  - The package has no SSR registry/hydration adapter, offline write queue or optimistic reconciliation policy.