formstand 0.9.0 → 0.10.0
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 +357 -1
- package/README.md +17 -13
- package/dist/index.cjs +465 -266
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +108 -57
- package/dist/index.d.ts +108 -57
- package/dist/index.js +467 -269
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,362 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.10.0 — 2026-07-28
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- **`validateFields` / `validateFieldsAsync` return discriminated results
|
|
8
|
+
instead of booleans** (2026-07 self-review #1). `validateFields(paths)` now
|
|
9
|
+
returns `{ kind: "valid" }`, `{ kind: "invalid", errors }` (the error map
|
|
10
|
+
scoped to the requested paths), or `{ kind: "pending", promise }` on an
|
|
11
|
+
async schema — mirroring `validateField`. The old `boolean |
|
|
12
|
+
Promise<boolean>` union let a truthy in-flight `Promise` silently pass
|
|
13
|
+
`if (form.validateFields(...))` gates the moment a schema gained an async
|
|
14
|
+
refine. `validateFieldsAsync` resolves with the settled result instead of a
|
|
15
|
+
boolean. Migrate `if (ok)` to `if (result.kind === "valid")`.
|
|
16
|
+
- **`FieldFormApi.validateField` must not throw the async-required signal**
|
|
17
|
+
— `useField` no longer catches it and escalates to `validateFieldAsync`.
|
|
18
|
+
The declared contract (return `{ kind: "pending", promise }`) has been the
|
|
19
|
+
library's own behavior since the async-routing rework; custom
|
|
20
|
+
implementations written against the old throwing style should return the
|
|
21
|
+
pending result instead.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`pathDepth` option on `createForm`/`useForm`** — a type-level-only,
|
|
26
|
+
per-form typed-path depth budget (in segments): `createForm(schema, {
|
|
27
|
+
initialValues, pathDepth: 12 })` widens the `FieldPath` union so deeper
|
|
28
|
+
paths bind with full inference, threading through every typed surface
|
|
29
|
+
(`Form<TSchema, D>`, `useField`/`useFieldArray`/`useVariantField`, the
|
|
30
|
+
flag/selector hooks, `createFormHooks`, `createFormContext<S, D>`, the
|
|
31
|
+
bound components' `PathsOf`). The runtime ignores it entirely — path APIs
|
|
32
|
+
always walked any depth — and D infers as a literal from the option.
|
|
33
|
+
Motivated by one global store backing many forms, whose leaves sit past
|
|
34
|
+
the default budget. Raising it is a deliberate TypeScript compile-cost
|
|
35
|
+
trade: the union grows with every extra level, and every path-taking call
|
|
36
|
+
in the editor pays for it. `D` must be a number literal; budgets up to 25
|
|
37
|
+
are supported, and the option is CONSTRAINED to the exported `PathDepth`
|
|
38
|
+
union (`0 | 1 | ... | 25`, exactly the decrement table's range) —
|
|
39
|
+
`pathDepth: 26`, `pathDepth: -1`, a widened `number`-typed variable, and
|
|
40
|
+
a UNION value (`cond ? 12 : 9`, which would infer an unusable
|
|
41
|
+
`Form<S, 9 | 12>`) all fail to compile at the call site instead of
|
|
42
|
+
silently misbehaving. The same `D extends PathDepth` constraint is
|
|
43
|
+
enforced on EVERY generic surface carrying a depth parameter — `Form`
|
|
44
|
+
itself, `createFormContext<S, D>`, `persistForm`, the hooks'
|
|
45
|
+
explicit-`D` positions — so `Form<S, number>` and
|
|
46
|
+
`createFormContext<S, 26>()` are compile errors too, not silently
|
|
47
|
+
unbounded budgets.
|
|
48
|
+
`FieldPath<T, D>` itself (the compat surface for direct users, still
|
|
49
|
+
`D extends number`) NORMALIZES any unusable `D` before recursing: a
|
|
50
|
+
widened `number` (an options object built separately), an out-of-range
|
|
51
|
+
literal (26, -1 — outside the decrement table, where the decrement would
|
|
52
|
+
silently no-op), and a finite union (`9 | 12`, or `PathDepth` itself —
|
|
53
|
+
the decrement distributes over unions and would recurse to the union's
|
|
54
|
+
max) all fall back to the default depth instead of silently building the
|
|
55
|
+
enormous ~25-level union (an empirically verified hazard) or
|
|
56
|
+
hard-erroring TS2615 on recursive value types. The default is now the exported
|
|
57
|
+
`DefaultPathDepth` alias — one source of truth replacing the 22 scattered
|
|
58
|
+
`= 9` literals across the typed surfaces. NOT breaking for existing
|
|
59
|
+
code: `D` is appended LAST with a
|
|
60
|
+
default on every generic surface (the hooks' typed overloads
|
|
61
|
+
forward-reference it from the path parameter's constraint, and the
|
|
62
|
+
selector overloads stay at one type parameter), so existing references —
|
|
63
|
+
`Form<Schema>`, explicit instantiations like
|
|
64
|
+
`useFieldArray<typeof schema, "tags">`, `createFormHooks<S, "name">` —
|
|
65
|
+
keep compiling unchanged; type tests pin the arity, the option's
|
|
66
|
+
rejection cases, the non-literal fallback, and both directions of
|
|
67
|
+
`Form<S, 12>` / `Form<S, 9>` non-assignability.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- **Default typed-path depth raised from 7 to 9 segments**
|
|
72
|
+
(`FieldPath<T, D = 9>`). Real-world stores kept hitting the 7-segment cap
|
|
73
|
+
(the CLI's nested-array output reaches 7 with just three array levels,
|
|
74
|
+
and one more object wrapper fell off the union). Measured typecheck cost
|
|
75
|
+
of the wider default across this repo (library + tests + examples) is
|
|
76
|
+
noise-level. `Form<Schema>` references keep compiling unchanged — the new
|
|
77
|
+
`D` parameter defaults to 9 everywhere.
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- `resetField` on a path with no counterpart in `initialValues` (an appended
|
|
82
|
+
array row) no longer writes an explicit `undefined` into the values tree —
|
|
83
|
+
a hole that crashed row components and failed schema validation. The value
|
|
84
|
+
is left unchanged (with a console warning), and error/touched state under
|
|
85
|
+
the path still clears (2026-07 self-review #2).
|
|
86
|
+
- `useFieldArray` row ids stay glued to the right rows even when row values
|
|
87
|
+
are `Object.is`-equal (`remove(0)` on two blank rows handed the survivor
|
|
88
|
+
the removed row's id, resurrecting the wrong React subtree). The core now
|
|
89
|
+
records every array op's exact new→old index mapping in an internal
|
|
90
|
+
per-path op log, and id derivation replays that log — so the fix covers
|
|
91
|
+
hook-issued ops, IMPERATIVE ops (`form.arrayRemove(...)`), and multiple
|
|
92
|
+
ops per render batch alike. All hook instances on the same path share one
|
|
93
|
+
id state, so sibling `useFieldArray` hooks can never disagree on row ids.
|
|
94
|
+
Whole-array writes that bypass the ops (`setValue`, `restore`) still
|
|
95
|
+
reconcile by value as before, and replay keeps its exact progress when a
|
|
96
|
+
chain breaks mid-batch (an op followed by a `setValue` reconciles only the
|
|
97
|
+
remaining hop); consecutive log records compose when the ring fills, so
|
|
98
|
+
arbitrarily long op batches stay fully replayable. Exact replay applies to
|
|
99
|
+
forms created by `createForm`/`useForm` — schema-less custom
|
|
100
|
+
`FieldArrayFormApi` implementations reconcile by value (2026-07
|
|
101
|
+
self-review #3, completed by the deferred store-side follow-up).
|
|
102
|
+
- `flattenIssues` no longer crashes on an issue path of `"__proto__"`
|
|
103
|
+
(reachable via `ctx.addIssue` in a `superRefine`): the accumulator read now
|
|
104
|
+
checks own keys instead of walking the prototype chain (2026-07
|
|
105
|
+
self-review #4).
|
|
106
|
+
- `SelectField` renders its empty option whenever the bound `<select>`
|
|
107
|
+
displays `""` (the condition now derives from `selectProps`' own value
|
|
108
|
+
coercion, covering `undefined`, `null`, AND `""`) — an empty-string value
|
|
109
|
+
no longer displays as the first real option while the form holds `""`.
|
|
110
|
+
An options list that supplies its own `""`-valued entry wins: the
|
|
111
|
+
implicit blank option is suppressed so the explicit labelled one shows.
|
|
112
|
+
- `setError`/server errors keyed at `"__proto__"` are no longer silently
|
|
113
|
+
dropped from the merged `errors` map (`mergeErrorChannels` now checks own
|
|
114
|
+
keys, the same hardening `flattenIssues` got).
|
|
115
|
+
|
|
116
|
+
### Performance
|
|
117
|
+
|
|
118
|
+
- Async-requiring scopes are latched: once a sync `validate` /
|
|
119
|
+
`validateField` / `validateFields` pass discovers a scope needs async
|
|
120
|
+
parsing, later calls skip the doomed sync parse and go straight to the
|
|
121
|
+
async pass (previously every keystroke on an async schema paid a full sync
|
|
122
|
+
parse that always threw) (2026-07 self-review #5). One nuance: after the
|
|
123
|
+
latch, a value that fails an EARLIER sync check in the same scope also
|
|
124
|
+
returns `pending` (settling via the promise) where it previously settled
|
|
125
|
+
`invalid` synchronously — callers on async schemas must handle `pending`
|
|
126
|
+
regardless, since valid values always produced it. Paths that address no
|
|
127
|
+
slot (out-of-range indices) never consult the latch and still settle
|
|
128
|
+
synchronously.
|
|
129
|
+
|
|
130
|
+
### Internals
|
|
131
|
+
|
|
132
|
+
- The five bound field components share one `FieldShell` (label + control +
|
|
133
|
+
error line, with the checkbox's label-wrapping layout as a variant) and one
|
|
134
|
+
exported `hasFieldError` predicate — the a11y wiring (`useId`, `-error` id,
|
|
135
|
+
`aria-describedby`) now lives in a single place instead of five copies.
|
|
136
|
+
`hasFieldError` is exported for custom UI-kit adapters.
|
|
137
|
+
- `FieldPathArg` is now actually used by the `useField` / `useFieldArray`
|
|
138
|
+
overloads it describes (it was exported dead).
|
|
139
|
+
|
|
140
|
+
## formstand-cli 0.8.0 — 2026-07-28
|
|
141
|
+
|
|
142
|
+
### Changed
|
|
143
|
+
|
|
144
|
+
- **The typed-path budget follows the library default, now 9 segments**
|
|
145
|
+
(`FORMSTAND_PATH_DEPTH` 7 → 9, tracking `FieldPath<T, D = 9>` in
|
|
146
|
+
src/core/fieldPath.ts). Bindings whose full path is 8 or 9 segments —
|
|
147
|
+
degraded to TODOs by the budget fix below — bind real controls again;
|
|
148
|
+
10+-segment paths keep the TODO + stderr warning. In-budget output is
|
|
149
|
+
byte-identical (the three-level nested-array snapshot is unchanged). The
|
|
150
|
+
library's new per-form `pathDepth` option can widen a form past 9; a
|
|
151
|
+
future `--path-depth` flag would pair with it (not implemented — bind
|
|
152
|
+
those paths by hand for now).
|
|
153
|
+
- **The default walker nesting budget is now DERIVED from the path budget**
|
|
154
|
+
(`DEFAULT_MAX_DEPTH = FORMSTAND_PATH_DEPTH + 2` = 11, up from the
|
|
155
|
+
standalone 10; the constant moved to a shared `depth` module both walkers
|
|
156
|
+
and emitters import). The walker must reach one level PAST the path
|
|
157
|
+
budget so a leaf of up to 10 segments (path budget + 1) at default flags
|
|
158
|
+
degrades via the PATH budget — a real, correctly typed subtree with a
|
|
159
|
+
depth TODO — rather than via walker truncation, whose wrong-kind string
|
|
160
|
+
fallback poisoned `initialValues` and failed the consumer's typecheck (a
|
|
161
|
+
nullable number at level 10 blanked to `""` instead of `null`). Leaves
|
|
162
|
+
DEEPER than that (11+ segments at default flags) still truncate to the
|
|
163
|
+
string-kind stand-in; the generated file now always compiles anyway (the
|
|
164
|
+
truncated leaf forces the `as unknown as` initialValues cast) and each
|
|
165
|
+
truncated path gets its own stderr warning — see Fixed below. The
|
|
166
|
+
DeepBoundaryForm playground demo no longer needs its `--max-depth 11`
|
|
167
|
+
override, and its regenerated output is byte-identical. `--max-depth`
|
|
168
|
+
remains as an override and the recursion backstop.
|
|
169
|
+
- **One boundary predicate for every depth decision.** The ~38 hand-written
|
|
170
|
+
`>`/`>=` comparisons against `FORMSTAND_PATH_DEPTH` across the
|
|
171
|
+
single-file and module emitters are replaced by a single exported
|
|
172
|
+
`overDepthBudget(spec, segments)` — spec-aware (it picks the
|
|
173
|
+
at-the-budget vs needs-headroom boundary from the spec's kind) and used
|
|
174
|
+
at every depth decision in both layouts and the CLI warnings, so they
|
|
175
|
+
can no longer drift on where degradation starts. (A second predicate for
|
|
176
|
+
spec-less row paths, `pastRowBudget`, briefly existed but had no call
|
|
177
|
+
sites and disagreed with `overDepthBudget`'s boundary — it has been
|
|
178
|
+
deleted.) `depthTodoLine` is likewise the one production of the TODO
|
|
179
|
+
comment shared by both emitters.
|
|
180
|
+
|
|
181
|
+
### Fixed
|
|
182
|
+
|
|
183
|
+
- **Emitted bindings now respect formstand's typed `FieldPath` budget**
|
|
184
|
+
(7 segments when this fix landed; now 9 — see Changed above) (found by
|
|
185
|
+
dogfooding: the DeepBoundaryForm playground demo). The
|
|
186
|
+
walkers happily emitted 9-segment paths (`l1.l2....l8.leaf`) that fail
|
|
187
|
+
typecheck in every consumer (TS2820, `FieldPath<T, D = 7>` in
|
|
188
|
+
`src/core/fieldPath.ts`). The emitters now count segments of the FULL bound
|
|
189
|
+
path — dots split, a template hole (`${index}`, `${p0}`) one segment each,
|
|
190
|
+
so an array level spends TWO — and a binding that would exceed the budget
|
|
191
|
+
degrades exactly like other unsupported shapes: compiling output with a
|
|
192
|
+
`{/* TODO: path ... exceeds formstand's typed FieldPath depth (7); bind by
|
|
193
|
+
hand */}` comment plus a stderr warning per degraded path. Container
|
|
194
|
+
recursion stops at the boundary (an object whose own path is at 7 segments
|
|
195
|
+
can only produce over-budget children), the subtree is still materialized
|
|
196
|
+
in the zod schema and `initialValues`, and paths exactly AT the limit —
|
|
197
|
+
including 7-segment nested-array templates like
|
|
198
|
+
`` `teams.${p0}.members.${p1}.phones.${index}` `` — keep binding
|
|
199
|
+
byte-identically to before. Covers both layouts, all three UIs, and
|
|
200
|
+
recursive nested-array row extraction; this is deliberately NOT a walker
|
|
201
|
+
depth clamp (walk-depth is the wrong proxy for segments — a coarse cap
|
|
202
|
+
would have broken the working 3-level nested-array output).
|
|
203
|
+
Follow-up alignment: an at-budget array whose ITEM is non-scalar (an
|
|
204
|
+
array-of-arrays, or array rows holding a tuple/union) now emits the DEPTH
|
|
205
|
+
todo at the row site instead of the generic extract-a-row advice — which
|
|
206
|
+
was unachievable there, since the row path itself is past the budget —
|
|
207
|
+
and `overBudgetFieldPaths` (the stderr warning list) mirrors the emitted
|
|
208
|
+
TODOs exactly, one entry per TODO site, per-FIELD for an array's object
|
|
209
|
+
rows (the emitters degrade those field by field). The mirror is
|
|
210
|
+
LAYOUT-AWARE (see Fixed below): the walk takes the emitting layout's
|
|
211
|
+
degradation frontier, since the two layouts stop descending at different
|
|
212
|
+
places.
|
|
213
|
+
- **`.default()` / `.prefault()` values now land in the generated
|
|
214
|
+
`initialValues`** instead of being ignored (`defaultedNumber: z.number()
|
|
215
|
+
.default(42)` emitted `undefined`, contradicting the README). `fromZod`
|
|
216
|
+
captures the value off the zod def into a new optional `defaultValue` on
|
|
217
|
+
the IR's `SharedSpecProps`, and `emitInitialValues` seeds the field with
|
|
218
|
+
it whenever it is a JSON-serializable primitive matching the field kind
|
|
219
|
+
(string / finite number / boolean / declared enum option). Dates and
|
|
220
|
+
object/array defaults keep the blank behavior. A seeded default satisfies
|
|
221
|
+
`z.input`, so it also counts toward the
|
|
222
|
+
checked-annotation-instead-of-cast decision. Type mode is unchanged — TS
|
|
223
|
+
types can't carry runtime defaults, so `fromType` never sets
|
|
224
|
+
`defaultValue`. The README now states the real rule.
|
|
225
|
+
Capture hardening on review: zod v4's `def.defaultValue` is a GETTER that
|
|
226
|
+
already resolves the user's factory, so the walk only READS it — a
|
|
227
|
+
function-valued result (an older shape storing the factory itself, or a
|
|
228
|
+
factory returning a function) is treated as not capturable rather than
|
|
229
|
+
invoked, which previously executed arbitrary user code at generation
|
|
230
|
+
time (a fixture spy proves the resolved function is never called). The
|
|
231
|
+
getter is read TWICE and the value captured only when both reads
|
|
232
|
+
`Object.is`-agree on a JSON primitive, so a non-deterministic factory
|
|
233
|
+
(`Date.now`, `randomUUID`) can never bake a run-dependent literal into
|
|
234
|
+
byte-deterministic output. And a default is NEVER seeded into a
|
|
235
|
+
todo-fallback spec (`z.custom<T>().default(...)` walks as a string
|
|
236
|
+
stand-in whose kind lies about `T`): seeding there broke the generated
|
|
237
|
+
file's checked `initialValues` annotation — the field keeps its blank
|
|
238
|
+
behavior instead.
|
|
239
|
+
- **Walker truncation can no longer ship a non-compiling file, and is never
|
|
240
|
+
silent.** A leaf past the walker nesting budget (11+ segments at default
|
|
241
|
+
flags) degrades to a string-kind stand-in BEFORE its wrappers unwrap, so
|
|
242
|
+
its kind and flags can be wrong (`z.number().nullable()` at 11 segments
|
|
243
|
+
blanked to `""` in a `number | null` slot) — and the checked
|
|
244
|
+
`initialValues` annotation shipped that as a compile error in the
|
|
245
|
+
consumer's tsc. `blankNeedsCast` now treats a REQUIRED todo-bearing leaf
|
|
246
|
+
(truncated specs are always required-flagged; a required `z.custom`
|
|
247
|
+
fallback hits the same rule) as not input-satisfying, forcing the
|
|
248
|
+
`as unknown as` cast so the generated file always compiles — in both
|
|
249
|
+
layouts, which share the one predicate. Optional/nullable fallbacks keep
|
|
250
|
+
the checked annotation (their `undefined`/`null` blanks genuinely satisfy
|
|
251
|
+
`z.input` — those flags only exist when real wrappers unwrapped). And the
|
|
252
|
+
truncation is mirrored on stderr: the walkers stamp a recognizable marker
|
|
253
|
+
todo (`NESTING_LIMIT_TODO`), `truncatedFieldPaths` collects the truncated
|
|
254
|
+
paths, and the CLI prints
|
|
255
|
+
`warning: path "…" exceeds the walker nesting budget (11); field degraded
|
|
256
|
+
to a placeholder — raise --max-depth or bind by hand` per path.
|
|
257
|
+
- **Depth warnings no longer promise TODOs the chosen layout never emits.**
|
|
258
|
+
`overBudgetFieldPaths` walked one fixed shape while the two layouts stop
|
|
259
|
+
descending at different frontiers: the single-file layout does not descend
|
|
260
|
+
into array-of-array items (generic extract-a-row TODO, no depth TODO
|
|
261
|
+
inside), and the module layout does not recurse into OBJECT fields of an
|
|
262
|
+
array row (generic bind-by-hand TODO) — so for those shapes the stderr
|
|
263
|
+
warnings claimed depth TODOs that weren't in the file (always
|
|
264
|
+
over-promising, never under-reporting). The walk now takes the emitting
|
|
265
|
+
layout's `DepthWarningFrontier` (`depthWarningFrontier("single" |
|
|
266
|
+
"module")`; the single-file frontier is the default, preserving existing
|
|
267
|
+
programmatic behavior), and `warnDegradedBindings` passes the CLI's
|
|
268
|
+
`--layout`. Tests pin warnings-per-TODO equality for both divergent
|
|
269
|
+
shapes in both layouts.
|
|
270
|
+
- **Refused `.default()` captures now warn instead of degrading silently.**
|
|
271
|
+
Every mode that leaves a declared default unseeded is mirrored on stderr:
|
|
272
|
+
the capture guard's refusals (function-valued resolved default,
|
|
273
|
+
non-deterministic two-read disagreement, throwing getter) are recorded on
|
|
274
|
+
the IR as `droppedDefault`, and emit-time refusals (todo-fallback specs,
|
|
275
|
+
kind-mismatched or non-finite values, undeclared enum options, dates,
|
|
276
|
+
container defaults) are detected off the captured `defaultValue` — the
|
|
277
|
+
CLI prints `warning: field "…" has a .default() the CLI could not capture
|
|
278
|
+
(non-primitive, non-deterministic, or degraded field); it starts blank —
|
|
279
|
+
seed it by hand`, one per field. Fields with no default never warn.
|
|
280
|
+
|
|
281
|
+
## formstand-cli 0.7.0 — 2026-07-12
|
|
282
|
+
|
|
283
|
+
### Added
|
|
284
|
+
|
|
285
|
+
- **Tuple support** (`z.tuple([...])` and `[A, B]` in type mode). Tuples were
|
|
286
|
+
degrading to a single string field with a TODO; they now generate fixed
|
|
287
|
+
positional controls, each bound at a static numeric-index path (`coord.0`,
|
|
288
|
+
`coord.1`), in both the single-file and module layouts and all three UIs.
|
|
289
|
+
Scalar elements render a real control; a non-scalar element (object / array /
|
|
290
|
+
union / nested tuple) or a variadic rest degrades to a TODO at just that
|
|
291
|
+
position, so the fixed scalar positions still generate. Generated output is
|
|
292
|
+
typechecked against the real library across all backends.
|
|
293
|
+
- **Recursive nested-array extraction in both layouts.** An array nested
|
|
294
|
+
inside an array row used to generate one level (module) or a TODO
|
|
295
|
+
(single-file). It now recurses to arbitrary depth (bounded by `--max-depth`)
|
|
296
|
+
in **both** layouts: `teams[] › members[] › phones[]` generates a full
|
|
297
|
+
component tree, threading each enclosing row's index down as a `p0`, `p1`, …
|
|
298
|
+
prop. In `--layout module` each level is a `Row`/`Rows` pair; in single-file
|
|
299
|
+
each is a child `{Stem}Rows` component with a typed `form` prop, emitted
|
|
300
|
+
above the main component. Array-of-arrays sections extract an inner row
|
|
301
|
+
component too (module). A non-array shape inside a row (nested object /
|
|
302
|
+
union / tuple) still degrades to a TODO. Every generated tree is typechecked
|
|
303
|
+
against the real library across all three UIs.
|
|
304
|
+
- `--max-depth <n>` flag (and a `maxDepth` argument on `fromZod` / `fromType`)
|
|
305
|
+
for the schema/type nesting budget — the number of levels the walkers
|
|
306
|
+
descend before a level degrades to a string + TODO. The default rose from 6
|
|
307
|
+
to **10**, so deeper schemas generate fully. Recursion is still caught
|
|
308
|
+
directly by a seen-set (a self-referential schema → TODO), with the depth
|
|
309
|
+
budget as the backstop for getter-minted schemas; the IR is always finite.
|
|
310
|
+
|
|
311
|
+
### Fixed
|
|
312
|
+
|
|
313
|
+
- An array whose item is itself non-scalar (an array-of-arrays, or an array of
|
|
314
|
+
tuples/unions) no longer emits an empty `{/* unreachable */}` row in the
|
|
315
|
+
single-file layout — it now emits a clear TODO to extract a row component,
|
|
316
|
+
matching the documented nested-array behavior.
|
|
317
|
+
|
|
318
|
+
### Docs & examples (no package changes)
|
|
319
|
+
|
|
320
|
+
- Corrected the CLI README / code-generation guide, which claimed several
|
|
321
|
+
already-resolved limitations: `date` fields have emitted real `DateField` /
|
|
322
|
+
date-input bindings since 0.9 (not a text-input TODO), and the module layout
|
|
323
|
+
extracts one real nested-array row level (not a blanket TODO).
|
|
324
|
+
- Schema builder: **paste a zod schema**. An **Import code…** modal takes a
|
|
325
|
+
TypeScript type _or_ a `z.object(...)` schema — by paste or by picking a
|
|
326
|
+
`.ts` file — and generates the form from it. The zod source is evaluated in
|
|
327
|
+
the browser against the bundled zod (the REPL trust model: your own code, in
|
|
328
|
+
your own tab, nothing fetched or sent) and walked by the real `fromZod`, so
|
|
329
|
+
it shares the paste-a-type mode's exact IR → emitters path. Switching the
|
|
330
|
+
dialect swaps in a worked sample while the field is untouched.
|
|
331
|
+
|
|
332
|
+
## formstand-cli 0.6.2 — 2026-07-10
|
|
333
|
+
|
|
334
|
+
### Added
|
|
335
|
+
|
|
336
|
+
- Browser-safe programmatic API via the new `formstand-cli/codegen` subpath.
|
|
337
|
+
Everything downstream of the IR — `fromZod`, every emitter
|
|
338
|
+
(`emitPlainForm` / `emitMuiForm` / `emitShadcnForm` / `emitTemplateForm` /
|
|
339
|
+
`emitModuleForm`, `emitZodSchema` / `emitInitialValues`), `joinModuleFiles`,
|
|
340
|
+
`defineTemplate`, `labelFromName` and the casing helpers — is a pure string
|
|
341
|
+
builder with no `fs`/`path` and no TypeScript compiler, so it bundles for the
|
|
342
|
+
browser (the docs Schema-builder generates forms client-side through it). The
|
|
343
|
+
main `formstand-cli` entry still re-exports all of it and adds the
|
|
344
|
+
Node-oriented `fromType` / `defineConfig`.
|
|
345
|
+
|
|
346
|
+
## formstand-cli 0.6.1 — 2026-07-10
|
|
347
|
+
|
|
348
|
+
### Added
|
|
349
|
+
|
|
350
|
+
- Custom templates (`--template <file>` or `template:` in the config): a
|
|
351
|
+
`defineTemplate({ name, imports, leaf })` module overrides the per-kind
|
|
352
|
+
field rendering for a UI kit formstand doesn't ship (Mantine, Chakra,
|
|
353
|
+
in-house), inheriting the generated form's scaffold — sections, arrays,
|
|
354
|
+
discriminated unions, submit. Each leaf renderer gets a context of
|
|
355
|
+
JS-expression strings (`bind`, `field`, `label`, `options`), and the same
|
|
356
|
+
renderer works both in the generated wrapper component and inside union
|
|
357
|
+
variant blocks. Unlisted kinds fall back to plain. `--layout single`
|
|
358
|
+
only for now; overrides `--ui`.
|
|
359
|
+
|
|
4
360
|
|
|
5
361
|
## 0.9.0 — 2026-07-10
|
|
6
362
|
|
package/README.md
CHANGED
|
@@ -85,10 +85,10 @@ const form = createForm(schema, {
|
|
|
85
85
|
| `setError(path, errors)` / `setErrors(map)` / `clearErrors(path?)` | the app-owned **server error channel** (`state.serverErrors`) — validation never touches it; `setError` accepts a single string or an array; `clearErrors(path)` clears both channels at the path and its descendants (`clearErrors("")` clears just the root entry; `clearErrors()` clears everything); `setErrors` replaces the whole server channel |
|
|
86
86
|
| `setMode` / `setReValidateMode` | switch modes at runtime |
|
|
87
87
|
| `reset(nextInitial?, options?)` | reset to initial; optional partial overrides (shallow-merged for record roots, replaced wholesale otherwise) and `{ keepErrors, keepTouched, keepSubmitCount }` (no `keepDirty` — dirtiness derives from values vs initial, and reset makes them equal) |
|
|
88
|
-
| `resetField(path)` | reset one field to its initial value, clearing its (and descendants') error/touched state (dirtiness clears by definition — the value now equals initial) |
|
|
88
|
+
| `resetField(path)` | reset one field to its initial value, clearing its (and descendants') error/touched state (dirtiness clears by definition — the value now equals initial); a path with no initial counterpart (an appended array row) keeps its value and only clears field state |
|
|
89
89
|
| `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors` and in-flight validation flags (`isValidating`/`isValidatingForm` — the rebase disowns in-flight passes), but **preserves** interaction state (`touched`, `submitCount`, `isSubmitting`, `mode`). Use `reset()` for a full wipe |
|
|
90
90
|
| `updateState(updater)` | atomic multi-field patch; `errors` is derived from `schemaErrors`/`serverErrors`, so patch the channels — the patch type omits `errors` entirely, and a plain-JS `errors` patch is warned about and ignored |
|
|
91
|
-
| `validate()` / `validateField(path)` / `validateFields(paths)` | sync validation; on an async schema
|
|
91
|
+
| `validate()` / `validateField(path)` / `validateFields(paths)` | sync validation; on an async schema all three transparently start the async pass instead and return `{ kind: "pending", promise }` |
|
|
92
92
|
| `validateAsync()` / `validateFieldAsync(path)` / `validateFieldsAsync(paths)` | async; supports `async .refine` |
|
|
93
93
|
| `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched), `{ kind: "skipped" }` when another submit is in flight, or `{ kind: "error", error }` when `onValid` throws/rejects — submit resolves instead of rejecting, so `handleSubmit` never produces an unhandled rejection from an `onValid` throw |
|
|
94
94
|
| `handleSubmit(onValid, onInvalid?)(event?)` | event handler wrapper that calls `preventDefault` |
|
|
@@ -108,7 +108,7 @@ const form = createForm(schema, {
|
|
|
108
108
|
- `mode: "all"`: validate on every change and blur.
|
|
109
109
|
- `reValidateMode` (default `"onChange"`) kicks in once `submitCount > 0`.
|
|
110
110
|
|
|
111
|
-
For schemas with `async .refine`, sync `validate()` / `validateField()` / `validateFields()`
|
|
111
|
+
For schemas with `async .refine`, sync `validate()` / `validateField()` / `validateFields()` don't throw: they detect the async requirement, start the async pass themselves, and hand you the in-flight work as `{ kind: "pending", promise }` (never a bare truthy `Promise` — a boolean gate can't silently pass). The requirement is also remembered per scope, so later sync calls skip straight to the async pass instead of re-paying a doomed sync parse per keystroke. If the *field being validated* is itself sync, `validateField` still settles synchronously even when other fields carry async refines — field validation parses just that field's subschema when it can (see below).
|
|
112
112
|
|
|
113
113
|
## React hooks
|
|
114
114
|
|
|
@@ -165,12 +165,13 @@ users.insert(i, item);
|
|
|
165
165
|
users.error; // array-level error (e.g. min(1))
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
-
`fields` IDs are
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
(
|
|
173
|
-
|
|
168
|
+
`fields` IDs are shared per `(form, path)` — every hook on the same array sees
|
|
169
|
+
the same ids. Array ops (hook-issued or imperative `form.arrayMove` etc.)
|
|
170
|
+
record their exact index mapping and id derivation replays it, so even rows
|
|
171
|
+
with equal values keep the right ids through ops; whole-array writes
|
|
172
|
+
(`setValue`, `restore`) reconcile by item identity with a positional fallback,
|
|
173
|
+
so editing a field keeps its row's `id` (the row updates instead of
|
|
174
|
+
remounting). A genuinely new item gets a fresh `id`.
|
|
174
175
|
|
|
175
176
|
### `useFormSelector(form, selector)` / `useFormSelectorShallow(form, selector)`
|
|
176
177
|
|
|
@@ -347,9 +348,12 @@ decided by the **existing container**: arrays take numeric segments as indices,
|
|
|
347
348
|
plain objects take any segment as a string key — so a `z.record` keyed `"0"`
|
|
348
349
|
reads and writes the record key instead of silently becoming an array. Only
|
|
349
350
|
when the container doesn't exist yet does the segment type pick what's created
|
|
350
|
-
(numeric → array, otherwise object).
|
|
351
|
-
not addressable
|
|
352
|
-
index must not allocate gigabytes)
|
|
351
|
+
(numeric → array, otherwise object). Three limitations: keys containing `.`
|
|
352
|
+
are not addressable; array writes beyond index 100 000 are refused (a typo'd
|
|
353
|
+
index must not allocate gigabytes); and the typed `FieldPath` union stops at
|
|
354
|
+
9 segments by default — the runtime walks any depth, and `createForm`'s
|
|
355
|
+
type-level `pathDepth` option widens the union per form (a deliberate
|
|
356
|
+
compile-time trade; see the [typed paths guide](https://scrumrot.github.io/formstand/guide/typed-paths)).
|
|
353
357
|
|
|
354
358
|
## Subscriptions (non-React)
|
|
355
359
|
|
|
@@ -408,7 +412,7 @@ Note `submit` still proceeds when the schema is valid even if server errors are
|
|
|
408
412
|
|
|
409
413
|
## Field arrays with nested arrays
|
|
410
414
|
|
|
411
|
-
Both work — `arrayPush("users.0.tags", tag)` mutates the inner array; outer reorders correctly re-key meta for all nested paths. `
|
|
415
|
+
Both work — `arrayPush("users.0.tags", tag)` mutates the inner array; outer reorders correctly re-key meta for all nested paths. Inner row ids live per `(form, path)` and reconcile by value when the outer index re-seats a different item at the path — pass a `key` from the outer `fields` so each row's whole subtree moves with its item.
|
|
412
416
|
|
|
413
417
|
## Optimistic UI
|
|
414
418
|
|