formstand 0.21.0 → 0.22.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 CHANGED
@@ -1,5 +1,435 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.1 — 2026-10-22
4
+
5
+ ### Fixed
6
+
7
+ - Nested `useFieldArray` row ids survive an outer reorder. Inner ids are
8
+ stored per path (`teams.0.members`), so an outer `move`, `swap`, `remove`,
9
+ `insert`, or whole-array write left the entry at `teams.2.members` meeting
10
+ team A's members with team C's history: the same-index fallback handed the
11
+ first rows the other team's ids, the rest reminted, and React remounted
12
+ inputs a user may have been typing in. The outer hook now re-seats every
13
+ descendant path's id entry to follow its row (by id, so an exact op
14
+ mapping and a by-value reconciliation re-seat the same way), and drops the
15
+ entries of a removed row's slot. Inner-only trees that reorder the outer
16
+ array imperatively keep the value fallback.
17
+
18
+ ### Docs
19
+
20
+ - Field arrays: the array-op revalidation sentence now matches the gate.
21
+ Ops run under the change trigger, so in the default `onBlur` mode (and
22
+ `onSubmit`) they stay silent until the first failed submit switches to
23
+ `reValidateMode`; `onTouched` needs the array path itself touched;
24
+ `onChange` and `all` revalidate at once. A "Render cost" note says the
25
+ hook re-renders its caller on every keystroke in any row and how to
26
+ keep rows cheap.
27
+ - Persistence: a loaded draft is adopted without validation, so a draft
28
+ that fails the schema reads valid until something validates;
29
+ `validateOnMount` runs before `persistForm` applies the draft and does
30
+ not cover it, so call `form.validate()` after the load.
31
+ - `useFormValues` re-renders on every keystroke anywhere in the form; keep
32
+ it out of per-row components.
33
+ - A `"use server"` action must parse `data` with the schema on the server
34
+ on every path, not only the no-JS one.
35
+ - Masked inputs: `"empty"` writes `emptyValue` for clearable and non-string
36
+ fields and `""` for a required string, as the 0.22.0 binding does.
37
+ - Components: the `SelectField` props row names the real option type, and
38
+ the guide states the by-value `path` rule (`TextField` on a `z.number()`
39
+ is a compile error) that the API page already had.
40
+ - CLI: the schema module and `formstand.config.ts` are executed through
41
+ jiti, so point the CLI only at files you would run with `node`; the
42
+ overview names all three inputs.
43
+ - Snippets import every name they use (`Form`, `FormStateApi`,
44
+ `createForm`, `textInputProps`); the `onSubmit` snippet is in a tsx
45
+ fence; `fillFields` is documented as typed per key.
46
+ - A "Security notes" section on validation.md collects server
47
+ re-validation, untrusted drafts, and the CLI executing modules, with
48
+ links to the sections that hold the detail.
49
+ - Roadmap: `--path-depth` moves to the shipped list. The remaining em
50
+ dashes in the roadmap and the VitePress theme comments are reworded.
51
+ - Playground: every demo is a lazy chunk. The main bundle drops from
52
+ 2.95 MB (779 kB gzip) to 381 kB (116 kB gzip) and the main stylesheet
53
+ from 601 kB to 34 kB; each tab's component, kit and stylesheet load on
54
+ first visit, and the View code panel fetches source text on demand
55
+ behind a static file tree. Mantine's sheet loads as its `@layer` variant
56
+ so the shell's unlayered rules keep winning regardless of load order.
57
+ - Playground: the Conditional demo is a `z.discriminatedUnion` bound with
58
+ `useField` for the discriminant and `useVariantField` for the branch
59
+ fields, and the payload carries only the active branch (the flat version
60
+ submitted the hidden card number alongside the bank fields).
61
+ - Playground: the hand-rolled demos wire each error line to its input
62
+ (`aria-describedby`, `aria-invalid`, `aria-live`) through one shared
63
+ helper and focus the first errored control after a failed submit; the
64
+ View state / View code toggles expose `aria-pressed`; the MUI Invoice,
65
+ shadcn Team and shadcn Onboarding contact rows label their
66
+ placeholder-only cells.
67
+ - Playground: the Autosave status reads "Draft restored" after a restore
68
+ instead of "Unsaved changes"; the Schema builder's Zod paste tab says
69
+ that pasted code runs in the page and its View code list includes
70
+ `ImportModal.tsx` and `parseZod.ts`; the Perf demo's inputs follow the
71
+ theme; the light theme's eyebrow (6.76:1) and muted text (4.88:1) clear
72
+ the AA floor; the theme preference is written only when the user
73
+ toggles, so a docs visitor's "auto" survives opening the playground.
74
+ - Playground: the Onboarding module's field files, its emergency-contact
75
+ rows, and the CLI command builder wire each error line to its control
76
+ through the shared `fieldA11y` helper, like the other hand-rolled
77
+ demos since 0.22.0; the contacts array's own error line is a live
78
+ region. The command builder's file set ships the helper, so it opens
79
+ in StackBlitz.
80
+ - Playground: the generated kitchen sink's input schema carries human
81
+ messages on every check, so an empty submit no longer shows zod's raw
82
+ messages, and the regenerated plain output carries the CLI's new aria
83
+ wiring. `examples/src` is now linted with the library's rule set.
84
+
85
+ ## formstand-cli 0.24.0 — 2026-10-20
86
+
87
+ ### Added
88
+
89
+ - `--no-live` and `--no-form-prop`: an explicit flag turns the mode off for
90
+ one run when the config file sets `live: true` / `formProp: true`. Before,
91
+ nothing could override a configured mode (`--no-live` was an unknown
92
+ flag).
93
+ - JSON Schema mode carries `pattern` and `multipleOf` into the generated
94
+ validator, as `.regex(new RegExp("..."))` and `.multipleOf(n)`. A pattern
95
+ JavaScript's `RegExp` refuses degrades to a TODO instead of a module that
96
+ throws on load. Both, and any `format` outside email/uri/uuid/date, are
97
+ recorded as unmodeled checks, so the generated spec skips its blind round
98
+ trip over them and `--tests` warns on stderr; they were dropped silently
99
+ before. The IR gains `StringChecks.regex` and `NumberChecks.multipleOf`.
100
+ - Type mode walks intersections (`A & B`, the merged member set, later wins
101
+ on a collision, a TODO where two members disagree on a key) and
102
+ discriminated unions of object types sharing a string-literal key, to
103
+ the same IR union the zod and JSON Schema walks produce. A union of
104
+ numeric literals names the string-only enum as its reason instead of
105
+ "unsupported union type".
106
+
107
+ ### Fixed
108
+
109
+ - Type mode had no recursion guard: `type Tree = { value: string; left?:
110
+ Tree; right?: Tree }` expanded to the depth budget (6142 IR nodes, a
111
+ 188 KB file, 518 module files). The walk tracks the declared types on the
112
+ current path and cuts a repeat with a "recursive type" TODO, the way the
113
+ zod walker treats its getter idiom. A member typed as the global
114
+ `Function` (no call signature of its own) walked Function.prototype to
115
+ the budget; it now degrades like a method.
116
+ - `--type someConst` (a value, not a type) generated an "any" TODO form; it
117
+ is now an error naming the symbol kind. An index signature beside named
118
+ members (`{ a: string; [k: string]: string }`) was dropped without a
119
+ trace; the members bind and a TODO sits on the section, mirrored on
120
+ stderr and above the generated schema.
121
+ - OpenAPI 3.0 idioms in JSON Schema mode: `{ $ref, nullable: true }` was
122
+ not nullable (the ref walk returned before the flag applied);
123
+ `{ allOf: [{ $ref }], nullable: true }` lost the outer keywords in the
124
+ merge; `allOf: [{ $ref: StringEnum }]` (the 3.0 way to annotate a ref)
125
+ was "allOf with non-object branches"; `enum: ["a", "b", null]` with
126
+ `nullable: true` (3.0) or `type: ["string", "null"]` (3.1) was a
127
+ "non-string enum" TODO; an `allOf` branch that is itself an `allOf`, and
128
+ `$ref` chains, degraded; `#/$defs/My%20Type` was not percent-decoded.
129
+ All six now walk to the expected IR, and a self-referring `allOf` branch
130
+ is named in its TODO.
131
+ - Draft-04 `exclusiveMinimum: true` / `exclusiveMaximum: true` beside
132
+ `minimum` / `maximum` silently read as an inclusive bound; the flag now
133
+ makes the bound exclusive.
134
+ - An empty enum list (`enum: []` in a document, `z.enum([])` in a schema,
135
+ which zod constructs and then rejects every value for) emitted a select
136
+ with no option; it degrades to a TODO in both walkers.
137
+ - `--out some/dir/` (a trailing separator, or an existing directory) in the
138
+ single-file layout wrote a file literally named after the folder, or
139
+ refused to "overwrite" it. It is refused with an error that says to name
140
+ a `.tsx` file or use `--layout module`.
141
+ - `--name profileForm` passed validation and the generated spec rendered
142
+ `<profileForm />`, which JSX reads as a DOM tag; the name must now start
143
+ with an uppercase letter.
144
+ - `--force` over a module folder left the previous run's files in place
145
+ silently (a removed section's `sections/GoneSection.tsx` survived and
146
+ still compiled). Files that carry the generator header on their first
147
+ line and are not in the new set are now named on stderr as
148
+ `warning: stale: <path>`, so the leftover is visible. They are not
149
+ deleted: the header says the file is yours to edit, and a folder can hold
150
+ a single-file component from another run under the same header, so
151
+ removal is left to you.
152
+ - `--schema constructor` (or `toString`, `__proto__`) resolved a prototype
153
+ member of the component store and generated a degraded form named after
154
+ it; lookups and pointer segments use own keys only, so it reports "no
155
+ schema named".
156
+ - A string-literal export name (`export { inner as "my schema" }`) emitted
157
+ `import { my schema } from ...`; it is refused with the rule, for an
158
+ explicit `--export` and for the sole-export fallback.
159
+ - The generated spec's blank-submit case picked its control among the
160
+ blank-invalid fields only, so a required "Age" beside an optional "Age"
161
+ in another section made `getByLabelText` throw. Uniqueness is now judged
162
+ against every label the blank render shows, for the label fills too.
163
+ - **The plain backend's error lines are described to their controls.** The
164
+ in-file `AutocompleteField`, `TextareaField`, `PlaceholderField` and
165
+ `EnumSelectField`, the union variant leaves, and the module layout's field
166
+ files and array rows emitted a bare `<p role="alert">` with no id and a
167
+ control with no `aria-describedby`, so assistive tech heard the field but
168
+ never its message. The error line now owns `${field.path}-error` and the
169
+ control points at it while the error shows (the same runtime derivation
170
+ the autocomplete datalist id already used, unique per array row with no
171
+ `useId`); `aria-invalid` keeps coming from the prop builders. The kit
172
+ backends were already wired through their own components.
173
+
174
+ ## 0.22.0 — 2026-10-19
175
+
176
+ ### Fixed
177
+
178
+ - **`useFieldArray` no longer remounts the row you are typing in when rows
179
+ are identical primitives.** With `["", ""]`, a keystroke into row 0 made
180
+ the value-matching pass hand row 0's id to row 1 and mint a new id for
181
+ row 0, so React remounted the focused input. A same-index identity pass
182
+ now runs first, so an edited row keeps its id and duplicates keep theirs.
183
+ - **`debounceMs` no longer suppresses blur validation on an untouched
184
+ form.** The reset-protection guard read "values is initialValues" as
185
+ pristine, which is also every form nobody has typed in yet, so a
186
+ required field tabbed through showed nothing. A rebase is now detected
187
+ as the values reference moving onto initialValues since scheduling,
188
+ which only reset and adoptValues produce.
189
+ - **`useFormSteps.next()` and `goTo()` no longer apply a stale step** when
190
+ the user presses `back()` while the async step gate is in flight; the
191
+ user's newer position wins and `goTo` returns it.
192
+ - **`useMaskedInput` follows the text binding's clearing rule.** Clearing a
193
+ required string writes `""` (clean against a `""` initial, the author's
194
+ message) instead of `undefined`; optional, nullable, and non-string
195
+ fields still clear to `emptyValue`.
196
+ - **`clearable` and `emptyValue` read the schema through wrappers and
197
+ unions.** A leaf under an optional object and a field inside a
198
+ discriminated-union variant (`useVariantField`) now report their own
199
+ schema's blank instead of the runtime heuristic.
200
+ - **`persistForm` refuses a `null` top-level draft** (an unversioned
201
+ `"null"` or a versioned wrapper holding `values: null`) instead of
202
+ adopting it as the whole values object. Nested nulls stay legal.
203
+ - **`handleSubmit` and the action bridge never reject when `process` is
204
+ undefined.** The dev-only "nothing observed the error" warning read
205
+ `process.env.NODE_ENV` unguarded, so in a runtime without a `process`
206
+ global the never-rejects contract broke exactly on the failure path. One
207
+ shared `isDevBuild()` now guards all three sites.
208
+ - **Array ops reject tuple paths at the type level.** `arrayPush("pair", x)`
209
+ on a `z.tuple` no longer compiles; it could only ever grow a fixed-arity
210
+ value the schema rejects.
211
+ - **`flattenIssues` and the plural error writes scale linearly.** The
212
+ issue grouping rebuilt its accumulator with a spread per issue, so
213
+ `validate()` on 3000 empty rows spent 1.3 s grouping what zod parsed in
214
+ 7 ms; it now groups once through a Map. The same per-entry spread sat in
215
+ `validateFieldsAsync`'s in-flight flags and in `addErrors`, which both
216
+ now run one pass over the map.
217
+ - **Error keys named after `Object.prototype` members no longer crash or
218
+ leak.** `setError("constructor", "x")`, `addErrors({ constructor: [...] })`,
219
+ and `validate()` on a schema with a `constructor` field threw inside the
220
+ store write (a bare `prev[k]` read `Object` as the previous error list);
221
+ `getFieldState("toString").touched` and the `useField` / `useFieldArray`
222
+ selectors read prototype functions as field state. Every runtime-keyed
223
+ read of `errors`, `touched`, and `isValidating` is now an own-key read.
224
+ - **An async field verdict survives an unrelated field's change.** A
225
+ subschema `validateFieldAsync("username")` dropped its result, and
226
+ cleared its in-flight flag, the moment any other value changed while it
227
+ was in flight; it now compares its own slot, so "username taken" lands
228
+ after you move on to `email`. Full-form scopes (an ancestor refine may
229
+ read any field) keep the whole-values compare.
230
+ - **`validateFieldsAsync` takes the subschema fast path.** It always parsed
231
+ the whole schema while the sync `validateFields` parsed only the
232
+ requested slots, so `useFormSteps` paid for every other step's fields on
233
+ each `next()` and an async refine on another step's field fired every
234
+ time. The async plural now mirrors the sync routing (all paths extract:
235
+ parse those slots, in parallel; any path bails: one full parse) and
236
+ judges staleness per path.
237
+ - **`updateState` values patches release server errors.** A patch that
238
+ carries `values` now runs the same release as `setValues`: entries whose
239
+ value slice changed go, untouched siblings stay. An explicit
240
+ `serverErrors` in the same patch is taken as given.
241
+ - **The devtools gates fail closed without a readable `NODE_ENV`.**
242
+ `FormstandDevtools` rendered the full state panel and the zustand
243
+ devtools middleware stayed enabled in a CDN/ESM, Deno, or worker
244
+ production build, because "no `process`" read as a dev build. Both now
245
+ gate on `isDevToolsBuild()`, true only when `process.env.NODE_ENV` is a
246
+ readable string other than `"production"`; the console warnings keep the
247
+ fail-open `isDevBuild()`.
248
+ - **Two invalid Dates compare equal, and -0 equals 0, in dirty tracking.**
249
+ A field holding `new Date(NaN)` against an invalid initial read as
250
+ permanently dirty, and `-0` over an initial `0` was dirty while the input
251
+ rendered `"0"` either way.
252
+
253
+ ### Changed
254
+
255
+ - **Array ops and `useFieldArray` take array paths only.** `arrayRemove`,
256
+ `arrayMove`, and `arraySwap` accepted any `FieldPath`, so
257
+ `form.arrayRemove("name", 0)` on a `z.string()` compiled (and warned at
258
+ runtime), while `useFieldArray(form, "pair")` on a `z.tuple` bound with
259
+ the tuple's element union and `useFieldArray(form, "name")` bound with a
260
+ `never` item. Every array op and the hook's typed overload now constrain
261
+ the path to `ArrayFieldPath<Values, D>`: variable-length arrays, optional
262
+ and nullable included, tuples and scalars excluded, template paths like
263
+ `` `rows.${number}.tags` `` kept. Type-level only: the code it rejects was
264
+ already a runtime no-op or a schema failure. `ArrayFieldPath`,
265
+ `ArrayItemOf` (the one item type, tuple arm included, now shared by the
266
+ core and the hook), and the form-level `ArrayPathsOf<F>` are exported
267
+ beside `PathsOf`.
268
+ - **`PathsAccepting` and `SelectValueAt` are actually exported**, as the
269
+ bound-components entry below says they are; `BoundUseFields` and
270
+ `BoundUseVariantField` join the other `Bound*` types.
271
+ - **`FormstandDevtoolsProps` carries the form's depth.**
272
+ `<FormstandDevtools form={createForm(s, { pathDepth: 12 })} />` was a
273
+ type error against the depth-less `Form<TSchema>` prop; the props now
274
+ take `D extends PathDepth = DefaultPathDepth`. Widening only.
275
+ - **Bound components type their `path` by value.** `TextField` accepts
276
+ string-valued paths, `NumberField` number-valued, `DateField` Date-valued,
277
+ `CheckboxField` boolean-valued, `SelectField` string-valued (optional and
278
+ nullable fields qualify everywhere). `<TextField path="age" />` on a
279
+ `z.number()` used to compile and store `"42"`; it is a compile error now,
280
+ as `textInputProps(useField(form, "age"))` always was. Type-level only:
281
+ the code it rejects was already a runtime bug. `PathsAccepting<F, V>`
282
+ and `SelectValueAt<F, P>` are exported beside `PathsOf`.
283
+ - **`SelectField` checks its options against the field.** An option whose
284
+ `value` is not in the path's `z.enum` is a compile error at the option;
285
+ a free `z.string()` path still takes any string. `SelectFieldProps` is
286
+ now generic over the form and the path (`SelectFieldProps<F, P>`) rather
287
+ than the option value type.
288
+ - **`fillFields` types each value from its key**, the contract `fillField`
289
+ already had; a wrong-typed fixture value is a compile error.
290
+ - **`persistForm`'s `apply: "edit"`** is the new spelling for "load the
291
+ draft as an edit, dirty against the initial values". `"restore"` keeps
292
+ working as an alias; it collided with `form.restore(snapshot)` and with
293
+ the former `restore()` method.
294
+
295
+ ### Docs
296
+
297
+ - Every em dash is gone from the documentation pages and the playground
298
+ sources (comments included, since they show in the View code panel).
299
+ - API pages match the shipped surface: `useFields`, `useIsValidating`,
300
+ and `usePersistForm` rows; `firstError` on `UseFieldReturn`; `path`,
301
+ `firstError`, `setError`, `clearError` on `UseFieldArrayReturn`;
302
+ `addErrors`, `SubmitOptions`, and the `devtools` option on the API
303
+ index; the missing exported types and the `formstand/testing` subpath
304
+ on the utilities page.
305
+ - The ssr.md provider snippet destructures `Provider` (there is no
306
+ `FormProvider`); components.md documents why a reusable field component
307
+ must be generic over its value type; multi-step.md notes that
308
+ `useFormSteps` memoizes on the steps array's identity.
309
+ - CLI pages: a `--tests` flag row, the current version floors in the CLI
310
+ README, three input modes everywhere, and a working link for the UI-kit
311
+ matrix.
312
+ - Playground: "Open in StackBlitz" seeds a project that installs and boots
313
+ (vite and plugin-react agree, react-dom is always a dependency), with a
314
+ test pinning the seed to examples/package.json. Array-row errors are
315
+ styled, textareas follow the theme, buttons show a keyboard focus ring,
316
+ row inputs carry aria-labels, and the nested-arrays tab says how to
317
+ start.
318
+ - Docs site: reference tables stop stacking at phone width, and the
319
+ dark-mode "Get started" button meets AA contrast.
320
+
321
+ ## formstand-cli 0.23.0 — 2026-10-19
322
+
323
+ ### Fixed
324
+
325
+ - `--watch` regenerated from a stale module: jiti's runtime module cache
326
+ survives a fresh `createJiti` per run, so a field added while watching
327
+ never reached the output. The loader now disables that cache.
328
+ - The config file's `tests` block (`runners`, `renderWrapper`,
329
+ `playwright.baseURL` / `route`) was read and then dropped as an unknown
330
+ key, so it never reached the spec emitter. It is now validated with the
331
+ same loud errors as the other keys and applied: `runners` is the
332
+ `--tests` default, kit specs render through the configured wrapper, and
333
+ the Playwright spec is emitted when a base URL is configured.
334
+ - Generated specs no longer fail on their own valid sample. A number
335
+ bounded on both sides by an exclusive window narrower than one unit
336
+ (`z.number().gt(0).lt(1)`) sampled the bound itself (0); it now samples
337
+ the midpoint (0.5), and the bound probe fills distinct bad and good
338
+ values. A schema with a degraded leaf (a `z.record`, a callable, an
339
+ unsupported kind) emits its round-trip case as `it.skip` with a comment
340
+ naming the path, since the stand-in's text sample can never parse.
341
+ - The module layout's field file for a degraded leaf (for example a
342
+ `z.record`) did not compile: the bound hook inferred the real value type
343
+ and spread it into a text control. The binding is now pinned to the text
344
+ control's value type, with the TODO kept above it.
345
+ - OpenAPI 3.0 discriminated unions: a branch tag spelled as a single-value
346
+ `enum` (3.0 has no `const`) or supplied only through
347
+ `discriminator.mapping` degraded to a string. All three spellings now
348
+ walk to the same union, and a branch that still lacks a tag is named in
349
+ the TODO.
350
+ - Every walker TODO (unsupported zod kinds such as `z.record`, callable
351
+ types, non-string enums, recursive schemas) is now mirrored on stderr as
352
+ `warning: field "<path>": <todo>`; only the depth, truncation, dotted-key,
353
+ and dropped-default degradations were mirrored before.
354
+ - Type mode: a function-typed member was dropped silently (the generated
355
+ schema lost a key of the type it mirrors); it now becomes the
356
+ string-plus-TODO placeholder. An index-signature-only object type
357
+ (`Record<string, number>`) walked to an empty section and an empty
358
+ `z.object({})`; it now degrades to a TODO like `z.record` does.
359
+ - Discriminant-only union variants (a branch with no fields beyond the
360
+ tag) emitted an empty `{x.value === "pickup" && (<></>)}` block in every
361
+ kit and in the module layout. The block is replaced by a one-line comment
362
+ naming the variant.
363
+ - Generated probe-case titles were duplicated when two sections shared a
364
+ field label; a repeated label now carries its path in the title
365
+ (`Email (billing.email) rejects ...`).
366
+ - Plain single-file output did not compile for any degraded leaf (a
367
+ `z.record`, `z.bigint`, `z.lazy`, `z.any`, a non-string literal or enum,
368
+ `z.map`): the stand-in was bound as `<TextField path="meta" />`, and the
369
+ library's bound components now type `path` by value, so a non-text leaf is
370
+ a compile error. The stand-in now renders an in-file `PlaceholderField`
371
+ bound through `useField` pinned to the text control's value type, the TODO
372
+ kept above it. Degraded leaves inside union variants are pinned at their
373
+ hoisted binding in every single-file backend (the kits failed there too).
374
+ - `z.coerce.number()` / `.date()` / `.boolean()` emitted a typed control
375
+ whose path the input type (`unknown`) refuses, in both layouts. Coerced
376
+ schemas now degrade to a TODO stand-in naming the reason.
377
+ - The module layout bound a degraded leaf inside an array row (an object
378
+ row, a tuple row, a union row) and in a union section without the field
379
+ file's pin, so those files did not compile. Every row and variant binding
380
+ now carries the same cast, and the section file imports the type. A
381
+ variant whose only field is degraded also gets the fragment its TODO
382
+ comment needs (two JSX nodes under `&& (...)` did not parse).
383
+ - `z.enum(Color)` / `z.nativeEnum(Color)` over a TypeScript enum did not
384
+ compile in the plain single-file layout: `SelectField` checks each option
385
+ literal against the field's type, which is the enum itself. The walker
386
+ marks such enums and plain renders them through an in-file
387
+ `EnumSelectField` on the raw `selectProps` builder, options unchanged.
388
+ - The generated spec's "filled with valid values" round trip ran live over
389
+ checks the walker never modeled (`.regex()`, `.startsWith()`,
390
+ `.multipleOf()`, a `.refine()`, `z.date().min()`, formats such as
391
+ `z.ipv4()`), and failed on its own sample. The walker now records the
392
+ unmodeled check kinds per path, the case is emitted as `it.skip` with the
393
+ paths and reasons in a comment (as it already was for degraded leaves),
394
+ and `--tests` mirrors each on stderr. The emitted schema is unchanged.
395
+ The method-form `.email()` / `.url()` / `.uuid()` are now modeled like
396
+ `z.email()` (blank-invalid, a format probe, the format in the schema).
397
+ - Security: schema text could break out of a `//` comment into generated
398
+ TypeScript. A JSON Schema `$ref` or `type` string carrying a line
399
+ terminator (`\n`, `\r`, U+2028, U+2029) reached `// TODO: ...` lines in
400
+ the emitted schema and module field files, so the text after the
401
+ terminator became code (a `$ref` of `"#/x\n injected: z.string(), //"`
402
+ minted a real property). Every comment and every stderr warning built
403
+ from schema text now collapses line terminators to a space, so a hostile
404
+ document can neither add code nor forge a `wrote ...` line.
405
+ - An enum option or union tag containing `"` or `\` broke the generated
406
+ spec's valid-values literal (interpolated raw where everything else was
407
+ JSON-quoted).
408
+ - Module layout file names that differed only in case (`email` / `eMail`
409
+ to `EmailField.tsx` / `EMailField.tsx`) were one file on Windows and
410
+ macOS, the second overwriting the first. Component names are now deduped
411
+ case-insensitively (`EMailField2`).
412
+ - The `--template` with `--layout module` error no longer promises module
413
+ support; it says templates apply to the single-file layout only.
414
+
415
+ ### Changed
416
+
417
+ - Optional and nullable objects start empty. `billing: address.optional()`
418
+ used to seed `{ street: "", zip: "" }`, so a blank form carried a billing
419
+ object nobody filled and could not submit; it now seeds `undefined`
420
+ (nullable: `null`), like an optional union. This is a behavior change for
421
+ generated forms: an untouched optional section passes validation on
422
+ submit. Paths under it stay addressable and materialize on the first
423
+ write, the `initialValues` cast is no longer forced by their members, and
424
+ the generated spec's blank-submit case no longer lists them.
425
+ - The module layout's form body is emitted in schema order, interleaving
426
+ root fields and sections as declared, instead of all root fields first and
427
+ every section after.
428
+ - The generated-file header now reads
429
+ `// Generated by formstand-cli: edit freely, this file is yours.`; it and
430
+ every other emitted string (TODO text, spec titles, help, wizard labels,
431
+ error messages) no longer contain an em dash.
432
+
3
433
  ## 0.21.0 — 2026-09-23
4
434
 
5
435
  ### Fixed
package/README.md CHANGED
@@ -83,7 +83,8 @@ npx formstand-gen src/profileSchema.ts --ui mui --sections panel --columns 2 --o
83
83
 
84
84
  You get typed `initialValues`, a bound control per field, sections for nested objects,
85
85
  `useFieldArray` blocks with add and remove buttons, and a wired submit. Point it at a
86
- TypeScript type instead (`--type Profile`) and it generates the zod schema too. `--ui`
86
+ TypeScript type instead (`--type Profile`), or at a JSON Schema or OpenAPI document
87
+ (`api.json --schema Profile`), and it generates the zod schema too. `--ui`
87
88
  targets plain formstand components, Material UI 5 through 9, shadcn/ui, Chakra 3,
88
89
  Mantine 9, or Ant Design 6.
89
90
 
@@ -9,8 +9,10 @@ var isPlainObject = (v) => {
9
9
  };
10
10
  var valuesEqual = (a, b) => {
11
11
  if (Object.is(a, b)) return true;
12
+ if (typeof a === "number" && typeof b === "number") return a === b;
12
13
  if (a instanceof Date && b instanceof Date) {
13
- return a.getTime() === b.getTime();
14
+ const [ta, tb] = [a.getTime(), b.getTime()];
15
+ return ta === tb || Number.isNaN(ta) && Number.isNaN(tb);
14
16
  }
15
17
  if (Array.isArray(a) && Array.isArray(b)) {
16
18
  return a.length === b.length && a.every((v, i) => valuesEqual(v, b[i]));
@@ -146,6 +148,14 @@ function useFormSelectorShallow(form, selector) {
146
148
  return useStore(form.store, useShallow(selector));
147
149
  }
148
150
 
149
- export { getAtPath, hasOwnSlot, isFieldDirty, isPlainObject, parsePath, setAtPath, slotAtPath, useFormSelector, useFormSelectorShallow, valuesEqual };
150
- //# sourceMappingURL=chunk-OB23TUKY.js.map
151
- //# sourceMappingURL=chunk-OB23TUKY.js.map
151
+ // src/core/env.ts
152
+ var isDevBuild = () => typeof process === "undefined" || process.env?.["NODE_ENV"] !== "production";
153
+ var isDevToolsBuild = () => {
154
+ if (typeof process === "undefined") return false;
155
+ const env = process.env?.["NODE_ENV"];
156
+ return typeof env === "string" && env !== "production";
157
+ };
158
+
159
+ export { getAtPath, hasOwnSlot, isDevBuild, isDevToolsBuild, isFieldDirty, isPlainObject, parsePath, setAtPath, slotAtPath, useFormSelector, useFormSelectorShallow, valuesEqual };
160
+ //# sourceMappingURL=chunk-BMXIZEI4.js.map
161
+ //# sourceMappingURL=chunk-BMXIZEI4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/equality.ts","../src/core/path.ts","../src/react/useFormSelector.ts","../src/core/env.ts"],"names":[],"mappings":";;;;AAAO,IAAM,aAAA,GAAgB,CAAC,CAAA,KAA6C;AACzE,EAAA,IAAI,OAAO,CAAA,KAAM,QAAA,IAAY,CAAA,KAAM,MAAM,OAAO,KAAA;AAChD,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,cAAA,CAAe,CAAC,CAAA;AACrC,EAAA,OAAO,KAAA,KAAU,MAAA,CAAO,SAAA,IAAa,KAAA,KAAU,IAAA;AACjD;AAOO,IAAM,WAAA,GAAc,CAAC,CAAA,EAAY,CAAA,KAAwB;AAC9D,EAAA,IAAI,MAAA,CAAO,EAAA,CAAG,CAAA,EAAG,CAAC,GAAG,OAAO,IAAA;AAK5B,EAAA,IAAI,OAAO,CAAA,KAAM,QAAA,IAAY,OAAO,CAAA,KAAM,QAAA,SAAiB,CAAA,KAAM,CAAA;AACjE,EAAA,IAAI,CAAA,YAAa,IAAA,IAAQ,CAAA,YAAa,IAAA,EAAM;AAI1C,IAAA,MAAM,CAAC,EAAA,EAAI,EAAE,CAAA,GAAI,CAAC,EAAE,OAAA,EAAQ,EAAG,CAAA,CAAE,OAAA,EAAS,CAAA;AAC1C,IAAA,OAAO,EAAA,KAAO,MAAO,MAAA,CAAO,KAAA,CAAM,EAAE,CAAA,IAAK,MAAA,CAAO,MAAM,EAAE,CAAA;AAAA,EAC1D;AACA,EAAA,IAAI,MAAM,OAAA,CAAQ,CAAC,KAAK,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA,EAAG;AACxC,IAAA,OAAO,CAAA,CAAE,MAAA,KAAW,CAAA,CAAE,MAAA,IAAU,EAAE,KAAA,CAAM,CAAC,CAAA,EAAG,CAAA,KAAM,WAAA,CAAY,CAAA,EAAG,CAAA,CAAE,CAAC,CAAC,CAAC,CAAA;AAAA,EACxE;AACA,EAAA,IAAI,aAAA,CAAc,CAAC,CAAA,IAAK,aAAA,CAAc,CAAC,CAAA,EAAG;AACxC,IAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,IAAA,CAAK,CAAC,CAAA;AAC3B,IAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,IAAA,CAAK,CAAC,CAAA;AAO3B,IAAA,OACE,KAAA,CAAM,WAAW,KAAA,CAAM,MAAA,IACvB,MAAM,KAAA,CAAM,CAAC,MAAM,MAAA,CAAO,MAAA,CAAO,GAAG,CAAC,CAAA,IAAK,YAAY,CAAA,CAAE,CAAC,GAAG,CAAA,CAAE,CAAC,CAAC,CAAC,CAAA;AAAA,EAErE;AACA,EAAA,OAAO,KAAA;AACT;AAWA,IAAM,UAAA,uBAAiB,OAAA,EAAyC;AAChE,IAAM,YAAA,GAAe,CAAA;AAKd,IAAM,YAAA,GAAe,CAAC,KAAA,EAAgB,YAAA,KAAmC;AAC9E,EAAA,IAAI,MAAA,CAAO,EAAA,CAAG,KAAA,EAAO,YAAY,GAAG,OAAO,KAAA;AAC3C,EAAA,IAAI,OAAO,KAAA,KAAU,QAAA,IAAY,KAAA,KAAU,IAAA,EAAM;AAC/C,IAAA,OAAO,CAAC,WAAA,CAAY,KAAA,EAAO,YAAY,CAAA;AAAA,EACzC;AACA,EAAA,MAAM,MAAA,GAAS,UAAA,CAAW,GAAA,CAAI,KAAK,KAAK,EAAC;AACzC,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,IAAA,CAAK,CAAC,CAAA,KAAM,OAAO,EAAA,CAAG,CAAA,CAAE,OAAA,EAAS,YAAY,CAAC,CAAA;AACjE,EAAA,IAAI,GAAA,KAAQ,MAAA,EAAW,OAAO,GAAA,CAAI,KAAA;AAClC,EAAA,MAAM,KAAA,GAAQ,CAAC,WAAA,CAAY,KAAA,EAAO,YAAY,CAAA;AAC9C,EAAA,UAAA,CAAW,GAAA;AAAA,IACT,KAAA;AAAA,IACA,CAAC,EAAE,OAAA,EAAS,YAAA,EAAc,KAAA,EAAM,EAAG,GAAG,MAAM,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,YAAY;AAAA,GACrE;AACA,EAAA,OAAO,KAAA;AACT;;;ACjEA,IAAM,eAAA,GAAkB,GAAA;AAExB,IAAM,cAAA,GAAiB,CAAC,CAAA,KAAuB;AAC7C,EAAA,MAAM,CAAA,GAAI,OAAO,CAAC,CAAA;AAClB,EAAA,OAAO,MAAA,CAAO,UAAU,CAAC,CAAA,IAAK,KAAK,CAAA,IAAK,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA;AACxD,CAAA;AAWA,IAAM,eAAA,GAAkB,IAAA;AACxB,IAAM,UAAA,uBAAiB,GAAA,EAAoC;AAEpD,IAAM,SAAA,GAAY,CAAC,IAAA,KAAyC;AACjE,EAAA,MAAM,MAAA,GAAS,UAAA,CAAW,GAAA,CAAI,IAAI,CAAA;AAClC,EAAA,IAAI,WAAW,MAAA,EAAW;AACxB,IAAA,UAAA,CAAW,OAAO,IAAI,CAAA;AACtB,IAAA,UAAA,CAAW,GAAA,CAAI,MAAM,MAAM,CAAA;AAC3B,IAAA,OAAO,MAAA;AAAA,EACT;AAMA,EAAA,MAAM,WAAW,MAAA,CAAO,MAAA;AAAA,IACtB,SAAS,EAAA,GACL,EAAC,GACD,IAAA,CAAK,MAAM,GAAG,CAAA,CAAE,GAAA,CAAI,CAAC,MAAO,cAAA,CAAe,CAAC,IAAI,MAAA,CAAO,CAAC,IAAI,CAAE;AAAA,GACpE;AACA,EAAA,IAAI,UAAA,CAAW,QAAQ,eAAA,EAAiB;AACtC,IAAA,MAAM,MAAA,GAAS,UAAA,CAAW,IAAA,EAAK,CAAE,MAAK,CAAE,KAAA;AACxC,IAAA,IAAI,MAAA,KAAW,MAAA,EAAW,UAAA,CAAW,MAAA,CAAO,MAAM,CAAA;AAAA,EACpD;AACA,EAAA,UAAA,CAAW,GAAA,CAAI,MAAM,QAAQ,CAAA;AAC7B,EAAA,OAAO,QAAA;AACT;AAEA,IAAM,aAAA,GAAgB,CAAC,KAAA,KACrB,KAAA,KAAU,IAAA,IAAQ,OAAO,KAAA,KAAU,QAAA,IAAY,CAAC,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA;AAMrE,IAAM,WAAA,GAAc,CAAC,GAAA,EAAc,OAAA,KAAkC;AACnE,EAAA,IAAI,GAAA,IAAO,MAAM,OAAO,MAAA;AACxB,EAAA,IAAI,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA,EAAG;AACtB,IAAA,OAAO,OAAO,OAAA,KAAY,QAAA,GAAW,GAAA,CAAI,OAAO,CAAA,GAAI,MAAA;AAAA,EACtD;AAIA,EAAA,OAAO,aAAA,CAAc,GAAG,CAAA,IAAK,MAAA,CAAO,OAAO,GAAA,EAAK,MAAA,CAAO,OAAO,CAAC,CAAA,GAC3D,GAAA,CAAI,MAAA,CAAO,OAAO,CAAC,CAAA,GACnB,MAAA;AACN,CAAA;AAEO,IAAM,SAAA,GAAY,CAAC,GAAA,EAAc,IAAA,KACtC,UAAU,IAAI,CAAA,CAAE,MAAA,CAAgB,WAAA,EAAa,GAAG;AAgB3C,IAAM,UAAA,GAAa,CAAC,GAAA,EAAc,IAAA,KAA6B;AACpE,EAAA,MAAM,MAAA,GAAS,SAAA,CAAU,IAAI,CAAA,CAAE,MAAA;AAAA,IAG7B,CAAC,KAAK,OAAA,KAAY;AAChB,MAAA,MAAM,YAAY,GAAA,CAAI,OAAA;AACtB,MAAA,MAAM,WAAA,GACJ,OAAO,OAAA,KAAY,QAAA,GACf,MAAM,OAAA,CAAQ,SAAS,CAAA,GACrB,OAAA,GAAU,UAAU,MAAA,GACpB,aAAA,CAAc,SAAS,CAAA,GACzB,cAAc,SAAS,CAAA;AAC7B,MAAA,OAAO,CAAC,GAAA,CAAI,EAAA,IAAM,CAAC,WAAA,GACf,EAAE,IAAI,KAAA,EAAO,OAAA,EAAS,MAAA,EAAU,GAChC,EAAE,EAAA,EAAI,IAAA,EAAM,SAAS,WAAA,CAAY,SAAA,EAAW,OAAO,CAAA,EAAE;AAAA,IAC3D,CAAA;AAAA,IACA,EAAE,EAAA,EAAI,IAAA,EAAM,OAAA,EAAS,GAAA;AAAI,GAC3B;AACA,EAAA,OAAO,MAAA,CAAO,EAAA,GAAK,EAAE,MAAA,EAAQ,IAAA,EAAM,KAAA,EAAO,MAAA,CAAO,OAAA,EAAQ,GAAI,EAAE,MAAA,EAAQ,KAAA,EAAM;AAC/E;AAUO,IAAM,aAAa,CAAC,GAAA,EAAc,IAAA,KACvC,SAAA,CAAU,IAAI,CAAA,CAAE,MAAA;AAAA,EACd,CAAC,KAAK,OAAA,KAAY;AAChB,IAAA,MAAM,YAAY,GAAA,CAAI,OAAA;AACtB,IAAA,MAAM,OAAO,KAAA,CAAM,OAAA,CAAQ,SAAS,CAAA,GAChC,OAAO,YAAY,QAAA,IAAY,OAAA,GAAU,UAAU,MAAA,GACnD,aAAA,CAAc,SAAS,CAAA,IAAK,MAAA,CAAO,OAAO,SAAA,EAAW,MAAA,CAAO,OAAO,CAAC,CAAA;AACxE,IAAA,OAAO,CAAC,GAAA,CAAI,EAAA,IAAM,CAAC,IAAA,GACf,EAAE,IAAI,KAAA,EAAO,OAAA,EAAS,MAAA,EAAU,GAChC,EAAE,EAAA,EAAI,IAAA,EAAM,SAAS,WAAA,CAAY,SAAA,EAAW,OAAO,CAAA,EAAE;AAAA,EAC3D,CAAA;AAAA,EACA,EAAE,EAAA,EAAI,IAAA,EAAM,OAAA,EAAS,GAAA;AACvB,CAAA,CAAE;AAEJ,IAAM,cAAA,GAAiB,CACrB,GAAA,EACA,KAAA,EACA,MACA,KAAA,KACuB;AACvB,EAAA,MAAM,SAAS,IAAA,CAAK,GAAA,CAAI,GAAA,CAAI,MAAA,EAAQ,QAAQ,CAAC,CAAA;AAC7C,EAAA,OAAO,KAAA,CAAM,IAAA;AAAA,IAAK,EAAE,MAAA,EAAO;AAAA,IAAG,CAAC,CAAA,EAAG,CAAA,KAChC,CAAA,KAAM,KAAA,GAAQ,aAAA,CAAc,GAAA,CAAI,CAAC,CAAA,EAAG,IAAA,EAAM,KAAK,CAAA,GAAI,IAAI,CAAC;AAAA,GAC1D;AACF,CAAA;AAEA,IAAM,gBAAA,GAAmB,CAAC,KAAA,KAA2B;AACnD,EAAA,IAAI,QAAQ,eAAA,EAAiB;AAC3B,IAAA,OAAA,CAAQ,IAAA;AAAA,MACN,CAAA,0CAAA,EAA6C,KAAK,CAAA,MAAA,EAAS,eAAe,CAAA,wBAAA;AAAA,KAC5E;AACA,IAAA,OAAO,IAAA;AAAA,EACT;AACA,EAAA,OAAO,KAAA;AACT,CAAA;AAEA,IAAM,aAAA,GAAgB,CACpB,GAAA,EACA,QAAA,EACA,KAAA,KACY;AACZ,EAAA,MAAM,CAAC,IAAA,EAAM,GAAG,IAAI,CAAA,GAAI,QAAA;AACxB,EAAA,IAAI,IAAA,KAAS,QAAW,OAAO,KAAA;AAE/B,EAAA,IAAI,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA,EAAG;AACtB,IAAA,IAAI,OAAO,SAAS,QAAA,EAAU;AAC5B,MAAA,OAAA,CAAQ,IAAA;AAAA,QACN,wCAAwC,IAAI,CAAA,sCAAA;AAAA,OAC9C;AACA,MAAA,OAAO,GAAA;AAAA,IACT;AACA,IAAA,IAAI,gBAAA,CAAiB,IAAI,CAAA,EAAG,OAAO,GAAA;AACnC,IAAA,OAAO,cAAA,CAAe,GAAA,EAAK,IAAA,EAAM,IAAA,EAAM,KAAK,CAAA;AAAA,EAC9C;AAKA,EAAA,IAAI,aAAA,CAAc,GAAG,CAAA,EAAG;AAKtB,IAAA,IAAI,CAAC,aAAA,CAAc,GAAG,CAAA,EAAG;AACvB,MAAA,OAAA,CAAQ,IAAA;AAAA,QACN,CAAA,8BAAA,EAAiC,MAAA,CAAO,IAAI,CAAC,CAAA,iFAAA;AAAA,OAC/C;AACA,MAAA,OAAO,GAAA;AAAA,IACT;AACA,IAAA,MAAM,GAAA,GAAM,OAAO,IAAI,CAAA;AACvB,IAAA,OAAO,EAAE,GAAG,GAAA,EAAK,CAAC,GAAG,GAAG,aAAA,CAAc,GAAA,CAAI,GAAG,CAAA,EAAG,IAAA,EAAM,KAAK,CAAA,EAAE;AAAA,EAC/D;AAGA,EAAA,IAAI,OAAO,SAAS,QAAA,EAAU;AAC5B,IAAA,IAAI,gBAAA,CAAiB,IAAI,CAAA,EAAG,OAAO,GAAA;AACnC,IAAA,OAAO,cAAA,CAAe,EAAC,EAAG,IAAA,EAAM,MAAM,KAAK,CAAA;AAAA,EAC7C;AACA,EAAA,OAAO,EAAE,CAAC,IAAI,GAAG,cAAc,MAAA,EAAW,IAAA,EAAM,KAAK,CAAA,EAAE;AACzD,CAAA;AAEO,IAAM,SAAA,GAAY,CAAI,GAAA,EAAQ,IAAA,EAAc,KAAA,KACjD,cAAc,GAAA,EAAK,SAAA,CAAU,IAAI,CAAA,EAAG,KAAK;ACxKpC,SAAS,eAAA,CACd,MACA,QAAA,EACG;AACH,EAAA,OAAO,QAAA,CAAS,IAAA,CAAK,KAAA,EAAO,QAAQ,CAAA;AACtC;AAcO,SAAS,sBAAA,CACd,MACA,QAAA,EACG;AACH,EAAA,OAAO,QAAA,CAAS,IAAA,CAAK,KAAA,EAAO,UAAA,CAAW,QAAQ,CAAC,CAAA;AAClD;;;AC/CO,IAAM,UAAA,GAAa,MACxB,OAAO,OAAA,KAAY,eACnB,OAAA,CAAQ,GAAA,GAAM,UAAU,CAAA,KAAM;AAWzB,IAAM,kBAAkB,MAAe;AAC5C,EAAA,IAAI,OAAO,OAAA,KAAY,WAAA,EAAa,OAAO,KAAA;AAC3C,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,GAAA,GAAM,UAAU,CAAA;AACpC,EAAA,OAAO,OAAO,GAAA,KAAQ,QAAA,IAAY,GAAA,KAAQ,YAAA;AAC5C","file":"chunk-BMXIZEI4.js","sourcesContent":["export const isPlainObject = (v: unknown): v is Record<string, unknown> => {\n if (typeof v !== \"object\" || v === null) return false;\n const proto = Object.getPrototypeOf(v);\n return proto === Object.prototype || proto === null;\n};\n\n// Structural equality used to decide whether a field still matches its initial\n// value. Recurses into arrays and plain objects; Dates compare by timestamp\n// (re-picking the same date must not leave the field permanently dirty);\n// other exotic objects fall back to Object.is so a real change is never\n// mistaken for \"unchanged\".\nexport const valuesEqual = (a: unknown, b: unknown): boolean => {\n if (Object.is(a, b)) return true;\n // Numbers compare with `===` semantics on top of Object.is: Object.is\n // alone keeps NaN equal to itself (wanted) but tells -0 from 0, and a\n // number input renders both as \"0\", so a field holding -0 against an\n // initial 0 read as permanently dirty with nothing visible to undo.\n if (typeof a === \"number\" && typeof b === \"number\") return a === b;\n if (a instanceof Date && b instanceof Date) {\n // Two invalid Dates are equal: their timestamps are both NaN, which\n // `===` denies, so a cleared or half-typed date against an invalid\n // initial read as dirty forever.\n const [ta, tb] = [a.getTime(), b.getTime()];\n return ta === tb || (Number.isNaN(ta) && Number.isNaN(tb));\n }\n if (Array.isArray(a) && Array.isArray(b)) {\n return a.length === b.length && a.every((v, i) => valuesEqual(v, b[i]));\n }\n if (isPlainObject(a) && isPlainObject(b)) {\n const aKeys = Object.keys(a);\n const bKeys = Object.keys(b);\n // hasOwn matters: equal counts don't imply equal key SETS. Without it,\n // { realname: undefined } vs { nickname: \"Ann\" } passes (1 === 1, and\n // a.realname === missing b.realname === undefined) without b's real key\n // ever being inspected — dirty tracking would read a real change as\n // clean. With hasOwn plus the length check the key sets must match, so\n // the compare is also symmetric.\n return (\n aKeys.length === bKeys.length &&\n aKeys.every((k) => Object.hasOwn(b, k) && valuesEqual(a[k], b[k]))\n );\n }\n return false;\n};\n\n// The deep compare for a container-valued field runs inside store selectors\n// and watch subscriptions, i.e. on every store change — cache verdicts per\n// value object, keyed by the initial-slice reference each was computed for.\n// A small list (not a single slot) so distinct fields/forms sharing one value\n// object (a module-level default, a shared sentinel array) coexist instead of\n// evicting each other; bounded so no entry grows without limit. Form state is\n// immutable, so identical references always mean an identical verdict, and\n// the WeakMap releases everything when the value object dies.\ntype DirtyVerdict = Readonly<{ initial: unknown; dirty: boolean }>;\nconst dirtyCache = new WeakMap<object, readonly DirtyVerdict[]>();\nconst MAX_VERDICTS = 4;\n\n// The single definition of dirtiness: the current value slice differs\n// structurally from its initial-values slice. Derived (rather than tracked by\n// writers) so reads are accurate at any depth.\nexport const isFieldDirty = (value: unknown, initialValue: unknown): boolean => {\n if (Object.is(value, initialValue)) return false;\n if (typeof value !== \"object\" || value === null) {\n return !valuesEqual(value, initialValue);\n }\n const cached = dirtyCache.get(value) ?? [];\n const hit = cached.find((v) => Object.is(v.initial, initialValue));\n if (hit !== undefined) return hit.dirty;\n const dirty = !valuesEqual(value, initialValue);\n dirtyCache.set(\n value,\n [{ initial: initialValue, dirty }, ...cached].slice(0, MAX_VERDICTS),\n );\n return dirty;\n};\n","import { isPlainObject } from \"./equality\";\n\nexport type PathSegment = string | number;\n\n// Segment keys containing \".\" are not addressable (paths are split on dots);\n// use nested objects or index-free keys instead.\n\n// Writes above this index are refused — a typo'd path like \"items.4294967296\"\n// must not allocate a multi-gigabyte array.\nconst MAX_ARRAY_INDEX = 100_000;\n\nconst isIndexSegment = (s: string): boolean => {\n const n = Number(s);\n return Number.isInteger(n) && n >= 0 && String(n) === s;\n};\n\n// Hot path: every field subscription re-resolves its path on every store\n// notification (getAtPath in selectors), so parsing is memoized — a form's\n// paths are a small fixed set. Bounded as an LRU over Map insertion order:\n// a hit re-inserts its key (moving it to the back) and an overflow evicts\n// the front, so an app whose LIVE path set exceeds the cap keeps its hot\n// paths cached instead of re-parsing every one of them after each reset\n// (the old wholesale clear). Eviction only costs re-parsing, never\n// correctness. Same delete-then-set recency idiom as useFieldArray's\n// per-path id cache.\nconst PARSE_CACHE_MAX = 4096;\nconst parseCache = new Map<string, readonly PathSegment[]>();\n\nexport const parsePath = (path: string): readonly PathSegment[] => {\n const cached = parseCache.get(path);\n if (cached !== undefined) {\n parseCache.delete(path);\n parseCache.set(path, cached);\n return cached;\n }\n // Frozen because the array is now SHARED across every caller (and cached):\n // parsePath is a public export, and pre-cache each call returned a private\n // array that was safe to mutate. Freezing enforces the `readonly` type at\n // runtime so an external `.sort()`/`.reverse()`/`.push()` throws instead of\n // silently corrupting the cache for every later lookup of this path.\n const segments = Object.freeze(\n path === \"\"\n ? []\n : path.split(\".\").map((s) => (isIndexSegment(s) ? Number(s) : s)),\n );\n if (parseCache.size >= PARSE_CACHE_MAX) {\n const oldest = parseCache.keys().next().value;\n if (oldest !== undefined) parseCache.delete(oldest);\n }\n parseCache.set(path, segments);\n return segments;\n};\n\nconst isPlainRecord = (value: unknown): value is Record<string, unknown> =>\n value !== null && typeof value === \"object\" && !Array.isArray(value);\n\n// The existing container decides how a segment is interpreted: arrays take\n// numeric segments as indices, records take any segment as a string key (so a\n// z.record keyed \"0\" reads/writes the record key, not an array index). Only\n// when the container is absent does the segment type pick what gets created.\nconst readSegment = (obj: unknown, segment: PathSegment): unknown => {\n if (obj == null) return undefined;\n if (Array.isArray(obj)) {\n return typeof segment === \"number\" ? obj[segment] : undefined;\n }\n // Own properties only: a runtime-built path like \"lookup.constructor\" on a\n // z.record must read as absent, not leak Object.prototype members as field\n // values.\n return isPlainRecord(obj) && Object.hasOwn(obj, String(segment))\n ? obj[String(segment)]\n : undefined;\n};\n\nexport const getAtPath = (obj: unknown, path: string): unknown =>\n parsePath(path).reduce<unknown>(readSegment, obj);\n\nexport type SlotAtPath =\n | Readonly<{ exists: true; value: unknown }>\n | Readonly<{ exists: false }>;\n\n// Resolve `path` while checking that every segment is read from a real\n// container: an in-range array index, or a key on a plain record (containers\n// decide, mirroring readSegment). A segment into an absent or non-container\n// value addresses no slot at all — a full-form parse would key that error at\n// the missing ANCESTOR, not here, so field-scoped validation must skip it\n// rather than parse the leaf's subschema against undefined (which would\n// fabricate a spurious \"required\" error the full pass never produces). Note\n// this checks the CONTAINER, not the value: a leaf that is undefined at the\n// FINAL step through a real record is still an addressable (legitimately\n// empty) slot. Returns the resolved value so callers don't walk twice.\nexport const slotAtPath = (obj: unknown, path: string): SlotAtPath => {\n const walked = parsePath(path).reduce<\n Readonly<{ ok: boolean; current: unknown }>\n >(\n (acc, segment) => {\n const container = acc.current;\n const addressable =\n typeof segment === \"number\"\n ? Array.isArray(container)\n ? segment < container.length\n : isPlainRecord(container)\n : isPlainRecord(container);\n return !acc.ok || !addressable\n ? { ok: false, current: undefined }\n : { ok: true, current: readSegment(container, segment) };\n },\n { ok: true, current: obj },\n );\n return walked.ok ? { exists: true, value: walked.current } : { exists: false };\n};\n\n// Whether the path addresses an OWN slot: every step reads a real\n// container's own key / in-range array index, the final segment included.\n// This is stricter than slotAtPath, which treats a MISSING final key on a\n// real record as an addressable (legitimately empty) slot — here it reads\n// false. The consumer is setValue's identity guard: writing `undefined`\n// over an ABSENT key is NOT a no-op (it creates the key, a real state\n// change — key-count dirtiness, persisted shape), while writing it over a\n// key that exists holding undefined is.\nexport const hasOwnSlot = (obj: unknown, path: string): boolean =>\n parsePath(path).reduce<Readonly<{ ok: boolean; current: unknown }>>(\n (acc, segment) => {\n const container = acc.current;\n const owns = Array.isArray(container)\n ? typeof segment === \"number\" && segment < container.length\n : isPlainRecord(container) && Object.hasOwn(container, String(segment));\n return !acc.ok || !owns\n ? { ok: false, current: undefined }\n : { ok: true, current: readSegment(container, segment) };\n },\n { ok: true, current: obj },\n ).ok;\n\nconst writeIntoArray = (\n arr: readonly unknown[],\n index: number,\n rest: readonly PathSegment[],\n value: unknown,\n): readonly unknown[] => {\n const length = Math.max(arr.length, index + 1);\n return Array.from({ length }, (_, i) =>\n i === index ? writeSegments(arr[i], rest, value) : arr[i],\n );\n};\n\nconst refusesHugeIndex = (index: number): boolean => {\n if (index > MAX_ARRAY_INDEX) {\n console.warn(\n `[formstand] refusing to write array index ${index} (max ${MAX_ARRAY_INDEX}); value left unchanged.`,\n );\n return true;\n }\n return false;\n};\n\nconst writeSegments = (\n obj: unknown,\n segments: readonly PathSegment[],\n value: unknown,\n): unknown => {\n const [head, ...rest] = segments;\n if (head === undefined) return value;\n\n if (Array.isArray(obj)) {\n if (typeof head !== \"number\") {\n console.warn(\n `[formstand] cannot write string key \"${head}\" into an array; value left unchanged.`,\n );\n return obj;\n }\n if (refusesHugeIndex(head)) return obj;\n return writeIntoArray(obj, head, rest, value);\n }\n\n // Records take any segment as a string key (mirroring readSegment), so a\n // z.record keyed by a large numeric id is writable — the array-index cap\n // only applies where an array actually exists or would be created.\n if (isPlainRecord(obj)) {\n // Spreading is only safe for PLAIN objects: spreading a Date/Map/Set or\n // class instance keeps own enumerable props but drops the prototype (and\n // for Map/Set, every entry) — the original would be silently destroyed.\n // Refuse, like the other unwritable shapes above.\n if (!isPlainObject(obj)) {\n console.warn(\n `[formstand] cannot write key \"${String(head)}\" through a non-plain object (Date/Map/Set/class instance); value left unchanged.`,\n );\n return obj;\n }\n const key = String(head);\n return { ...obj, [key]: writeSegments(obj[key], rest, value) };\n }\n\n // Absent (or non-container) value: create a container from the segment type.\n if (typeof head === \"number\") {\n if (refusesHugeIndex(head)) return obj;\n return writeIntoArray([], head, rest, value);\n }\n return { [head]: writeSegments(undefined, rest, value) };\n};\n\nexport const setAtPath = <T>(obj: T, path: string, value: unknown): T =>\n writeSegments(obj, parsePath(path), value) as T;\n","import type { z } from \"zod\";\nimport type { StoreApi } from \"zustand/vanilla\";\nimport { useStore } from \"zustand/react\";\nimport { useShallow } from \"zustand/react/shallow\";\nimport type { Form } from \"../core/createForm\";\nimport type { DefaultPathDepth, PathDepth } from \"../core/fieldPath\";\nimport type { FormState } from \"../core/types\";\n\ntype ReadonlyStore<T> = Pick<\n StoreApi<T>,\n \"getState\" | \"getInitialState\" | \"subscribe\"\n>;\n\nexport type FormStateApi = Readonly<{\n store: ReadonlyStore<FormState<unknown>>;\n}>;\n\nexport function useFormSelector<\n TSchema extends z.ZodType,\n U,\n D extends PathDepth = DefaultPathDepth,\n>(\n form: Form<TSchema, D>,\n selector: (state: FormState<z.input<TSchema>>) => U,\n): U;\n// The `schema?: undefined` brand forces Form<TSchema> (which has a real\n// `schema` property) to bind only the typed overload above. Without\n// it, Form<TSchema> would structurally satisfy FormStateApi too and a\n// bad selector could silently widen to FormState<unknown>.\nexport function useFormSelector<U>(\n form: FormStateApi & { readonly schema?: undefined },\n selector: (state: FormState<unknown>) => U,\n): U;\nexport function useFormSelector<U>(\n form: FormStateApi,\n selector: (state: FormState<unknown>) => U,\n): U {\n return useStore(form.store, selector);\n}\n\nexport function useFormSelectorShallow<\n TSchema extends z.ZodType,\n U,\n D extends PathDepth = DefaultPathDepth,\n>(\n form: Form<TSchema, D>,\n selector: (state: FormState<z.input<TSchema>>) => U,\n): U;\nexport function useFormSelectorShallow<U>(\n form: FormStateApi & { readonly schema?: undefined },\n selector: (state: FormState<unknown>) => U,\n): U;\nexport function useFormSelectorShallow<U>(\n form: FormStateApi,\n selector: (state: FormState<unknown>) => U,\n): U {\n return useStore(form.store, useShallow(selector));\n}\n","// The dev-build test the library consults before emitting a console\n// warning. Bundlers that define NODE_ENV fold it to a constant and drop the\n// dev-only branches; the `typeof process` guard keeps the SAME expression\n// from throwing in a runtime that defines no `process` at all (a CDN/ESM\n// consumer, a test runner without the define). That last case matters most\n// on the submit path: handleSubmit promises never to reject, and the\n// warning that fires only when a save failed was the one line that could\n// break the promise exactly on the failure path. It FAILS OPEN on purpose:\n// an unknowable environment gets the warnings, which cost nothing but a\n// console line.\nexport const isDevBuild = (): boolean =>\n typeof process === \"undefined\" ||\n process.env?.[\"NODE_ENV\"] !== \"production\";\n\n// The gate for the two sites that EXPOSE form state: the FormstandDevtools\n// panel (renders every value and error into the page) and the zustand\n// devtools middleware (streams every write to the Redux extension). These\n// must FAIL CLOSED: isDevBuild's \"no process means dev\" reading let a\n// CDN/ESM, Deno, or worker production build mount the panel and keep the\n// middleware live for any end user with the extension installed. Only a\n// readable NODE_ENV that is a string other than \"production\" counts as\n// development; no process, no env, or an unset NODE_ENV all read as\n// production here.\nexport const isDevToolsBuild = (): boolean => {\n if (typeof process === \"undefined\") return false;\n const env = process.env?.[\"NODE_ENV\"];\n return typeof env === \"string\" && env !== \"production\";\n};\n"]}
@@ -1,3 +1,7 @@
1
+ // src/core/maps.ts
2
+ var entryAt = (map, key) => Object.hasOwn(map, key) ? map[key] : void 0;
3
+ var flagAt = (map, key) => Object.hasOwn(map, key) && map[key] === true;
4
+
1
5
  // src/core/mode.ts
2
6
  var shouldValidateOn = (trigger, mode, reValidateMode, submitAttempted, touched = false) => {
3
7
  const effective = submitAttempted ? reValidateMode : mode;
@@ -15,6 +19,6 @@ var shouldValidateOn = (trigger, mode, reValidateMode, submitAttempted, touched
15
19
  }
16
20
  };
17
21
 
18
- export { shouldValidateOn };
19
- //# sourceMappingURL=chunk-MQ6262S3.js.map
20
- //# sourceMappingURL=chunk-MQ6262S3.js.map
22
+ export { entryAt, flagAt, shouldValidateOn };
23
+ //# sourceMappingURL=chunk-V3PDMTPP.js.map
24
+ //# sourceMappingURL=chunk-V3PDMTPP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/maps.ts","../src/core/mode.ts"],"names":[],"mappings":";AAYO,IAAM,OAAA,GAAU,CACrB,GAAA,EACA,GAAA,KACmB,MAAA,CAAO,MAAA,CAAO,GAAA,EAAK,GAAG,CAAA,GAAI,GAAA,CAAI,GAAG,CAAA,GAAI;AAEnD,IAAM,MAAA,GAAS,CAAC,GAAA,EAAc,GAAA,KACnC,MAAA,CAAO,MAAA,CAAO,GAAA,EAAK,GAAG,CAAA,IAAK,GAAA,CAAI,GAAG,CAAA,KAAM;;;ACPnC,IAAM,mBAAmB,CAC9B,OAAA,EACA,MACA,cAAA,EACA,eAAA,EACA,UAAmB,KAAA,KACP;AACZ,EAAA,MAAM,SAAA,GAAY,kBAAkB,cAAA,GAAiB,IAAA;AACrD,EAAA,QAAQ,SAAA;AAAW,IACjB,KAAK,UAAA;AACH,MAAA,OAAO,OAAA,KAAY,QAAA;AAAA,IACrB,KAAK,QAAA;AACH,MAAA,OAAO,OAAA,KAAY,MAAA;AAAA,IACrB,KAAK,UAAA;AACH,MAAA,OAAO,KAAA;AAAA,IACT,KAAK,WAAA;AACH,MAAA,OAAO,YAAY,MAAA,IAAU,OAAA;AAAA,IAC/B,KAAK,KAAA;AACH,MAAA,OAAO,IAAA;AAAA;AAEb","file":"chunk-V3PDMTPP.js","sourcesContent":["import type { BoolMap } from \"./types\";\n\n// Own-key reads for the path-keyed maps (errors, touched, isValidating).\n// Their keys are user-controlled strings: a schema may declare a field\n// named `constructor`, a server response may name `toString`, and\n// JSON.parse yields own `__proto__` keys. A bare `map[key]` on a plain\n// object walks the prototype chain for those, so an EMPTY error map read\n// `Object` as the error list at \"constructor\" (and `stringArraysEqual`\n// then threw inside setState on `.every`), while `touched[\"toString\"]` was\n// a truthy function. Every read of these maps by a runtime key goes\n// through here; writes are already safe (computed keys and\n// Object.fromEntries define own properties).\nexport const entryAt = <V>(\n map: Readonly<Record<string, V>>,\n key: string,\n): V | undefined => (Object.hasOwn(map, key) ? map[key] : undefined);\n\nexport const flagAt = (map: BoolMap, key: string): boolean =>\n Object.hasOwn(map, key) && map[key] === true;\n","export type ValidationMode =\n | \"onChange\"\n | \"onBlur\"\n | \"onSubmit\"\n // Validate on blur always; on change only once the field has been touched.\n | \"onTouched\"\n // Validate on both change and blur.\n | \"all\";\n\nexport type ValidationTrigger = \"change\" | \"blur\";\n\nexport const shouldValidateOn = (\n trigger: ValidationTrigger,\n mode: ValidationMode,\n reValidateMode: ValidationMode,\n submitAttempted: boolean,\n touched: boolean = false,\n): boolean => {\n const effective = submitAttempted ? reValidateMode : mode;\n switch (effective) {\n case \"onChange\":\n return trigger === \"change\";\n case \"onBlur\":\n return trigger === \"blur\";\n case \"onSubmit\":\n return false;\n case \"onTouched\":\n return trigger === \"blur\" || touched;\n case \"all\":\n return true;\n }\n};\n"]}
@@ -50,6 +50,10 @@ type FieldPathRec<T, D extends number> = [D] extends [0] ? never : IsTuple<T> ex
50
50
  }[keyof T & string] : never;
51
51
  type StepValue<T, K extends string> = T extends readonly (infer U)[] ? K extends keyof T ? T[K] : K extends `${number}` ? U : never : T extends object ? K extends keyof T ? T[K] : never : never;
52
52
  type FieldValue<T, P extends string> = [T] extends [NonNullable<T>] ? P extends `${infer Head}.${infer Tail}` ? FieldValue<StepValue<T, Head>, Tail> : StepValue<T, P> : FieldValue<NonNullable<T>, P> | undefined;
53
+ type ArrayItemOf<T> = [NonNullable<T>] extends [readonly (infer U)[]] ? number extends NonNullable<T>["length"] ? U : never : never;
54
+ type IsListValue<V> = [NonNullable<V>] extends [readonly unknown[]] ? number extends NonNullable<V>["length"] ? true : false : false;
55
+ type ArrayFieldPath<T, D extends number = DefaultPathDepth> = ArrayPathsWhere<T, FieldPath<T, D>>;
56
+ type ArrayPathsWhere<T, P extends string> = P extends unknown ? IsListValue<FieldValue<T, P>> extends true ? P : never : never;
53
57
 
54
58
  type ValidationMode = "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all";
55
59
  type ValidationTrigger = "change" | "blur";
@@ -137,7 +141,6 @@ type SubmitResult<TOutput> = Readonly<{
137
141
  kind: "error";
138
142
  error: unknown;
139
143
  }>;
140
- type ArrayItemOf<T> = T extends readonly (infer U)[] ? U : never;
141
144
  type ErrorPath<TValues, D extends PathDepth = DefaultPathDepth> = FieldPath<TValues, D> | "";
142
145
  type ResetOptions = Readonly<{
143
146
  keepErrors?: boolean;
@@ -183,11 +186,11 @@ type Form<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = R
183
186
  validateFieldAsync: (path: ErrorPath<z.input<TSchema>, D>) => Promise<SettledFieldValidationResult>;
184
187
  validateFieldsAsync: (paths: readonly FieldPath<z.input<TSchema>, D>[]) => Promise<SettledFieldsValidationResult>;
185
188
  submit: (onValid: SubmitHandler<TSchema>, onInvalid?: InvalidSubmitHandler, options?: SubmitOptions) => Promise<SubmitResult<z.output<TSchema>>>;
186
- arrayPush: <P extends FieldPath<z.input<TSchema>, D>>(path: P, item: ArrayItemOf<NonNullable<FieldValue<z.input<TSchema>, P>>>) => void;
187
- arrayRemove: (path: FieldPath<z.input<TSchema>, D>, index: number) => void;
188
- arrayInsert: <P extends FieldPath<z.input<TSchema>, D>>(path: P, index: number, item: ArrayItemOf<NonNullable<FieldValue<z.input<TSchema>, P>>>) => void;
189
- arrayMove: (path: FieldPath<z.input<TSchema>, D>, from: number, to: number) => void;
190
- arraySwap: (path: FieldPath<z.input<TSchema>, D>, a: number, b: number) => void;
189
+ arrayPush: <P extends ArrayFieldPath<z.input<TSchema>, D>>(path: P, item: ArrayItemOf<FieldValue<z.input<TSchema>, P>>) => void;
190
+ arrayRemove: (path: ArrayFieldPath<z.input<TSchema>, D>, index: number) => void;
191
+ arrayInsert: <P extends ArrayFieldPath<z.input<TSchema>, D>>(path: P, index: number, item: ArrayItemOf<FieldValue<z.input<TSchema>, P>>) => void;
192
+ arrayMove: (path: ArrayFieldPath<z.input<TSchema>, D>, from: number, to: number) => void;
193
+ arraySwap: (path: ArrayFieldPath<z.input<TSchema>, D>, a: number, b: number) => void;
191
194
  handleSubmit: (onValid: SubmitHandler<TSchema>, onInvalid?: InvalidSubmitHandler, options?: SubmitOptions) => (event?: {
192
195
  preventDefault: () => void;
193
196
  }) => Promise<SubmitResult<z.output<TSchema>>>;
@@ -198,4 +201,4 @@ type Form<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = R
198
201
  }>;
199
202
  declare const createForm: <TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth>(schema: TSchema, options: CreateFormOptions<TSchema, D>) => Form<TSchema, D>;
200
203
 
201
- export { type BoolMap as B, type CreateFormOptions as C, type DefaultPathDepth as D, type ErrorMap as E, type Form as F, type InvalidSubmitHandler as I, type PathDepth as P, type ReadonlyStoreApi as R, type SettledFieldValidationResult as S, type ValidationMode as V, type FieldValidationResult as a, type FormState as b, type FieldPath as c, type FieldValue as d, type FieldSnapshot as e, type FieldsValidationResult as f, type ResetOptions as g, type SettledFieldsValidationResult as h, type SettledValidationResult as i, type SubmitHandler as j, type SubmitOptions as k, type SubmitResult as l, type ValidationResult as m, type ValidationTrigger as n, createForm as o, emptyValueForSchema as p, flattenIssues as q, isAsyncRequiredError as r, isClearableSchema as s, shouldValidateOn as t, validateSync as u, validateAsync as v };
204
+ export { type ArrayFieldPath as A, type BoolMap as B, type CreateFormOptions as C, type DefaultPathDepth as D, type ErrorMap as E, type Form as F, type InvalidSubmitHandler as I, type PathDepth as P, type ReadonlyStoreApi as R, type SettledFieldValidationResult as S, type ValidationMode as V, type FormState as a, type FieldValidationResult as b, type FieldPath as c, type FieldValue as d, type ArrayItemOf as e, type FieldSnapshot as f, type FieldsValidationResult as g, type ResetOptions as h, type SettledFieldsValidationResult as i, type SettledValidationResult as j, type SubmitHandler as k, type SubmitOptions as l, type SubmitResult as m, type ValidationResult as n, type ValidationTrigger as o, createForm as p, emptyValueForSchema as q, flattenIssues as r, isAsyncRequiredError as s, isClearableSchema as t, shouldValidateOn as u, validateAsync as v, validateSync as w };