@shivaedev/react 0.20.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 (37) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +588 -538
  3. package/dist/client/authQuery.d.ts +5 -0
  4. package/dist/client/authQuery.d.ts.map +1 -0
  5. package/dist/client/authQuery.js +10 -0
  6. package/dist/client/authQuery.js.map +1 -0
  7. package/dist/client/sessionBoundary.d.ts.map +1 -1
  8. package/dist/client/sessionBoundary.js +4 -2
  9. package/dist/client/sessionBoundary.js.map +1 -1
  10. package/dist/client/useNow.d.ts +5 -0
  11. package/dist/client/useNow.d.ts.map +1 -0
  12. package/dist/client/useNow.js +20 -0
  13. package/dist/client/useNow.js.map +1 -0
  14. package/dist/client/useSignOut.d.ts +18 -0
  15. package/dist/client/useSignOut.d.ts.map +1 -0
  16. package/dist/client/useSignOut.js +26 -0
  17. package/dist/client/useSignOut.js.map +1 -0
  18. package/dist/internal/useLatestCallback.d.ts +2 -0
  19. package/dist/internal/useLatestCallback.d.ts.map +1 -0
  20. package/dist/internal/useLatestCallback.js +9 -0
  21. package/dist/internal/useLatestCallback.js.map +1 -0
  22. package/dist/permissionGuard.d.ts +5 -5
  23. package/dist/permissionGuard.d.ts.map +1 -1
  24. package/dist/permissionGuard.js +3 -2
  25. package/dist/permissionGuard.js.map +1 -1
  26. package/dist/signInSearch.d.ts +8 -0
  27. package/dist/signInSearch.d.ts.map +1 -0
  28. package/dist/signInSearch.js +5 -0
  29. package/dist/signInSearch.js.map +1 -0
  30. package/package.json +11 -10
  31. package/src/client/authQuery.ts +17 -0
  32. package/src/client/sessionBoundary.ts +4 -2
  33. package/src/client/useNow.ts +26 -0
  34. package/src/client/useSignOut.ts +33 -0
  35. package/src/internal/useLatestCallback.ts +9 -0
  36. package/src/permissionGuard.ts +9 -9
  37. package/src/signInSearch.ts +9 -0
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,13 +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
- ### 1. Once per application: own the registry
35
+ ### 1. Once per application
36
+
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.
38
+
39
+ #### Own the registry and the session
36
40
 
37
41
  For a screen without a session boundary, wrap it in the native provider:
38
42
 
@@ -40,7 +44,11 @@ For a screen without a session boundary, wrap it in the native provider:
40
44
  import { RegistryProvider } from "@effect/atom-react";
41
45
 
42
46
  export function App() {
43
- return <RegistryProvider><Orders /></RegistryProvider>;
47
+ return (
48
+ <RegistryProvider>
49
+ <Orders />
50
+ </RegistryProvider>
51
+ );
44
52
  }
45
53
  ```
46
54
 
@@ -51,148 +59,74 @@ import { SessionBoundary } from "@shivaedev/react/client/sessionBoundary.ts";
51
59
  import { makeOrderClient, OrderScreens } from "./orders.tsx";
52
60
 
53
61
  interface Session {
54
- readonly id: string;
55
- readonly credentialGeneration: number;
56
- readonly token: string;
62
+ readonly id: string;
63
+ readonly credentialGeneration: number;
64
+ readonly token: string;
57
65
  }
58
66
 
59
- export function App({ session, recheck }: {
60
- readonly session: Session | undefined;
61
- readonly recheck: () => void;
62
- }) {
63
- return (
64
- <SessionBoundary
65
- session={session}
66
- identify={(current) => `${current.id}:${current.credentialGeneration}`}
67
- connect={(current) => makeOrderClient(current.token)}
68
- recheck={recheck}
69
- signedOut={<p>Signed out</p>}
70
- >
71
- {(client) => <OrderScreens client={client} />}
72
- </SessionBoundary>
73
- );
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
+ );
74
79
  }
75
80
  ```
76
81
 
77
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.
78
83
 
79
- For a native shell, pass its resume source when binding the API returned by `connect`:
80
-
81
- ```ts
82
- import { bind } from "@shivaedev/contract/bind.ts";
83
- import type { ResumeSource } from "@shivaedev/react/resumeSignal.ts";
84
- import { onAppResume, onNetworkReturn } from "./native-lifecycle.ts";
85
-
86
- const resume: ResumeSource = (notify) => {
87
- const stopApp = onAppResume(notify);
88
- const stopNetwork = onNetworkReturn(notify);
89
- return () => { stopApp(); stopNetwork(); };
90
- };
91
- const connect = () => bind(Notes, Client, { resume });
92
- ```
93
-
94
- `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.
95
-
96
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.
97
85
 
98
- ### Migrate a self-only feature to session keys
86
+ #### Session-scoped bindings
99
87
 
100
- Before migration, an owner field exists only to give client reads and invalidations a key. The server already chooses the account from `Identity`:
101
-
102
- ```ts
103
- const preferences = collection("preferences", Schema.String);
104
- const Preferences = contract("preferences", {
105
- queries: [query("self", {
106
- payload: { ownerId: Schema.String },
107
- reads: ({ ownerId }) => [preferences.item(ownerId)],
108
- success: Schema.String,
109
- })],
110
- commands: [command("save", {
111
- payload: { ownerId: Schema.String, title: Schema.String },
112
- invalidates: ({ ownerId }) => [preferences.item(ownerId)],
113
- success: Schema.String,
114
- })],
115
- }).middleware(Authenticated);
116
-
117
- const api = bind(Preferences, Client);
118
- const self = api.self.query({ ownerId: session.userId });
119
- const save = api.save.run({ ownerId: session.userId, title: "My preferences" });
120
- ```
121
-
122
- 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:
123
-
124
- ```ts
125
- const preferences = collection("preferences", Schema.String);
126
- const Preferences = contract("preferences", {
127
- keyScope: "session",
128
- queries: [query("self", {
129
- reads: () => [preferences.item("self")],
130
- success: Schema.String,
131
- })],
132
- commands: [command("save", {
133
- payload: { title: Schema.String },
134
- invalidates: () => [preferences.item("self")],
135
- success: Schema.String,
136
- })],
137
- }).middleware(Authenticated);
138
-
139
- const api = bind(Preferences, Client, { registry });
140
- const self = api.self.query(undefined);
141
- const save = api.save.run({ title: "My preferences" });
142
- ```
143
-
144
- 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:
145
89
 
146
90
  ```tsx
91
+ import { bind } from "@shivaedev/contract/bind.ts";
147
92
  import { SessionBoundary } from "@shivaedev/react/client/sessionBoundary.ts";
148
93
 
149
94
  <SessionBoundary
150
- session={session}
151
- identify={(current) => current.generation}
152
- connect={(current, registry) => bind(Preferences, makePreferencesClient(current.token), { registry })}
153
- recheck={recheck}
154
- 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>}
155
100
  >
156
- {(api) => <PreferencesScreen api={api} />}
157
- </SessionBoundary>
101
+ {(api) => <PreferencesScreen api={api} />}
102
+ </SessionBoundary>;
158
103
  ```
159
104
 
160
- `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.
161
106
 
162
- 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
163
108
 
164
- ```ts
165
- Preferences.application.toLayer({
166
- "preferences.self": () => Effect.flatMap(Identity, ({ id }) => store.read(id)),
167
- "preferences.save": ({ title }) => Effect.flatMap(Identity, ({ id }) => store.save(id, title)),
168
- });
169
- ```
170
-
171
- `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.
172
-
173
- ### Trace an action across RPCs
174
-
175
- 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`:
176
110
 
177
111
  ```ts
178
- import { Effect } from "effect";
179
- 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";
180
115
 
181
- const save = OrdersClient.runtime.fn((input: OrderInput) =>
182
- actionTrace(Effect.gen(function* () {
183
- yield* api.validate.run(input);
184
- return yield* api.save.run(input);
185
- }), "ui.saveOrder"),
186
- );
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 });
187
125
  ```
188
126
 
189
- `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.
190
-
191
- 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.
192
-
193
- ### 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.
194
128
 
195
- 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
196
130
 
197
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.
198
132
 
@@ -202,73 +136,38 @@ import * as AsyncResult from "effect/reactivity/AsyncResult";
202
136
  import { NoticeHost, type Notice } from "@shivaedev/react/notices.ts";
203
137
 
204
138
  function renderNotice(notice: Notice) {
205
- const action = notice.action;
206
- return <article role="status">
207
- <p>{notice.message}</p>
208
- {action && <button disabled={action.pending} onClick={action.run}>{action.label}</button>}
209
- {action && AsyncResult.isSuccess(action.result) && <p>Undo completed</p>}
210
- {action && AsyncResult.isFailure(action.result) && <p>{Cause.pretty(action.result.cause)}</p>}
211
- <button onClick={notice.dismiss}>Dismiss</button>
212
- </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
+ );
213
153
  }
214
154
 
215
155
  function SessionScreens({ client }: { readonly client: AppClient }) {
216
- return <NoticeHost render={renderNotice}><OrderScreens client={client} /></NoticeHost>;
156
+ return (
157
+ <NoticeHost render={renderNotice}>
158
+ <OrderScreens client={client} />
159
+ </NoticeHost>
160
+ );
217
161
  }
218
162
  ```
219
163
 
220
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.
221
165
 
222
- 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:
166
+ ### 2. Once per feature
223
167
 
224
- ```ts
225
- const remove = useAction(OrdersClient.runtime, api.remove.run, {
226
- concurrent: true,
227
- notices: {
228
- onSuccess: (removed, input) => ({
229
- message: `Removed ${input.title}`,
230
- action: {
231
- label: "Undo",
232
- run: api.restore.run({
233
- id: input.id,
234
- title: input.title,
235
- expectedVersion: removed.version,
236
- }),
237
- },
238
- }),
239
- onFailure: () => ({ message: "Could not remove this order" }),
240
- },
241
- });
242
- ```
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.
243
169
 
244
- `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.
245
-
246
- 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.
247
-
248
- The query and form in the same feature use their own feedback options:
249
-
250
- ```tsx
251
- const notes = useQuery(api.list.query(), {
252
- notice: ({ refreshing }) => ({
253
- message: refreshing ? "Refresh failed" : "Load failed",
254
- }),
255
- });
256
- const save = useCreate({
257
- fields: TitleFields,
258
- initialValues: { title: "" },
259
- create: api.save.run,
260
- runtime: NotesClient.runtime,
261
- notices: { onSuccess: (title) => ({ message: `Saved ${title}` }) },
262
- });
263
- ```
264
-
265
- 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.
266
-
267
- 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.
268
-
269
- 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.
270
-
271
- ### 2. Once per feature: native queries and actions
170
+ #### Queries and actions
272
171
 
273
172
  The hooks accept native atoms directly. This complete component module shows query and action state without RPC setup:
274
173
 
@@ -281,36 +180,43 @@ import * as Atom from "effect/reactivity/Atom";
281
180
 
282
181
  const report = Atom.make(Effect.succeed({ id: 1, title: "Quarterly report" }));
283
182
  const rename = Atom.fn<string>()((title) =>
284
- title.trim() === ""
285
- ? Effect.fail("Title required" as const)
286
- : Effect.succeed({ id: 1, title: title.trim() }),
183
+ title.trim() === "" ? Effect.fail("Title required" as const) : Effect.succeed({ id: 1, title: title.trim() }),
287
184
  );
288
185
 
289
186
  function Report() {
290
- const query = useQuery(report);
291
- const action = useAction(rename);
292
- return (
293
- <section>
294
- <QueryView
295
- query={query}
296
- content={(row) => <h1>{row.title}</h1>}
297
- loading={(state) => <p>{state.refreshing ? "Refreshing…" : "Loading…"}</p>}
298
- failure={(error, state) => (
299
- <p role="alert">{String(error)} <button disabled={state.pending} onClick={state.refresh}>Retry</button></p>
300
- )}
301
- />
302
- <button onClick={query.refresh}>Refresh</button>
303
- <button disabled={action.pending} onClick={() => action.dispatch("Annual report")}>
304
- Rename
305
- </button>
306
- {Option.isSome(action.data) && <output>{action.data.value.title}</output>}
307
- {Option.isSome(action.cause) && <p role="alert">Could not rename</p>}
308
- </section>
309
- );
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
+ );
310
212
  }
311
213
 
312
214
  export function App() {
313
- return <RegistryProvider><Report /></RegistryProvider>;
215
+ return (
216
+ <RegistryProvider>
217
+ <Report />
218
+ </RegistryProvider>
219
+ );
314
220
  }
315
221
  ```
316
222
 
@@ -323,14 +229,16 @@ For both queries and actions, a successful `undefined` is `Option.some(undefined
323
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:
324
230
 
325
231
  ```ts
326
- const notes = useQuery(NotesClient.query("ListNotes", undefined, {
327
- reactivityKeys: ["notes"],
328
- }));
232
+ const notes = useQuery(
233
+ NotesClient.query("ListNotes", undefined, {
234
+ reactivityKeys: ["notes"],
235
+ }),
236
+ );
329
237
  const create = useAction(NotesClient.mutation("CreateNote"));
330
238
 
331
239
  create.dispatch({
332
- payload: { title: "Morning walk" },
333
- reactivityKeys: ["notes"],
240
+ payload: { title: "Morning walk" },
241
+ reactivityKeys: ["notes"],
334
242
  });
335
243
  ```
336
244
 
@@ -338,53 +246,275 @@ create.dispatch({
338
246
 
339
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.
340
248
 
341
- ### Completion inside each command invocation
249
+ #### An editor and a create form
342
250
 
343
- 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.
344
252
 
345
253
  ```tsx
346
- const rename = useAction(ReportsClient.runtime, api.rename.run, {
347
- notices: {
348
- onSuccess: (saved, input) => ({ message: `Renamed ${input.id} to ${saved.title}` }),
349
- onFailure: (_error, input) => ({ message: `Could not rename ${input.id}` }),
350
- },
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,
351
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
+ }
352
357
  ```
353
358
 
354
- `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.
355
360
 
356
- 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.
357
362
 
358
- 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.
359
364
 
360
- 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`.
361
366
 
362
- ### Better Auth actions
367
+ #### One-shot create and edit
363
368
 
364
- `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:
365
372
 
366
373
  ```tsx
367
- const signIn = authAction((credentials: { email: string; password: string }, signal) =>
368
- authClient.signIn.email(credentials, { signal }),
369
- );
374
+ import { Option } from "effect";
375
+ import { useEffect, useState } from "react";
376
+ import { useCreate } from "@shivaedev/react/client/create.ts";
370
377
 
371
- function SignInButton({ email, password }: { email: string; password: string }) {
372
- const action = useAction(signIn);
373
- const failure = AsyncResult.error(action.result);
374
- return (
375
- <>
376
- <button disabled={action.pending} onClick={() => action.dispatch({ email, password })}>
377
- {action.pending ? "Signing in…" : "Sign in"}
378
- </button>
379
- {Option.isSome(failure) && <p role="alert">Sign-in failed</p>}
380
- </>
381
- );
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
+ );
382
417
  }
383
418
  ```
384
419
 
385
- 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.
386
421
 
387
- ### Commands with a pending display value
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:
474
+
475
+ ```tsx
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
+ ```
489
+
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
+ });
507
+ ```
508
+
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.
510
+
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.
516
+
517
+ #### Commands with a pending display value
388
518
 
389
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:
390
520
 
@@ -392,21 +522,20 @@ Use `usePendingCommand` for a command acting on a query row, such as a list togg
392
522
  import * as Atom from "effect/reactivity/Atom";
393
523
  import { usePendingCommand } from "@shivaedev/react/client/pendingCommand.ts";
394
524
 
395
- const toggle = Atom.family((id: number) =>
396
- ItemsClient.runtime.fn((input: { readonly enabled: boolean }) => api.toggle.run({ id, ...input })),
397
- );
525
+ const toggle = Atom.family((id: number) => ItemsClient.runtime.fn((input: { readonly enabled: boolean }) => api.toggle.run({ id, ...input })));
398
526
  const list = api.list.query();
399
527
 
400
528
  function EnabledToggle({ row }: { readonly row: { readonly id: number; readonly enabled: boolean } }) {
401
- const command = usePendingCommand(toggle(row.id), {
402
- queries: [list],
403
- value: row.enabled,
404
- wanted: (input) => input.enabled,
405
- });
406
- return <button aria-pressed={command.value} disabled={command.pending}
407
- onClick={() => command.dispatch({ enabled: !command.value })} type="button">
408
- Enabled
409
- </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
+ );
410
539
  }
411
540
  ```
412
541
 
@@ -416,7 +545,30 @@ The example assumes a bound `api.toggle.run` accepting `{ id, enabled }`, a boun
416
545
 
417
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.
418
547
 
419
- ### 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
420
572
 
421
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.
422
574
 
@@ -436,48 +588,55 @@ import { api } from "./notes-client.ts";
436
588
  const registry = AtomRegistry.make();
437
589
  const root = createRootRouteWithContext<{ registry: AtomRegistry.AtomRegistry }>()();
438
590
  const Search = Schema.Struct({
439
- page: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)).pipe(
440
- Schema.catchDecoding(() => Effect.succeed(Option.some(1))),
441
- Schema.withDecodingDefaultKey(Effect.succeed(1)),
442
- ),
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
+ ),
443
595
  });
444
596
  const note = createRoute({
445
- getParentRoute: () => root,
446
- path: "/notes/$id",
447
- validateSearch: Schema.toStandardSchemaV1(Search),
448
- ...queryRoute(({ params, search }: { params: { id: string }; search: typeof Search.Type }) =>
449
- api.get.query({ id: params.id, page: search.page }),
450
- ),
451
- notFoundComponent: () => <h1>Note not found</h1>,
452
- component: Note,
453
- 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,
454
604
  });
455
605
  const router = createRouter({
456
- context: { registry },
457
- routeTree: root.addChildren([note]),
458
- defaultPendingMs: Infinity,
459
- InnerWrap: Shell,
606
+ context: { registry },
607
+ routeTree: root.addChildren([note]),
608
+ defaultPendingMs: Infinity,
609
+ InnerWrap: Shell,
460
610
  });
461
611
 
462
612
  declare module "@tanstack/react-router" {
463
- interface Register {
464
- router: typeof router;
465
- }
613
+ interface Register {
614
+ router: typeof router;
615
+ }
466
616
  }
467
617
 
468
618
  function Shell({ children }: { readonly children: ReactNode }) {
469
- const pending = useRouterState({ select: (state) => state.isLoading });
470
- 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
+ );
471
626
  }
472
627
  function Note() {
473
- const query = useLoaderQuery(api.get.query({ ...note.useParams(), ...note.useSearch() }), note.useLoaderData());
474
- 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>;
475
630
  }
476
631
  function Failure({ error }: ErrorComponentProps) {
477
- 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>;
478
633
  }
479
634
  export function App() {
480
- 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
+ );
481
640
  }
482
641
  ```
483
642
 
@@ -485,170 +644,139 @@ export function App() {
485
644
 
486
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.
487
646
 
488
- `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.
489
648
 
490
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.
491
650
 
492
- `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.
493
652
 
494
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`.
495
654
 
496
- Install the optional router peer when using this entry point:
655
+ #### Session permissions and guarded routes
497
656
 
498
- ```sh
499
- pnpm add @tanstack/react-router
500
- ```
501
-
502
- ### 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.
503
658
 
504
- 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.
505
660
 
506
661
  ```tsx
507
- import { useCreate } from "@shivaedev/react/client/create.ts";
508
- import { useEditor } from "@shivaedev/react/client/editor.ts";
509
- import { QueryView } from "@shivaedev/react/client/QueryView.ts";
510
- import { useField } from "@shivaedev/form/client/react.ts";
511
- import type { Form } from "@shivaedev/form/shape.ts";
512
- import { Option, Schema } from "effect";
513
- 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";
514
673
 
515
- const InvoiceLineFields = Schema.Struct({
516
- name: Schema.String,
517
- 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,
518
703
  });
519
- type Fields = typeof InvoiceLineFields.fields;
520
704
 
521
- function Inputs<A, E, ER>({ form }: { readonly form: Form<Fields, A, E, ER> }) {
522
- const name = useField(form, "name");
523
- const quantity = useField(form, "quantity");
524
- return (
525
- <>
526
- <label>
527
- Name
528
- <input name="name" value={name.value} onBlur={name.onBlur}
529
- onChange={(event) => name.onChange(event.target.value)} />
530
- </label>
531
- {name.error && <p role="alert">{name.error}</p>}
532
- <label>
533
- Quantity
534
- <input name="quantity" value={quantity.value} onBlur={quantity.onBlur}
535
- onChange={(event) => quantity.onChange(event.target.value)} />
536
- </label>
537
- {quantity.error && <p role="alert">{quantity.error}</p>}
538
- </>
539
- );
705
+ function Controls() {
706
+ const can = useCan();
707
+ return can(ReadNotes) ? <button type="button">Read notes</button> : null;
540
708
  }
541
709
 
542
- export function InvoiceLineEditor({ id }: { readonly id: number }) {
543
- const editor = useEditor({
544
- query: api.get.query({ id }),
545
- fields: InvoiceLineFields,
546
- values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
547
- save: (values) => api.save.run({ id, ...values }),
548
- runtime: InvoiceLinesClient.runtime,
549
- });
550
- return (
551
- <QueryView
552
- query={editor.query}
553
- loading={(query) => <p>{query.refreshing ? "Refreshing…" : "Loading…"}</p>}
554
- failure={(_error, query) => (
555
- <p role="alert">Could not load <button disabled={query.pending} onClick={query.refresh}>Retry</button></p>
556
- )}
557
- content={() => editor.form && (
558
- <form onSubmit={(event) => { event.preventDefault(); editor.save(); }}>
559
- <Inputs form={editor.form} />
560
- {Option.isSome(editor.failure) && <p role="alert">Could not save</p>}
561
- <button type="submit" disabled={editor.saving}>Save</button>
562
- <button type="button" onClick={editor.query.refresh}>Refresh</button>
563
- <button type="button" onClick={editor.revert}>Revert</button>
564
- <p>{editor.saving ? "Saving" : editor.dirty ? "Unsaved" : "Saved"}</p>
565
- </form>
566
- )}
567
- />
568
- );
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
+ );
569
719
  }
570
720
 
571
- export function InvoiceLineCreate() {
572
- const create = useCreate({
573
- fields: InvoiceLineFields,
574
- initialValues: { name: "", quantity: "" },
575
- create: api.create.run,
576
- runtime: InvoiceLinesClient.runtime,
577
- });
578
- return (
579
- <form onSubmit={(event) => { event.preventDefault(); create.save(); }}>
580
- <Inputs form={create.form} />
581
- {Option.isSome(create.failure) && <p role="alert">Could not create</p>}
582
- <button type="submit" disabled={create.saving}>Create</button>
583
- {Option.isSome(create.created) && <output>Created {create.created.value.name}</output>}
584
- </form>
585
- );
586
- }
721
+ const showNotes = can(auth.session, ReadNotes);
587
722
  ```
588
723
 
589
- 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.
590
725
 
591
- 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.
592
727
 
593
- `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
594
729
 
595
- 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:
596
731
 
597
- ### One-shot create and edit
732
+ ```tsx
733
+ const signIn = authAction((credentials: { email: string; password: string }, signal) => authClient.signIn.email(credentials, { signal }));
598
734
 
599
- 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
+ ```
600
748
 
601
- 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.
602
750
 
603
- ```tsx
604
- import { Option } from "effect";
605
- import { useEffect, useState } from "react";
606
- 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:
607
752
 
608
- type VersionedLine = {
609
- readonly id: number;
610
- readonly version: number;
611
- readonly name: string;
612
- readonly quantity: number;
613
- };
753
+ ```tsx
754
+ const authSession = authQuery((disableCookieCache: boolean, signal) =>
755
+ authClient.getSession({ query: { disableCookieCache }, fetchOptions: { signal } }),
756
+ );
614
757
 
615
- export function OneShotEdit({ opening, onSaved }: {
616
- readonly opening: VersionedLine;
617
- readonly onSaved: (line: VersionedLine) => void;
618
- }) {
619
- const [original] = useState(opening);
620
- const edit = useCreate({
621
- fields: InvoiceLineFields,
622
- initialValues: { name: original.name, quantity: String(original.quantity) },
623
- create: (values) => api.save.run({
624
- id: original.id,
625
- expectedVersion: original.version,
626
- ...values,
627
- }),
628
- runtime: InvoiceLinesClient.runtime,
629
- });
630
- useEffect(() => {
631
- if (Option.isSome(edit.created)) onSaved(edit.created.value);
632
- }, [edit.created, onSaved]);
633
- return (
634
- <form onSubmit={(event) => { event.preventDefault(); edit.save(); }}>
635
- <Inputs form={edit.form} />
636
- {Option.isSome(edit.failure) && <p role="alert">
637
- {edit.failure.value._tag === "Conflict"
638
- ? "This line changed. Reopen it to use the latest version."
639
- : "Could not save"}
640
- </p>}
641
- <button type="submit" disabled={edit.saving}>Save</button>
642
- </form>
643
- );
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
+ );
644
768
  }
645
769
  ```
646
770
 
647
- 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.
648
772
 
649
- 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
650
776
 
651
- ### Hook configuration between renders
777
+ These rules apply to each component and test you write.
778
+
779
+ #### Hook configuration between renders
652
780
 
653
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.
654
782
 
@@ -656,28 +784,7 @@ Changing the `fields` schema or `runtime` by reference replaces either hook's fo
656
784
 
657
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`.
658
786
 
659
- ### Field rejections and other failures
660
-
661
- 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:
662
-
663
- ```ts
664
- const TitleFields = Schema.Struct({ title: Schema.String });
665
-
666
- const editor = useEditor({
667
- query,
668
- fields: TitleFields,
669
- values: (row) => ({ title: row.title }),
670
- save,
671
- runtime,
672
- rejectField: (error) => error._tag === "SubtitleRejected"
673
- ? { field: "title", message: error.message }
674
- : undefined,
675
- });
676
- ```
677
-
678
- 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.
679
-
680
- ### Resume and refresh policy
787
+ #### Resume and refresh policy
681
788
 
682
789
  Keep the signal and query identities stable:
683
790
 
@@ -696,114 +803,34 @@ Use native stale-while-revalidate policy when a resume should refresh only stale
696
803
 
697
804
  ```ts
698
805
  const query = Atom.swr(atom, {
699
- focusSignal: resume,
700
- revalidateOnFocus: true,
701
- staleTime: "1 hour",
806
+ focusSignal: resume,
807
+ revalidateOnFocus: true,
808
+ staleTime: "1 hour",
702
809
  });
703
810
  ```
704
811
 
705
812
  `atom` is the underlying native query. `Atom.swr` owns the staleness decision; the signal only reports that the application resumed.
706
813
 
707
- ### Session permissions and guarded routes
814
+ #### Read the current time
708
815
 
709
- 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.
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:
710
817
 
711
- 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.
712
-
713
- ```tsx
714
- import { RegistryContext } from "@effect/atom-react";
715
- import { createRootRouteWithContext, createRoute, createRouter, RouterProvider } from "@tanstack/react-router";
716
- import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
717
- import { permission } from "@shivaedev/access/permission.ts";
718
- import { PermissionBoundary, useCan } from "@shivaedev/react/client/permissionBoundary.ts";
719
- import { useLoaderQuery } from "@shivaedev/react/client/useLoaderQuery.ts";
720
- import { permissionGuard } from "@shivaedev/react/permissionGuard.ts";
721
- import { can, type PermissionSession } from "@shivaedev/react/permissions.ts";
722
- import { queryRoute } from "@shivaedev/react/queryRoute.ts";
723
- import { api, registry } from "./notes-client.ts";
724
- import { auth, SignIn } from "./auth.tsx";
725
-
726
- const ReadNotes = permission("notes.read");
727
- const root = createRootRouteWithContext<{ registry: AtomRegistry.AtomRegistry }>()({
728
- beforeLoad: permissionGuard({ session: () => auth.session, signIn: "/login" }),
729
- });
730
- const note = createRoute({
731
- getParentRoute: () => root,
732
- path: "/notes/$id",
733
- staticData: { permission: ReadNotes },
734
- ...queryRoute(({ params }: { params: { id: string }; search: Record<string, never> }) =>
735
- api.get.query({ id: params.id, page: 1 }),
736
- ),
737
- component: () => {
738
- const query = useLoaderQuery(api.get.query({ ...note.useParams(), page: 1 }), note.useLoaderData());
739
- return <h1>{query.data.title}</h1>;
740
- },
741
- });
742
- const login = createRoute({
743
- getParentRoute: () => root,
744
- path: "/login",
745
- validateSearch: (search: Record<string, unknown>) => ({
746
- returnTo: typeof search.returnTo === "string" ? search.returnTo : "/",
747
- }),
748
- component: () => {
749
- const { returnTo } = login.useSearch();
750
- return <SignIn onSignedIn={() => router.navigate({ href: returnTo })} />;
751
- },
752
- });
753
- const router = createRouter({
754
- context: { registry },
755
- routeTree: root.addChildren([note, login]),
756
- disableGlobalCatchBoundary: true,
757
- });
758
-
759
- function Controls() {
760
- const can = useCan();
761
- return can(ReadNotes) ? <button type="button">Read notes</button> : null;
762
- }
763
-
764
- export function App({ session }: { readonly session: PermissionSession | undefined }) {
765
- return (
766
- <PermissionBoundary session={session} fallback={<h1>Permission denied</h1>}>
767
- <RegistryContext.Provider value={registry}>
768
- <Controls />
769
- <RouterProvider router={router} />
770
- </RegistryContext.Provider>
771
- </PermissionBoundary>
772
- );
773
- }
818
+ ```ts
819
+ import { useNow } from "@shivaedev/react/client/useNow.ts";
774
820
 
775
- const showNotes = can(auth.session, ReadNotes);
821
+ const now = useNow(60_000, client.runtime);
822
+ const minutesAgo = Math.floor((now - savedAt) / 60_000);
776
823
  ```
777
824
 
778
- `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.
779
-
780
- `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.
781
-
782
- ### API
783
-
784
- `useQuery(atom)` and `useAction(atom)` return these shared fields:
785
-
786
- | Field | Meaning |
787
- | --- | --- |
788
- | `result` | Native `AsyncResult<A, E>`. |
789
- | `data` | `Option<A>` from the current or retained success. Queries can retain displayed data even when the native `result` has no value. |
790
- | `cause` | `Option<Cause<E>>` from a failed result. |
791
- | `pending` | The native result is waiting. |
792
- | `refreshing` | The result is waiting and successful data is available. |
793
-
794
- `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.
795
-
796
- 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.
797
-
798
- 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.
799
826
 
800
- ### Render tests in happy-dom
827
+ #### Render tests in happy-dom
801
828
 
802
- 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:
803
830
 
804
831
  ```sh
805
- pnpm add @shivaedev/contract
806
- 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
807
834
  ```
808
835
 
809
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:
@@ -816,7 +843,7 @@ import { act, createElement } from "react";
816
843
  import { expect } from "vitest";
817
844
  import { bind } from "@shivaedev/contract/bind.ts";
818
845
  import { contract } from "@shivaedev/contract/contract.ts";
819
- import { collection } from "@shivaedev/contract/keys.ts";
846
+ import { collection } from "@shivaedev/changes/keys.ts";
820
847
  import { command, query } from "@shivaedev/contract/operation.ts";
821
848
  import { useAction, useQuery } from "@shivaedev/react/client/resultState.ts";
822
849
  import { eventually, expectNoReactWarnings, render } from "@shivaedev/react/client/testing.ts";
@@ -883,39 +910,62 @@ it.live("renders a query and refetches it after a command through a test RPC lay
883
910
 
884
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`.
885
912
 
913
+ The RPC layer stays application-owned: replace these in-memory handlers with the service Layer your component needs.
914
+
886
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.
887
916
 
888
917
  ```tsx
889
918
  const warnings = expectNoReactWarnings();
890
919
 
891
920
  screenStories.it("shows the route failure", [], async () => {
892
- const onCaughtError = warnings.expectCaughtError((error) => error instanceof RouteFailure);
893
- const view = await render(<RouteFailureScreen />, { onCaughtError });
894
- try {
895
- expect(view.container.querySelector('[role="alert"]')?.textContent).toBe("Route unavailable");
896
- } finally {
897
- await view.unmount();
898
- }
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
+ }
899
928
  });
900
929
  ```
901
930
 
902
- The RPC layer stays application-owned: replace these in-memory handlers with the service Layer your component needs.
903
-
904
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.
905
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
+
906
951
  ### Install, setup and limits
907
952
 
908
953
  ```sh
909
- 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
910
955
  ```
911
956
 
912
- 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:
913
958
 
914
959
  ```sh
915
960
  pnpm add @shivaedev/form
916
961
  ```
917
962
 
918
- 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
+ ```
919
968
 
969
+ The package needs Node 24 or newer.
920
970
  - Query identity, TTL, invalidation and action concurrency remain native atom policies. Put the registry at the lifetime boundary those policies need.
921
971
  - The package has no SSR registry/hydration adapter, offline write queue or optimistic reconciliation policy.