@shivaedev/effect-react 0.2.1 → 0.2.2
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 +18 -0
- package/README.md +452 -55
- package/dist/client/create.d.ts +17 -0
- package/dist/client/create.d.ts.map +1 -0
- package/dist/{create.js → client/create.js} +9 -9
- package/dist/client/create.js.map +1 -0
- package/dist/client/editor.d.ts +20 -0
- package/dist/client/editor.d.ts.map +1 -0
- package/dist/{editor.js → client/editor.js} +5 -5
- package/dist/client/editor.js.map +1 -0
- package/dist/client/resultState.d.ts +23 -0
- package/dist/client/resultState.d.ts.map +1 -0
- package/dist/client/resultState.js +48 -0
- package/dist/client/resultState.js.map +1 -0
- package/dist/{save-state.d.ts → client/saveState.d.ts} +2 -2
- package/dist/client/saveState.d.ts.map +1 -0
- package/dist/{save-state.js → client/saveState.js} +5 -5
- package/dist/client/saveState.js.map +1 -0
- package/dist/client/sessionBoundary.d.ts +12 -0
- package/dist/client/sessionBoundary.d.ts.map +1 -0
- package/dist/{session-boundary.js → client/sessionBoundary.js} +18 -12
- package/dist/client/sessionBoundary.js.map +1 -0
- package/dist/client/testing.d.ts +12 -0
- package/dist/client/testing.d.ts.map +1 -0
- package/dist/client/testing.js +66 -0
- package/dist/client/testing.js.map +1 -0
- package/dist/field-rejection.d.ts.map +1 -1
- package/dist/field-rejection.js +3 -1
- package/dist/field-rejection.js.map +1 -1
- package/dist/loadQuery.d.ts +6 -0
- package/dist/loadQuery.d.ts.map +1 -0
- package/dist/loadQuery.js +21 -0
- package/dist/loadQuery.js.map +1 -0
- package/dist/submission.d.ts.map +1 -1
- package/docs/north-star.md +45 -0
- package/docs/roadmap.md +28 -0
- package/package.json +122 -16
- package/src/client/create.ts +41 -0
- package/src/client/editor.ts +56 -0
- package/src/client/resultState.ts +73 -0
- package/src/{save-state.ts → client/saveState.ts} +11 -10
- package/src/{session-boundary.ts → client/sessionBoundary.ts} +28 -32
- package/src/client/testing.ts +68 -0
- package/src/field-rejection.ts +3 -1
- package/src/loadQuery.ts +27 -0
- package/dist/create.d.ts +0 -17
- package/dist/create.d.ts.map +0 -1
- package/dist/create.js.map +0 -1
- package/dist/editor.d.ts +0 -20
- package/dist/editor.d.ts.map +0 -1
- package/dist/editor.js.map +0 -1
- package/dist/result-state.d.ts +0 -20
- package/dist/result-state.d.ts.map +0 -1
- package/dist/result-state.js +0 -36
- package/dist/result-state.js.map +0 -1
- package/dist/save-state.d.ts.map +0 -1
- package/dist/save-state.js.map +0 -1
- package/dist/session-boundary.d.ts +0 -12
- package/dist/session-boundary.d.ts.map +0 -1
- package/dist/session-boundary.js.map +0 -1
- package/src/create.ts +0 -43
- package/src/editor.ts +0 -58
- package/src/result-state.ts +0 -58
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Publish `@shivaedev/effect-react/testing.ts` with `render`, `Rendered`, `eventually` and `expectNoReactWarnings` for happy-dom render tests using native atom registries and test RPC layers. `render` exposes its registry, supports rerendering and releases its root, registry and container on unmount. React DOM and Vitest are optional peers needed by the testing entry.
|
|
8
|
+
- Add opt-in `useQuery(atom, { keepPreviousData: true })` to retain the last successful value while a changed query payload loads or fails. Query identity changes still reset displayed data by default, and registry changes clear it even with retention enabled. Native results, typed failures and interruption remain visible.
|
|
9
|
+
- `@shivaedev/effect-react/router.ts` exports `loadQuery(registry, atom, loaderContext)` for TanStack Router loaders. It reads contract query atoms in the application's shared registry, throws typed failures as their original tagged errors, and releases an aborted loader's subscription. TanStack Router is an optional peer.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Document the package's purpose, native atom mental model, query/action usage, session ownership and editor/create workflows; publish the north star and package roadmap with the README.
|
|
14
|
+
- Build package JavaScript, declarations and source maps with TypeScript 7.
|
|
15
|
+
- The reference PostgreSQL order handlers use the shared server live helper and support composing PostgreSQL change delivery. The reference fixture proves commits from two HTTP server processes and a pg-boss worker, rollback silence, and live resync after listener loss.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Keep the last successful query value during same-identity refreshes, including live invalidation, and after failed refreshes even when the native result has no retained success. `refreshing` reports pending work alongside displayed data.
|
|
20
|
+
|
|
3
21
|
## 0.2.1 - 2026-10-05
|
|
4
22
|
|
|
5
23
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,88 +1,485 @@
|
|
|
1
1
|
# @shivaedev/effect-react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React screens should not rebuild loading, refresh, save and session rules around every native Effect atom. This package gives queries and actions readable state, joins a record to its editable form, and keeps each session's client state together.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Why you want this
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
A refresh should not blank a useful screen. A save response should not erase what someone typed while waiting. Signing in as someone else should not inherit the previous person's draft. These hooks and the session boundary handle those transitions around native atoms and forms:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
```ts
|
|
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,
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`api` is the application's native RPC binding, and `InvoiceLineFields` describes its inputs. The editor creates the form when the row arrives. A refresh updates untouched fields and keeps edited ones; a save receives the server's normalized result while keeping edits made during the request. The component renders the form and decides what its loading and error messages say.
|
|
20
|
+
|
|
21
|
+
## Using it
|
|
22
|
+
|
|
23
|
+
### How to think about it
|
|
24
|
+
|
|
25
|
+
State belongs to an **atom in a registry**. A query hook reads that atom's native `AsyncResult`; an action hook reads and dispatches a native action atom. Two readers of the same query share its execution. Changing the query atom resets displayed data by default. An application can use `useQuery(atom, { keepPreviousData: true })` to keep the last successful value while a changed payload loads or fails. This retained display belongs to the hook and clears when its registry changes; native atoms still own execution and caching. Editors keep the default reset so a new record starts a new form.
|
|
26
|
+
|
|
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
|
+
|
|
29
|
+
An **editor** joins a query atom, an effect-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. effect-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
|
+
|
|
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. Cache policy, RPC declarations and transport stay with native Effect and the application; this package supplies their React state and lifecycle boundary.
|
|
32
|
+
|
|
33
|
+
### 1. Once per application: own the registry
|
|
34
|
+
|
|
35
|
+
For a screen without a session boundary, wrap it in the native provider:
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { RegistryProvider } from "@effect/atom-react";
|
|
39
|
+
|
|
40
|
+
export function App() {
|
|
41
|
+
return <RegistryProvider><Orders /></RegistryProvider>;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`Orders` is the application's feature component. For authenticated screens, use `SessionBoundary` at the authenticated root instead:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { SessionBoundary } from "@shivaedev/effect-react/session-boundary.ts";
|
|
49
|
+
import { makeOrderClient, OrderScreens } from "./orders.tsx";
|
|
50
|
+
|
|
51
|
+
interface Session {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly credentialGeneration: number;
|
|
54
|
+
readonly token: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function App({ session, recheck }: {
|
|
58
|
+
readonly session: Session | undefined;
|
|
59
|
+
readonly recheck: () => void;
|
|
60
|
+
}) {
|
|
61
|
+
return (
|
|
62
|
+
<SessionBoundary
|
|
63
|
+
session={session}
|
|
64
|
+
identify={(current) => `${current.id}:${current.credentialGeneration}`}
|
|
65
|
+
connect={(current) => makeOrderClient(current.token)}
|
|
66
|
+
recheck={recheck}
|
|
67
|
+
signedOut={<p>Signed out</p>}
|
|
68
|
+
>
|
|
69
|
+
{(client) => <OrderScreens client={client} />}
|
|
70
|
+
</SessionBoundary>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`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.
|
|
76
|
+
|
|
77
|
+
A query that fails with a tagged `Unauthorized` asks `recheck` to run and keeps its retained data. 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.
|
|
78
|
+
|
|
79
|
+
### 2. Once per feature: native queries and actions
|
|
80
|
+
|
|
81
|
+
The hooks accept native atoms directly. This complete component module shows query and action state without RPC setup:
|
|
10
82
|
|
|
11
83
|
```tsx
|
|
12
|
-
import { RegistryProvider } from "@effect/atom-react"
|
|
13
|
-
import { useAction, useQuery } from "@shivaedev/effect-react/result-state.ts"
|
|
14
|
-
import { Option } from "effect"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
84
|
+
import { RegistryProvider } from "@effect/atom-react";
|
|
85
|
+
import { useAction, useQuery } from "@shivaedev/effect-react/result-state.ts";
|
|
86
|
+
import { Effect, Option } from "effect";
|
|
87
|
+
import * as Atom from "effect/unstable/reactivity/Atom";
|
|
88
|
+
|
|
89
|
+
const report = Atom.make(Effect.succeed({ id: 1, title: "Quarterly report" }));
|
|
90
|
+
const rename = Atom.fn<string>()((title) =>
|
|
91
|
+
title.trim() === ""
|
|
92
|
+
? Effect.fail("Title required" as const)
|
|
93
|
+
: Effect.succeed({ id: 1, title: title.trim() }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
function Report() {
|
|
97
|
+
const query = useQuery(report);
|
|
98
|
+
const action = useAction(rename);
|
|
99
|
+
return (
|
|
100
|
+
<section>
|
|
101
|
+
{Option.match(query.data, {
|
|
102
|
+
onNone: () => <p>Loading…</p>,
|
|
103
|
+
onSome: (row) => <h1>{row.title}</h1>,
|
|
104
|
+
})}
|
|
105
|
+
{query.refreshing && <p>Refreshing…</p>}
|
|
106
|
+
{Option.isSome(query.cause) && <p role="alert">Could not load</p>}
|
|
107
|
+
<button onClick={query.refresh}>Refresh</button>
|
|
108
|
+
<button disabled={action.pending} onClick={() => action.dispatch("Annual report")}>
|
|
109
|
+
Rename
|
|
110
|
+
</button>
|
|
111
|
+
{Option.isSome(action.data) && <output>{action.data.value.title}</output>}
|
|
112
|
+
{Option.isSome(action.cause) && <p role="alert">Could not rename</p>}
|
|
113
|
+
</section>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function App() {
|
|
118
|
+
return <RegistryProvider><Report /></RegistryProvider>;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
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:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const notes = useQuery(NotesClient.query("ListNotes", undefined, {
|
|
126
|
+
reactivityKeys: ["notes"],
|
|
127
|
+
}));
|
|
128
|
+
const create = useAction(NotesClient.mutation("CreateNote"));
|
|
129
|
+
|
|
130
|
+
create.dispatch({
|
|
131
|
+
payload: { title: "Morning walk" },
|
|
132
|
+
reactivityKeys: ["notes"],
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`NotesClient` is an application's `AtomRpc.Service` with `ListNotes` and `CreateNote` RPCs. The native mutation's reactivity keys refresh the matching query after success. The package's rendered native RPC fixture exercises this shape through a real repository, including a rejected save that preserves the list.
|
|
137
|
+
|
|
138
|
+
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. Disable duplicate submissions or give independent work distinct native action atoms. When one caller needs its own success or failure, run the actual RPC Effect through its runtime, as the editor's `save` does; shared atom state cannot identify one invocation's completion.
|
|
139
|
+
|
|
140
|
+
### Route loaders with TanStack Router
|
|
141
|
+
|
|
142
|
+
TanStack Router owns routes, navigation, loader data and error components. `@shivaedev/effect-react/router.ts` supplies `loadQuery(registry, atom, loaderContext)` to read a contract query atom in the same registry as React. The atom runs through its native RPC runtime; the loader does not create another runtime or query cache.
|
|
143
|
+
|
|
144
|
+
This example assumes an application-owned `api.get.query({ id })` returning `{ id, title }`, with a declared `NoteMissing` rejection:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { RegistryContext } from "@effect/atom-react";
|
|
148
|
+
import { createRootRouteWithContext, createRoute, createRouter, Outlet, RouterProvider, useRouterState } from "@tanstack/react-router";
|
|
149
|
+
import type { ErrorComponentProps } from "@tanstack/react-router";
|
|
150
|
+
import { Predicate } from "effect";
|
|
151
|
+
import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
|
|
152
|
+
import { loadQuery } from "@shivaedev/effect-react/router.ts";
|
|
153
|
+
import { api } from "./notes-client.ts";
|
|
154
|
+
|
|
155
|
+
const registry = AtomRegistry.make();
|
|
156
|
+
const root = createRootRouteWithContext<{ registry: AtomRegistry.AtomRegistry }>()({
|
|
157
|
+
component: Shell,
|
|
158
|
+
});
|
|
159
|
+
const note = createRoute({
|
|
160
|
+
getParentRoute: () => root,
|
|
161
|
+
path: "/notes/$id",
|
|
162
|
+
loader: (context) => loadQuery(context.context.registry, api.get.query(context.params), context),
|
|
163
|
+
component: Note,
|
|
164
|
+
errorComponent: Failure,
|
|
165
|
+
});
|
|
166
|
+
const router = createRouter({
|
|
167
|
+
context: { registry },
|
|
168
|
+
routeTree: root.addChildren([note]),
|
|
169
|
+
defaultPendingMs: Infinity,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
declare module "@tanstack/react-router" {
|
|
173
|
+
interface Register {
|
|
174
|
+
router: typeof router;
|
|
28
175
|
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function Shell() {
|
|
179
|
+
const pending = useRouterState({ select: (state) => state.isLoading });
|
|
180
|
+
return <main>{pending && <p>Loading…</p>}<Outlet /></main>;
|
|
181
|
+
}
|
|
182
|
+
function Note() {
|
|
183
|
+
const row = note.useLoaderData();
|
|
184
|
+
return <h1>{row.title}</h1>;
|
|
185
|
+
}
|
|
186
|
+
function Failure({ error }: ErrorComponentProps) {
|
|
187
|
+
return <p role="alert">{Predicate.isTagged(error, "NoteMissing") ? "Note missing" : "Could not load"}</p>;
|
|
188
|
+
}
|
|
189
|
+
export function App() {
|
|
190
|
+
return <RegistryContext.Provider value={registry}><RouterProvider router={router} /></RegistryContext.Provider>;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`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 render fixture proves navigation between two note payloads, shared loader/component query execution, and a declared RPC failure reaching the error component as the original tagged error.
|
|
195
|
+
|
|
196
|
+
`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`.
|
|
197
|
+
|
|
198
|
+
Install the optional router peer when using this entry point:
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
pnpm add @tanstack/react-router
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### 3. Once per feature: an editor and a create form
|
|
205
|
+
|
|
206
|
+
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/effect-contract/README.md) describes that binding.
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
import { useCreate } from "@shivaedev/effect-react/create.ts";
|
|
210
|
+
import { useEditor } from "@shivaedev/effect-react/editor.ts";
|
|
211
|
+
import { useField } from "@shivaedev/effect-form/react.ts";
|
|
212
|
+
import type { Form } from "@shivaedev/effect-form/shape.ts";
|
|
213
|
+
import { Option, Schema } from "effect";
|
|
214
|
+
import { api, InvoiceLinesClient } from "./invoice-lines-client.ts";
|
|
215
|
+
|
|
216
|
+
const InvoiceLineFields = Schema.Struct({
|
|
217
|
+
name: Schema.String,
|
|
218
|
+
quantity: Schema.NumberFromString,
|
|
219
|
+
});
|
|
220
|
+
type Fields = typeof InvoiceLineFields.fields;
|
|
221
|
+
|
|
222
|
+
function Inputs<A, E, ER>({ form }: { readonly form: Form<Fields, A, E, ER> }) {
|
|
223
|
+
const name = useField(form, "name");
|
|
224
|
+
const quantity = useField(form, "quantity");
|
|
225
|
+
return (
|
|
226
|
+
<>
|
|
227
|
+
<label>
|
|
228
|
+
Name
|
|
229
|
+
<input name="name" value={name.value} onBlur={name.onBlur}
|
|
230
|
+
onChange={(event) => name.onChange(event.target.value)} />
|
|
231
|
+
</label>
|
|
232
|
+
{name.error && <p role="alert">{name.error}</p>}
|
|
233
|
+
<label>
|
|
234
|
+
Quantity
|
|
235
|
+
<input name="quantity" value={quantity.value} onBlur={quantity.onBlur}
|
|
236
|
+
onChange={(event) => quantity.onChange(event.target.value)} />
|
|
237
|
+
</label>
|
|
238
|
+
{quantity.error && <p role="alert">{quantity.error}</p>}
|
|
239
|
+
</>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
29
242
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
243
|
+
export function InvoiceLineEditor({ id }: { readonly id: number }) {
|
|
244
|
+
const editor = useEditor({
|
|
245
|
+
query: api.get.query({ id }),
|
|
246
|
+
fields: InvoiceLineFields,
|
|
247
|
+
values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
|
|
248
|
+
save: (values) => api.save.run({ id, ...values }),
|
|
249
|
+
runtime: InvoiceLinesClient.runtime,
|
|
250
|
+
});
|
|
251
|
+
if (editor.form === undefined) {
|
|
252
|
+
return Option.isSome(editor.query.cause)
|
|
253
|
+
? <button onClick={editor.query.refresh}>Retry</button>
|
|
254
|
+
: <p>Loading…</p>;
|
|
34
255
|
}
|
|
35
|
-
return
|
|
36
|
-
|
|
256
|
+
return (
|
|
257
|
+
<form onSubmit={(event) => { event.preventDefault(); editor.save(); }}>
|
|
258
|
+
<Inputs form={editor.form} />
|
|
259
|
+
{Option.isSome(editor.query.cause) && <p role="alert">Could not refresh</p>}
|
|
260
|
+
{Option.isSome(editor.failure) && <p role="alert">Could not save</p>}
|
|
261
|
+
<button type="submit" disabled={editor.saving}>Save</button>
|
|
262
|
+
<button type="button" onClick={editor.query.refresh}>Refresh</button>
|
|
263
|
+
<button type="button" onClick={editor.revert}>Revert</button>
|
|
264
|
+
<p>{editor.saving ? "Saving" : editor.dirty ? "Unsaved" : "Saved"}</p>
|
|
265
|
+
</form>
|
|
266
|
+
);
|
|
37
267
|
}
|
|
38
268
|
|
|
39
|
-
function
|
|
40
|
-
|
|
269
|
+
export function InvoiceLineCreate() {
|
|
270
|
+
const create = useCreate({
|
|
271
|
+
fields: InvoiceLineFields,
|
|
272
|
+
initialValues: { name: "", quantity: "" },
|
|
273
|
+
create: api.create.run,
|
|
274
|
+
runtime: InvoiceLinesClient.runtime,
|
|
275
|
+
});
|
|
276
|
+
return (
|
|
277
|
+
<form onSubmit={(event) => { event.preventDefault(); create.save(); }}>
|
|
278
|
+
<Inputs form={create.form} />
|
|
279
|
+
{Option.isSome(create.failure) && <p role="alert">Could not create</p>}
|
|
280
|
+
<button type="submit" disabled={create.saving}>Create</button>
|
|
281
|
+
{Option.isSome(create.created) && <output>Created {create.created.value.name}</output>}
|
|
282
|
+
</form>
|
|
283
|
+
);
|
|
41
284
|
}
|
|
42
285
|
```
|
|
43
286
|
|
|
44
|
-
|
|
287
|
+
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.
|
|
45
288
|
|
|
46
|
-
|
|
289
|
+
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.
|
|
47
290
|
|
|
48
|
-
`
|
|
291
|
+
`save()` ignores a second call while saving. Submitting through effect-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.
|
|
292
|
+
|
|
293
|
+
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`.
|
|
294
|
+
|
|
295
|
+
### Field rejections and other failures
|
|
296
|
+
|
|
297
|
+
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:
|
|
49
298
|
|
|
50
299
|
```ts
|
|
51
300
|
const editor = useEditor({
|
|
52
|
-
query
|
|
53
|
-
fields:
|
|
54
|
-
values: (
|
|
55
|
-
save
|
|
56
|
-
runtime
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
301
|
+
query,
|
|
302
|
+
fields: Schema.Struct({ title: Schema.String }),
|
|
303
|
+
values: (row) => ({ title: row.title }),
|
|
304
|
+
save,
|
|
305
|
+
runtime,
|
|
306
|
+
rejectField: (error) => error._tag === "SubtitleRejected"
|
|
307
|
+
? { field: "title", message: error.message }
|
|
308
|
+
: undefined,
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
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.
|
|
313
|
+
|
|
314
|
+
### Resume and refresh policy
|
|
315
|
+
|
|
316
|
+
Keep the signal and query identities stable:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { resumeSignal } from "@shivaedev/effect-react/resume-signal.ts";
|
|
320
|
+
import { Effect } from "effect";
|
|
321
|
+
import * as Atom from "effect/unstable/reactivity/Atom";
|
|
322
|
+
|
|
323
|
+
const resume = resumeSignal({ window });
|
|
324
|
+
const query = Atom.makeRefreshOnSignal(resume)(Atom.make(Effect.succeed("ready")));
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`resumeSignal` changes on visible `visibilitychange` and visible `online` events. Hidden browser events do not refresh the query. An optional `native` source subscribes a callback and returns its unsubscribe function; its callback also changes the signal. Registry disposal removes the listeners. This module uses the shared [effect-contract resume implementation](https://github.com/ShivaeDev/platform/blob/main/packages/effect-contract/src/resume.ts).
|
|
328
|
+
|
|
329
|
+
Use native stale-while-revalidate policy when a resume should refresh only stale data:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
const query = Atom.swr(atom, {
|
|
333
|
+
focusSignal: resume,
|
|
334
|
+
revalidateOnFocus: true,
|
|
335
|
+
staleTime: "1 hour",
|
|
336
|
+
});
|
|
60
337
|
```
|
|
61
338
|
|
|
62
|
-
|
|
339
|
+
`atom` is the underlying native query. `Atom.swr` owns the staleness decision; the signal only reports that the application resumed.
|
|
340
|
+
|
|
341
|
+
### API
|
|
342
|
+
|
|
343
|
+
`useQuery(atom)` and `useAction(atom)` return these shared fields:
|
|
63
344
|
|
|
64
|
-
|
|
345
|
+
| Field | Meaning |
|
|
346
|
+
| --- | --- |
|
|
347
|
+
| `result` | Native `AsyncResult<A, E>`. |
|
|
348
|
+
| `data` | `Option<A>` from the current or retained success. Queries can retain displayed data even when the native `result` has no value. |
|
|
349
|
+
| `cause` | `Option<Cause<E>>` from a failed result. |
|
|
350
|
+
| `pending` | The native result is waiting. |
|
|
351
|
+
| `refreshing` | The result is waiting and successful data is available. |
|
|
65
352
|
|
|
66
|
-
|
|
353
|
+
`useQuery(atom, options?)` adds `refresh(): void`; `useAction` adds `dispatch(input): void`. `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.
|
|
67
354
|
|
|
68
|
-
|
|
355
|
+
| Defining module | Exports |
|
|
356
|
+
| --- | --- |
|
|
357
|
+
| `result-state.ts` | `useQuery`, `useAction`, `isUnauthorized`; `ResultState`, `QueryOptions`, `QueryState`, `ActionState`. |
|
|
358
|
+
| `session-boundary.ts` | `SessionBoundary`, `useSessionRecheck`; `SessionBoundaryProps`. |
|
|
359
|
+
| `editor.ts` | `useEditor`; `Editor`, `EditorConfig`. |
|
|
360
|
+
| `create.ts` | `useCreate`; `Create`, `CreateConfig`. |
|
|
361
|
+
| `resume-signal.ts` | `resumeSignal`; `ResumeSource`, `ResumeWindow`, `ResumeOptions`. |
|
|
362
|
+
| `router.ts` | `loadQuery`. |
|
|
363
|
+
| `save-state.ts` | `useSaveState`; `SaveState`. |
|
|
364
|
+
| `field-rejection.ts` | `fieldRejectionOf`, `rejecting`; `FieldRejection`, `RejectField`, `FieldRejectionMapping`, `AtomServices`. |
|
|
365
|
+
| `testing.ts` | `render`, `eventually`, `expectNoReactWarnings`; `Rendered`. |
|
|
366
|
+
| `submission.ts` | `submission`; `Submission`, `SubmissionConfig`. |
|
|
69
367
|
|
|
70
|
-
- `
|
|
71
|
-
- `data`: `Option<A>`, using the current or previous successful result. A valid `undefined` success remains `Some(undefined)`.
|
|
72
|
-
- `cause`: `Option<Cause<E>>`, preserving failures, defects and interruption information.
|
|
73
|
-
- `pending`: whether the native result is waiting, including refreshes.
|
|
74
|
-
- `refreshing`: waiting while successful data is available.
|
|
368
|
+
Import the defining `.ts` module, such as `@shivaedev/effect-react/editor.ts`. There is no root import. `SessionBoundaryProps` supplies `session`, `identify`, `connect`, `recheck`, a client-to-children function and optional `signedOut`; `useSessionRecheck()` reads that boundary's callback.
|
|
75
369
|
|
|
76
|
-
`
|
|
370
|
+
`Editor` exposes `query`, optional `form` and `SaveState`; `Create` exposes `form`, `created` and `SaveState`. `SaveState` has `save`, `saving`, `dirty`, `failure` and `revert`. Prefer the two complete hooks when binding a form to a command. The lower-level form modules expose the same composition pieces: `submission` makes a form and its submission snapshot reader, `rejecting` maps save failures through a submitter, and `useSaveState` observes submission and invokes a success callback.
|
|
371
|
+
|
|
372
|
+
### Render tests in happy-dom
|
|
373
|
+
|
|
374
|
+
Use `@shivaedev/effect-react/testing.ts` with Vitest's `happy-dom` environment. Install effect-contract for the example's query and command declarations, and the testing peers and environment in your app's dev dependencies:
|
|
375
|
+
|
|
376
|
+
```sh
|
|
377
|
+
pnpm add @shivaedev/effect-contract
|
|
378
|
+
pnpm add -D vitest happy-dom react-dom@19.2.8 @types/react-dom
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
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:
|
|
382
|
+
|
|
383
|
+
```tsx
|
|
384
|
+
import { Effect, Option, Ref, Schema } from "effect";
|
|
385
|
+
import * as AtomRpc from "effect/unstable/reactivity/AtomRpc";
|
|
386
|
+
import { RpcTest } from "effect/unstable/rpc";
|
|
387
|
+
import { act, createElement } from "react";
|
|
388
|
+
import { expect, it } from "vitest";
|
|
389
|
+
import { bind } from "@shivaedev/effect-contract/bind.ts";
|
|
390
|
+
import { contract } from "@shivaedev/effect-contract/contract.ts";
|
|
391
|
+
import { collection } from "@shivaedev/effect-contract/keys.ts";
|
|
392
|
+
import { command, query } from "@shivaedev/effect-contract/operation.ts";
|
|
393
|
+
import { useAction, useQuery } from "@shivaedev/effect-react/result-state.ts";
|
|
394
|
+
import { eventually, expectNoReactWarnings, render } from "@shivaedev/effect-react/testing.ts";
|
|
77
395
|
|
|
78
|
-
|
|
396
|
+
Object.assign(globalThis, { "IS_REACT_ACT_ENVIRONMENT": true });
|
|
397
|
+
expectNoReactWarnings();
|
|
79
398
|
|
|
80
|
-
|
|
399
|
+
const notes = collection("notes", Schema.Number);
|
|
400
|
+
const List = query("list", { reads: () => [notes.list], success: Schema.Array(Schema.String) });
|
|
401
|
+
const Add = command("add", { invalidates: () => [notes.list], payload: { title: Schema.String } });
|
|
402
|
+
const Notes = contract("notes", { commands: [Add], queries: [List] });
|
|
403
|
+
|
|
404
|
+
const handlers = Notes.toLayer(
|
|
405
|
+
Effect.gen(function* () {
|
|
406
|
+
const stored = yield* Ref.make<readonly string[]>([]);
|
|
407
|
+
return Notes.of({
|
|
408
|
+
"notes.add": ({ title }) => Ref.update(stored, (all) => [...all, title]),
|
|
409
|
+
"notes.list": () => Ref.get(stored).pipe(Effect.delay("20 millis")),
|
|
410
|
+
});
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
class Client extends AtomRpc.Service<Client>()("test/Notes", {
|
|
414
|
+
group: Notes,
|
|
415
|
+
makeEffect: RpcTest.makeClient(Notes, { flatten: true }),
|
|
416
|
+
protocol: handlers,
|
|
417
|
+
}) {}
|
|
418
|
+
const api = bind(Notes, Client);
|
|
419
|
+
const add = Client.runtime.fn(api.add.run);
|
|
420
|
+
function NotesView() {
|
|
421
|
+
const list = useQuery(api.list.query());
|
|
422
|
+
const action = useAction(add);
|
|
423
|
+
return createElement(
|
|
424
|
+
"section",
|
|
425
|
+
null,
|
|
426
|
+
createElement("output", null, Option.getOrElse(list.data, () => []).join(", ")),
|
|
427
|
+
createElement(
|
|
428
|
+
"button",
|
|
429
|
+
{
|
|
430
|
+
disabled: list.pending || action.pending,
|
|
431
|
+
onClick: () => action.dispatch({ title: "Morning walk" }),
|
|
432
|
+
type: "button",
|
|
433
|
+
},
|
|
434
|
+
"Add",
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
it("renders a query and refetches it after a command through a test RPC layer", async () => {
|
|
440
|
+
const view = await render(createElement(NotesView));
|
|
441
|
+
try {
|
|
442
|
+
await eventually(() => expect(view.container.querySelector("button")?.disabled).toBe(false));
|
|
443
|
+
expect(view.container.querySelector("output")?.textContent).toBe("");
|
|
444
|
+
await act(async () => view.container.querySelector("button")?.click());
|
|
445
|
+
await eventually(() => {
|
|
446
|
+
expect(view.container.querySelector("output")?.textContent).toBe("Morning walk");
|
|
447
|
+
expect(view.container.querySelector("button")?.disabled).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
} finally {
|
|
450
|
+
await view.unmount();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`render(node)` 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. Always unmount in `finally`. Keep component interactions inside React's `act`. `eventually(assert)` retries the assertion for up to three seconds, allowing pending responses to settle inside `act` between attempts; it throws the last assertion failure on timeout. `expectNoReactWarnings()` registers a Vitest hook that fails a test if it logged to `console.error`.
|
|
456
|
+
|
|
457
|
+
The RPC layer stays application-owned: replace these in-memory handlers with the service Layer your component needs. This test proves rendered query data and command-triggered invalidation in happy-dom, without an HTTP server or a physical browser. The generic `effect-test` package has no React dependency.
|
|
458
|
+
|
|
459
|
+
### Testing a feature through its stack
|
|
460
|
+
|
|
461
|
+
The [order feature’s application-owned kit](https://github.com/ShivaeDev/platform/tree/main/packages/effect-react/src/test-support/order-example) uses `test-story` above `effect-test` to compose real PostgreSQL, HTTP RPC and the actual React components. Its screen traits mount fresh native atom registries and scoped roots; its verbs dispatch DOM events and wait for rendered output. The [stories](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/src/orderStory.dom.spec.tsx) verify list refresh through `useAction` and rejected-input retention and retry through `useEditor`, with persistence read from a separate PostgreSQL pool. These tests require `PLATFORM_EFFECT_SQL_TEST_DATABASE_URL`; DOM and loopback HTTP establish no physical browser, SSR or mobile behavior.
|
|
462
|
+
|
|
463
|
+
### Install, setup and limits
|
|
464
|
+
|
|
465
|
+
```sh
|
|
466
|
+
pnpm add @shivaedev/effect-react @effect/atom-react@4.0.0-rc.112 effect@4.0.0-rc.112 react@19.2.8
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
For editor, create or lower-level form modules, also install the optional form peer:
|
|
470
|
+
|
|
471
|
+
```sh
|
|
472
|
+
pnpm add @shivaedev/effect-form
|
|
473
|
+
```
|
|
81
474
|
|
|
82
|
-
|
|
475
|
+
Query, action, session and resume modules do not require effect-form. The package declares Node 24 or newer and peers on Effect, atom-react and React; this repository checks Effect and atom-react `4.0.0-rc.112` with React `19.2.8`.
|
|
83
476
|
|
|
84
|
-
|
|
477
|
+
- Query identity, TTL, invalidation and action concurrency remain native atom policies. Put the registry at the lifetime boundary those policies need.
|
|
478
|
+
- Action state describes the atom's latest result. A `dispatch` is not a Promise or a separate result handle.
|
|
479
|
+
- A session generation is application-defined. Credentials captured by `connect` require a changed generation when they rotate.
|
|
480
|
+
- The package has no SSR registry/hydration adapter, offline write queue or optimistic reconciliation policy.
|
|
481
|
+
- DOM tests and an injected resume source do not prove real-browser or mobile lifecycle behavior. The real HTTP order fixture proves its local composition, not a deployed host's auth, cancellation or rendering policy.
|
|
85
482
|
|
|
86
|
-
|
|
483
|
+
The full design is in [the north star](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/docs/north-star.md). Implementation status and unresolved choices are in [the roadmap](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/docs/roadmap.md).
|
|
87
484
|
|
|
88
|
-
|
|
485
|
+
The [reference PostgreSQL feature](https://github.com/ShivaeDev/platform/blob/main/docs/framework/composed-example.md) uses the same leased database infrastructure and a scoped story verb for its real server and rendered clients. It is a test fixture, not a copyable app; an app template is still needed. Its stories verify commit visibility, live refresh of a second editor, retained local edits and rollback without publication.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Effect, Option, type Schema } from "effect";
|
|
2
|
+
import type * as Atom from "effect/unstable/reactivity/Atom";
|
|
3
|
+
import type { Decoded, Encoded, FieldFailure, Fields, Form, Services } from "@shivaedev/effect-form/shape.ts";
|
|
4
|
+
import type { AtomServices, FieldRejectionMapping } from "#field-rejection.ts";
|
|
5
|
+
import { type SaveState } from "./saveState.ts";
|
|
6
|
+
export type CreateConfig<TFields extends Fields, TCreated, TSaveError, R, TRuntimeError> = {
|
|
7
|
+
readonly fields: Schema.Struct<TFields>;
|
|
8
|
+
readonly initialValues: Encoded<TFields>;
|
|
9
|
+
readonly create: (values: Decoded<TFields>) => Effect.Effect<TCreated, TSaveError, AtomServices<R>>;
|
|
10
|
+
readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError>;
|
|
11
|
+
} & FieldRejectionMapping<TFields, TSaveError>;
|
|
12
|
+
export interface Create<TFields extends Fields, TCreated, TSaveError, TRuntimeError> extends SaveState<TSaveError | TRuntimeError> {
|
|
13
|
+
readonly created: Option.Option<TCreated>;
|
|
14
|
+
readonly form: Form<TFields, TCreated, TSaveError | FieldFailure, TRuntimeError>;
|
|
15
|
+
}
|
|
16
|
+
export declare function useCreate<TFields extends Fields, TCreated, TSaveError, R, TRuntimeError>(config: CreateConfig<TFields, TCreated, TSaveError, R, TRuntimeError>): Create<TFields, TCreated, TSaveError, TRuntimeError>;
|
|
17
|
+
//# sourceMappingURL=create.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/client/create.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAS,MAAM,EAAE,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AACjE,OAAO,KAAK,KAAK,IAAI,MAAM,iCAAiC,CAAC;AAE7D,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAC9G,OAAO,KAAK,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE/E,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,gBAAgB,CAAC;AAC9D,MAAM,MAAM,YAAY,CAAC,OAAO,SAAS,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,EAAE,aAAa,IAAI;IAC1F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACxC,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE,UAAU,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;IACpG,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC;CACxE,GAAG,qBAAqB,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;AAC/C,MAAM,WAAW,MAAM,CAAC,OAAO,SAAS,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,CAAE,SAAQ,SAAS,CAAC,UAAU,GAAG,aAAa,CAAC;IACjI,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC1C,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,UAAU,GAAG,YAAY,EAAE,aAAa,CAAC,CAAC;CACjF;AAKD,wBAAgB,SAAS,CAAC,OAAO,SAAS,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,EAAE,aAAa,EACvF,MAAM,EAAE,YAAY,CAAC,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,EAAE,aAAa,CAAC,GACnE,MAAM,CAAC,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,CAAC,CAiBtD"}
|