@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.
- package/CHANGELOG.md +19 -0
- package/README.md +588 -538
- package/dist/client/authQuery.d.ts +5 -0
- package/dist/client/authQuery.d.ts.map +1 -0
- package/dist/client/authQuery.js +10 -0
- package/dist/client/authQuery.js.map +1 -0
- package/dist/client/sessionBoundary.d.ts.map +1 -1
- package/dist/client/sessionBoundary.js +4 -2
- package/dist/client/sessionBoundary.js.map +1 -1
- package/dist/client/useNow.d.ts +5 -0
- package/dist/client/useNow.d.ts.map +1 -0
- package/dist/client/useNow.js +20 -0
- package/dist/client/useNow.js.map +1 -0
- package/dist/client/useSignOut.d.ts +18 -0
- package/dist/client/useSignOut.d.ts.map +1 -0
- package/dist/client/useSignOut.js +26 -0
- package/dist/client/useSignOut.js.map +1 -0
- package/dist/internal/useLatestCallback.d.ts +2 -0
- package/dist/internal/useLatestCallback.d.ts.map +1 -0
- package/dist/internal/useLatestCallback.js +9 -0
- package/dist/internal/useLatestCallback.js.map +1 -0
- package/dist/permissionGuard.d.ts +5 -5
- package/dist/permissionGuard.d.ts.map +1 -1
- package/dist/permissionGuard.js +3 -2
- package/dist/permissionGuard.js.map +1 -1
- package/dist/signInSearch.d.ts +8 -0
- package/dist/signInSearch.d.ts.map +1 -0
- package/dist/signInSearch.js +5 -0
- package/dist/signInSearch.js.map +1 -0
- package/package.json +11 -10
- package/src/client/authQuery.ts +17 -0
- package/src/client/sessionBoundary.ts +4 -2
- package/src/client/useNow.ts +26 -0
- package/src/client/useSignOut.ts +33 -0
- package/src/internal/useLatestCallback.ts +9 -0
- package/src/permissionGuard.ts +9 -9
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly credentialGeneration: number;
|
|
64
|
+
readonly token: string;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
export function App({ session, recheck }: {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
86
|
+
#### Session-scoped bindings
|
|
99
87
|
|
|
100
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
</SessionBoundary
|
|
101
|
+
{(api) => <PreferencesScreen api={api} />}
|
|
102
|
+
</SessionBoundary>;
|
|
158
103
|
```
|
|
159
104
|
|
|
160
|
-
`makePreferencesClient(token)` creates
|
|
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
|
-
|
|
107
|
+
#### Resume from a native shell
|
|
163
108
|
|
|
164
|
-
|
|
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 {
|
|
179
|
-
import {
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
### 2. Once per feature
|
|
223
167
|
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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(
|
|
327
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
249
|
+
#### An editor and a create form
|
|
342
250
|
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
367
|
+
#### One-shot create and edit
|
|
363
368
|
|
|
364
|
-
`
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
374
|
+
import { Option } from "effect";
|
|
375
|
+
import { useEffect, useState } from "react";
|
|
376
|
+
import { useCreate } from "@shivaedev/react/client/create.ts";
|
|
370
377
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
613
|
+
interface Register {
|
|
614
|
+
router: typeof router;
|
|
615
|
+
}
|
|
466
616
|
}
|
|
467
617
|
|
|
468
618
|
function Shell({ children }: { readonly children: ReactNode }) {
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
632
|
+
return <p role="alert">{Predicate.isTagged(error, "NoteMissing") ? "Note missing" : "Could not load"}</p>;
|
|
478
633
|
}
|
|
479
634
|
export function App() {
|
|
480
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
655
|
+
#### Session permissions and guarded routes
|
|
497
656
|
|
|
498
|
-
|
|
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
|
-
|
|
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 {
|
|
508
|
-
import {
|
|
509
|
-
import
|
|
510
|
-
import {
|
|
511
|
-
import
|
|
512
|
-
import {
|
|
513
|
-
import {
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
728
|
+
#### Better Auth actions
|
|
594
729
|
|
|
595
|
-
|
|
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
|
-
|
|
732
|
+
```tsx
|
|
733
|
+
const signIn = authAction((credentials: { email: string; password: string }, signal) => authClient.signIn.email(credentials, { signal }));
|
|
598
734
|
|
|
599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
814
|
+
#### Read the current time
|
|
708
815
|
|
|
709
|
-
|
|
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
|
-
|
|
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
|
|
821
|
+
const now = useNow(60_000, client.runtime);
|
|
822
|
+
const minutesAgo = Math.floor((now - savedAt) / 60_000);
|
|
776
823
|
```
|
|
777
824
|
|
|
778
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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/
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
|
954
|
+
pnpm add @shivaedev/react @effect/atom-react effect react
|
|
910
955
|
```
|
|
911
956
|
|
|
912
|
-
|
|
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
|
-
|
|
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.
|