@shivaedev/form 0.6.2 → 0.7.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 +18 -0
- package/README.md +132 -120
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1 - 2026-10-30
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Require Effect 4.0.6 and align the Effect companion package peers and generated starter catalog to the same exact release. Install the 4.0.6 Effect stack together.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Register hosted RPC cache-control handling through Effect 4.0.6's request-scoped pre-response API so responses keep `private, no-store`.
|
|
12
|
+
- Accept native RPC's `application/x-ndjson` media type while preserving `application/ndjson` callers at the transport guard.
|
|
13
|
+
- Keep subscriptions open after their HTTP request body completes; closing the socket still cancels them.
|
|
14
|
+
|
|
15
|
+
## 0.7.0 - 2026-10-27
|
|
16
|
+
|
|
17
|
+
### Breaking
|
|
18
|
+
|
|
19
|
+
- Rename `@shivaedev/effect-form` to `@shivaedev/form`. Change dependency declarations and imports from `@shivaedev/effect-form/...` to `@shivaedev/form/...`; module paths inside the package are unchanged.
|
|
20
|
+
|
|
3
21
|
## 0.6.2 - 2026-10-24
|
|
4
22
|
|
|
5
23
|
### Changed
|
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@ An input gives you a string; your service needs a number. Meanwhile, fresh serve
|
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
10
|
const order = form(Schema.Struct({ name: Schema.String, quantity: Schema.NumberFromString }), {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
12
|
+
onSubmit: Effect.succeed,
|
|
13
|
+
runtime,
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
order.change("name", "Copy paper");
|
|
@@ -45,32 +45,27 @@ import { form } from "@shivaedev/form/form.ts";
|
|
|
45
45
|
|
|
46
46
|
const runtime = Atom.runtime(Layer.empty);
|
|
47
47
|
const fields = Schema.Struct({
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
name: Schema.NonEmptyString,
|
|
49
|
+
quantity: Schema.NumberFromString,
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
const order = form(fields, {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
? submitter.fail("name", "Choose another name")
|
|
57
|
-
: Effect.succeed(values),
|
|
58
|
-
runtime,
|
|
53
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
54
|
+
onSubmit: (values, submitter) => (values.name === "reserved" ? submitter.fail("name", "Choose another name") : Effect.succeed(values)),
|
|
55
|
+
runtime,
|
|
59
56
|
});
|
|
60
57
|
|
|
61
58
|
const registry = AtomRegistry.make();
|
|
62
59
|
const release = registry.mount(order.dirty);
|
|
63
60
|
|
|
64
61
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
console.log(saved);
|
|
62
|
+
order.change("quantity", "350");
|
|
63
|
+
registry.set(order.submit, undefined);
|
|
64
|
+
const saved = await Effect.runPromise(AtomRegistry.getResult(registry, order.submit, { suspendOnWaiting: true }));
|
|
65
|
+
console.log(saved);
|
|
71
66
|
} finally {
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
release();
|
|
68
|
+
registry.dispose();
|
|
74
69
|
}
|
|
75
70
|
```
|
|
76
71
|
|
|
@@ -94,40 +89,54 @@ const runtime = Atom.runtime(Layer.empty);
|
|
|
94
89
|
const fields = Schema.Struct({ name: Schema.NonEmptyString, quantity: Schema.NumberFromString });
|
|
95
90
|
|
|
96
91
|
function OrderEditor() {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
92
|
+
const [order] = useState(() =>
|
|
93
|
+
form(fields, {
|
|
94
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
95
|
+
onSubmit: Effect.succeed,
|
|
96
|
+
runtime,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
const name = useField(order, "name");
|
|
100
|
+
const quantity = useField(order, "quantity");
|
|
101
|
+
const submit = useSubmit(order);
|
|
102
|
+
const dirty = useDirty(order);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<form
|
|
106
|
+
onSubmit={(event) => {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
if (!submit.submitting) submit.run();
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<label>
|
|
112
|
+
Name <input value={name.value} onBlur={name.onBlur} onChange={(event) => name.onChange(event.target.value)} />
|
|
113
|
+
</label>
|
|
114
|
+
{name.error && <p role="alert">{name.error}</p>}
|
|
115
|
+
<label>
|
|
116
|
+
Quantity <input value={quantity.value} onBlur={quantity.onBlur} onChange={(event) => quantity.onChange(event.target.value)} />
|
|
117
|
+
</label>
|
|
118
|
+
{quantity.error && <p role="alert">{quantity.error}</p>}
|
|
119
|
+
<button disabled={submit.submitting}>Save</button>
|
|
120
|
+
<button type="button" onClick={order.revert}>
|
|
121
|
+
Revert
|
|
122
|
+
</button>
|
|
123
|
+
<p role="status">{submit.submitting ? "Saving…" : dirty ? "Unsaved changes" : "Saved"}</p>
|
|
124
|
+
</form>
|
|
125
|
+
);
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
export function mountOrderEditor(container: HTMLElement) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
const registry = AtomRegistry.make();
|
|
130
|
+
const root = createRoot(container);
|
|
131
|
+
root.render(
|
|
132
|
+
<RegistryContext.Provider value={registry}>
|
|
133
|
+
<OrderEditor />
|
|
134
|
+
</RegistryContext.Provider>,
|
|
135
|
+
);
|
|
136
|
+
return () => {
|
|
137
|
+
root.unmount();
|
|
138
|
+
registry.dispose();
|
|
139
|
+
};
|
|
131
140
|
}
|
|
132
141
|
```
|
|
133
142
|
|
|
@@ -139,16 +148,16 @@ Render general submission failures from `submit.result` in the application's own
|
|
|
139
148
|
|
|
140
149
|
Call `receive` with encoded values from a refreshed or saved row. Encode domain values before receiving them; for a numeric string field, receive `String(row.quantity)`.
|
|
141
150
|
|
|
142
|
-
| Situation
|
|
143
|
-
|
|
|
144
|
-
| A field still equals its baseline
|
|
145
|
-
| A field was edited locally
|
|
146
|
-
| A refresh omits an untouched optional key
|
|
147
|
-
| An optional key was added locally
|
|
148
|
-
| The handler succeeds
|
|
151
|
+
| Situation | What the form does |
|
|
152
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
153
|
+
| A field still equals its baseline | Receives the new value. |
|
|
154
|
+
| A field was edited locally | Keeps that input; received values become the new baseline. |
|
|
155
|
+
| A refresh omits an untouched optional key | Removes the key. |
|
|
156
|
+
| An optional key was added locally | Keeps it when a refresh omits it; revert restores the received values. |
|
|
157
|
+
| The handler succeeds | Accepts the submitted encoded values without replacing later edits. |
|
|
149
158
|
| A refresh arrived during a successful save | Keeps that refresh as the baseline; values still equal to the accepted submission count as saved and adopt the next refresh. |
|
|
150
|
-
| The handler fails
|
|
151
|
-
| `revert()` is called
|
|
159
|
+
| The handler fails | Keeps input; an intervening refresh remains the baseline. |
|
|
160
|
+
| `revert()` is called | Restores the latest baseline, including omitted optional keys. |
|
|
152
161
|
|
|
153
162
|
The form does not replace submitted encoded input with its decoded representation. A trimmed name can be accepted as saved while the input still contains its spaces. Receive the normalized saved row when the application wants that representation on screen. `useEditor` from `react` performs that composition.
|
|
154
163
|
|
|
@@ -160,10 +169,10 @@ For a core form, supply the storage provider explicitly:
|
|
|
160
169
|
|
|
161
170
|
```ts
|
|
162
171
|
const order = form(fields, {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
173
|
+
onSubmit: Effect.succeed,
|
|
174
|
+
persistedDraft: { key: "order:123", storage: () => window.localStorage },
|
|
175
|
+
runtime,
|
|
167
176
|
});
|
|
168
177
|
```
|
|
169
178
|
|
|
@@ -174,14 +183,16 @@ import { useState } from "react";
|
|
|
174
183
|
import { usePersistedDraft } from "@shivaedev/form/client/react.ts";
|
|
175
184
|
|
|
176
185
|
function OrderEditor() {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
186
|
+
const persistedDraft = usePersistedDraft({ key: "order:123" });
|
|
187
|
+
const [order] = useState(() =>
|
|
188
|
+
form(fields, {
|
|
189
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
190
|
+
onSubmit: Effect.succeed,
|
|
191
|
+
persistedDraft,
|
|
192
|
+
runtime,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
return <OrderFields form={order} />;
|
|
185
196
|
}
|
|
186
197
|
```
|
|
187
198
|
|
|
@@ -201,13 +212,13 @@ For `Schema.NonEmptyString`, an empty input's message is `Required`. Longer mini
|
|
|
201
212
|
|
|
202
213
|
```ts
|
|
203
214
|
const profile = form(Schema.Struct({ name: Schema.String }), {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
215
|
+
checks: {
|
|
216
|
+
name: (value) => Effect.succeed(value === "taken" ? "Name is taken" : undefined),
|
|
217
|
+
},
|
|
218
|
+
debounce: "300 millis",
|
|
219
|
+
initialValues: { name: "" },
|
|
220
|
+
onSubmit: Effect.succeed,
|
|
221
|
+
runtime,
|
|
211
222
|
});
|
|
212
223
|
```
|
|
213
224
|
|
|
@@ -220,15 +231,17 @@ For a nested value held in one top-level field, `errorAt(["answers", index, "tex
|
|
|
220
231
|
Pass a path tuple to `submitter.fail` to reject one element or a property inside it. Paths use indices in the submitted encoded snapshot. Optional `arrayKeys` selectors receive encoded elements of top-level array fields and return their stable, unique keys:
|
|
221
232
|
|
|
222
233
|
```ts
|
|
223
|
-
const answers = form(
|
|
224
|
-
|
|
225
|
-
}),
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
234
|
+
const answers = form(
|
|
235
|
+
Schema.Struct({
|
|
236
|
+
answers: Schema.Array(Schema.Struct({ id: Schema.String, text: Schema.NonEmptyString })),
|
|
237
|
+
}),
|
|
238
|
+
{
|
|
239
|
+
arrayKeys: { answers: (answer) => answer.id },
|
|
240
|
+
initialValues: { answers: [{ id: "question", text: "Yes" }] },
|
|
241
|
+
onSubmit: (_values, submitter) => submitter.fail(["answers", 0, "text"], "This answer is closed"),
|
|
242
|
+
runtime,
|
|
243
|
+
},
|
|
244
|
+
);
|
|
232
245
|
```
|
|
233
246
|
|
|
234
247
|
`FieldFailure.path` keeps the top-level field name as a string. For a tuple rejection, optional `FieldFailure.segments` contains its submitted path.
|
|
@@ -244,8 +257,8 @@ import { Schema } from "effect";
|
|
|
244
257
|
import { emptyAsNull } from "@shivaedev/form/emptyAsNull.ts";
|
|
245
258
|
|
|
246
259
|
const fields = Schema.Struct({
|
|
247
|
-
|
|
248
|
-
|
|
260
|
+
visibility: Schema.Literals(["private", "public"]),
|
|
261
|
+
quantity: emptyAsNull(Schema.NumberFromString),
|
|
249
262
|
});
|
|
250
263
|
```
|
|
251
264
|
|
|
@@ -255,42 +268,42 @@ For a form made with these fields, `order.choices("visibility")` returns the enc
|
|
|
255
268
|
|
|
256
269
|
Import the module that defines a name; there is no root entry.
|
|
257
270
|
|
|
258
|
-
| Module
|
|
259
|
-
|
|
|
260
|
-
| `@shivaedev/form/form.ts`
|
|
261
|
-
| `@shivaedev/form/shape.ts`
|
|
271
|
+
| Module | Application-facing exports |
|
|
272
|
+
| --------------------------------- | ------------------------------------------------------------------------------- |
|
|
273
|
+
| `@shivaedev/form/form.ts` | `form(schema, config)` |
|
|
274
|
+
| `@shivaedev/form/shape.ts` | `Form`, `Submitter`, `FieldFailure`, `Invalid` and the field types they use |
|
|
262
275
|
| `@shivaedev/form/client/react.ts` | `useField`, `useSubmit`, `useDirty`, `usePersistedDraft`, `DraftSessionContext` |
|
|
263
|
-
| `@shivaedev/form/draftSession.ts` | `draftSession(id)`
|
|
264
|
-
| `@shivaedev/form/emptyAsNull.ts`
|
|
276
|
+
| `@shivaedev/form/draftSession.ts` | `draftSession(id)` |
|
|
277
|
+
| `@shivaedev/form/emptyAsNull.ts` | `emptyAsNull` |
|
|
265
278
|
|
|
266
279
|
`form` takes a `Schema.Struct` and this configuration:
|
|
267
280
|
|
|
268
|
-
| Option
|
|
269
|
-
|
|
|
270
|
-
| `initialValues`
|
|
271
|
-
| `runtime`
|
|
272
|
-
| `onSubmit`
|
|
273
|
-
| `checks`
|
|
274
|
-
| `arrayKeys`
|
|
275
|
-
| `debounce`
|
|
276
|
-
| `persistedDraft` | Optional `{ key, storage, maxBytes?, maxAgeMs?, session? }`; React's `usePersistedDraft` supplies storage and session ownership
|
|
281
|
+
| Option | Type or role |
|
|
282
|
+
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
283
|
+
| `initialValues` | `Encoded<F>` |
|
|
284
|
+
| `runtime` | Atom runtime for the handler/check services and schema decoding services |
|
|
285
|
+
| `onSubmit` | `(values, submitter, submitted) => Effect<A, E, R>`; the three arguments are decoded values, field failure helper and encoded snapshot |
|
|
286
|
+
| `checks` | Optional functions keyed by field, from encoded input to an Effect of `string \| undefined` |
|
|
287
|
+
| `arrayKeys` | Optional `ArrayKeys<F>` selectors keyed by top-level array field, from encoded element to stable `PropertyKey`; retains nested feedback across reordering |
|
|
288
|
+
| `debounce` | Optional Effect `Duration.Input` for checks |
|
|
289
|
+
| `persistedDraft` | Optional `{ key, storage, maxBytes?, maxAgeMs?, session? }`; React's `usePersistedDraft` supplies storage and session ownership |
|
|
277
290
|
|
|
278
291
|
The returned form exposes:
|
|
279
292
|
|
|
280
|
-
| Member
|
|
281
|
-
|
|
|
282
|
-
| `values`
|
|
283
|
-
| `field(name)`
|
|
284
|
-
| `change(name, value)`
|
|
285
|
-
| `blur(name)`
|
|
286
|
-
| `error(name)`
|
|
293
|
+
| Member | Type or use |
|
|
294
|
+
| -------------------------- | --------------------------------------------------------------------------------------------- |
|
|
295
|
+
| `values` | `AtomRef<Encoded<F>>` for the whole input |
|
|
296
|
+
| `field(name)` | `AtomRef<Encoded<F>[K]>` for a field |
|
|
297
|
+
| `change(name, value)` | Write encoded input through the form's editing operation |
|
|
298
|
+
| `blur(name)` | Field blur operation, supplied as `useField(...).onBlur` |
|
|
299
|
+
| `error(name)` | Atom of `string \| undefined` |
|
|
287
300
|
| `errorAt([name, ...path])` | Atom of nested Schema feedback and handler rejections; visibility follows the top-level field |
|
|
288
|
-
| `choices(name)`
|
|
289
|
-
| `dirty`
|
|
290
|
-
| `receive(values)`
|
|
291
|
-
| `revert()`
|
|
292
|
-
| `submit`
|
|
293
|
-
| `submitting`
|
|
301
|
+
| `choices(name)` | Encoded choices or `undefined` |
|
|
302
|
+
| `dirty` | Atom of `boolean` |
|
|
303
|
+
| `receive(values)` | Merge received encoded values |
|
|
304
|
+
| `revert()` | Restore the baseline |
|
|
305
|
+
| `submit` | `AtomResultFn<void, A, E \| ER \| FieldFailure \| Invalid>` |
|
|
306
|
+
| `submitting` | Atom of `boolean` for submission waiting state |
|
|
294
307
|
|
|
295
308
|
`F` is the schema's fields, `A` and `E` are the handler's success and failure types, and `ER` is the atom runtime's failure type. `FieldFailure` contains the top-level string `path`, `message` and optional submitted path `segments`; `Invalid` contains `messages`, a `FieldMessages` record.
|
|
296
309
|
|
|
@@ -299,17 +312,16 @@ The lower-level `draft.ts` (`draft`), `refs.ts` (`fromRef`, `propertyRef`) and `
|
|
|
299
312
|
### Install and limits
|
|
300
313
|
|
|
301
314
|
```sh
|
|
302
|
-
pnpm add @shivaedev/form effect
|
|
315
|
+
pnpm add @shivaedev/form effect
|
|
303
316
|
```
|
|
304
317
|
|
|
305
318
|
For React bindings:
|
|
306
319
|
|
|
307
320
|
```sh
|
|
308
|
-
pnpm add @effect/atom-react
|
|
321
|
+
pnpm add @effect/atom-react react
|
|
309
322
|
```
|
|
310
323
|
|
|
311
324
|
The React mounting example also uses the application's `react-dom`. React and `@effect/atom-react` are optional peers; the core modules do not import them. The package needs Node 24 or newer.
|
|
312
|
-
|
|
313
325
|
- Field names are top-level schema keys. There is no path-addressed field-array API.
|
|
314
326
|
- Replace encoded values rather than mutating objects in place; use `change` for ordinary UI edits.
|
|
315
327
|
- Keep the form and registry for their intended session, and clean up the registry when that session ends.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/form",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Schema-derived Effect atom forms with optional React hooks",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect"
|
|
@@ -43,21 +43,21 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@effect/atom-react": "4.0.
|
|
47
|
-
"@effect/vitest": "4.0.
|
|
48
|
-
"@shivaedev/quality": "0.
|
|
49
|
-
"@shivaedev/test": "0.
|
|
50
|
-
"@shivaedev/test-story": "0.
|
|
46
|
+
"@effect/atom-react": "4.0.6",
|
|
47
|
+
"@effect/vitest": "4.0.6",
|
|
48
|
+
"@shivaedev/quality": "0.45.0",
|
|
49
|
+
"@shivaedev/test": "0.10.1",
|
|
50
|
+
"@shivaedev/test-story": "0.24.0",
|
|
51
51
|
"@types/node": "24.19.2",
|
|
52
52
|
"@types/react": "19.3.0",
|
|
53
|
-
"effect": "4.0.
|
|
53
|
+
"effect": "4.0.6",
|
|
54
54
|
"react": "19.3.1",
|
|
55
55
|
"typescript": "7.0.2",
|
|
56
56
|
"vitest": "5.0.5"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@effect/atom-react": "4.0.
|
|
60
|
-
"effect": "4.0.
|
|
59
|
+
"@effect/atom-react": "4.0.6",
|
|
60
|
+
"effect": "4.0.6",
|
|
61
61
|
"react": "19.3.1"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|