feat(form): add fs-form — useFormSubmit + useValidationErrors#148
Conversation
Extract the form-submit composable pair that BIO and entreezuil independently
ran side-by-side into the armory:
- useValidationErrors(httpService, {keyMapper?}) — registers a 422-only
response-error middleware binding backend field errors into a reactive bag
(first message per field), auto-unregisters on unmount. The one axis the two
source territories diverged on — raw vs camelCased error keys — is absorbed by
an optional keyMapper (default identity).
- useFormSubmit(validationErrors) — double-submit guard + submitting flag;
swallows a 422 (errors already surfaced, form preserved) and re-throws else.
The middleware body is wrapped with fs-http's guarded() so a throwing keyMapper
cannot reject a resolved request or mask the real API error — Principle #8
compliant out of the box.
Gauntlet: dual ESM+CJS build, 100% coverage (22 tests), 91.89% mutation
(3 residual survivors are equivalent mutants the guarded() backstop creates —
killing them would mean removing the Principle #8 guard). publint + attw clean;
sideEffects:false side-effect-free assertion passes.
Docs (docs/packages/form.md + sidebar) and the CLAUDE.md package table updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq
Deploying fs-packages with
|
| Latest commit: |
979c54f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4932eaa8.fs-packages.pages.dev |
| Branch Preview URL: | https://feat-fs-form-package.fs-packages.pages.dev |
Goosterhof
left a comment
There was a problem hiding this comment.
💬 Self-review (COMMENT — own PR)
Devil's-advocate pass over the fs-form forge. 0 blockers · 0 majors · 1 caveat · 1 nit · 1 praise · 2 inline.
The pair is correctly complementary: useValidationErrors' guarded() 422 middleware populates the bag as a side effect (fs-http still rejects the request), and useFormSubmit swallows the 422 rejection — so the populated form survives without either piece double-owning the outcome. Principle #8 is honoured at the one place it matters (guarded() around the keyMapper/parse body). keyMapper-default-identity cleanly absorbs the BIO-camelCase / entreezuil-raw divergence at a single injection point. Types are clean, index.ts re-exports only the public surface, and no direct axios import (routed through fs-http re-exports) keeps the dep graph honest.
Notes (detail inline)
- 🟡
packages/form/src/validation-errors.ts:52— per-consumer 422 bag → cross-form bleed when two consumers share oneHttpService; wants an explicit docs note. - ⚪
packages/form/src/validation-errors.ts:23— uncheckedmessages[0]on a non-array field (defensive-depth only). - praise: the honest mutation-score writeup — the 3 survivors are equivalent mutants the
guarded()backstop creates; refusing to strip the guard for a vanity 100% is exactly the Principle #8 discipline.
CI note: the check 8-gate job is pending at review time — verdict is COMMENT (self-authored PR; GitHub blocks self-approve regardless). The npm Trusted Publisher publish precondition is correctly flagged as a non-blocking Commander console step.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
Wire useValidationErrors + useFormSubmit into a single useForm(httpService,
{keyMapper?}) so a page composes one call, not two — returns {errors,
clearErrors, handleSubmit, submitting}. submitting is the form's loading state
(true while a submit is in flight); no fs-loading coupling (its global counter
is the wrong granularity for per-form state).
The primitives stay exported for the one-half cases (validation-less confirm
action, etc.). Docs lead with useForm; primitives documented as composition.
Gauntlet: 100% coverage (form.ts + form-submit.ts 100% mutation), 92.31%
package mutation, lint:pkg clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq
Goosterhof
left a comment
There was a problem hiding this comment.
💬 Self-review R2 (COMMENT — own PR)
Re-review at 174f2c65. Delta since c0a81703 = the useForm one-call aggregator (feat(form): add useForm over the two primitives). Clean and correct: useForm(httpService, options) wires useValidationErrors(options) + useFormSubmit(validation) and returns {errors, clearErrors, handleSubmit, submitting}; UseForm<T> = UseValidationErrors<T> & UseFormSubmit is a collision-free intersection; UseFormOptions threads keyMapper through to the validation half; both primitives stay exported for one-half use, and the docs lead with useForm while keeping the compose-by-hand path. +152 test lines. No new blockers or majors.
The two caveats from my first review carry over unchanged — both still non-blocking, and both now reachable via useForm too since it inherits the primitives verbatim:
- 🟡 per-consumer 422 bag → cross-form bleed when two consumers share one
HttpService(validation-errors.ts:52, thread still open). The docsCleanupsection covers unmount-scoping but not the two-forms-one-service case; a one-line note there closes it. - ⚪ unchecked
messages[0]on a non-array field (validation-errors.ts:23, thread still open).
Both are author's-call — flagging that they persist, not re-raising as new findings.
CI note: check is re-running on the new commit at review time; verdict is COMMENT regardless (self-authored PR).
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
Address the two carried self-review findings on PR #148 as docs, both author's-call / non-blocking: - Cross-form bleed (🟡): document that one HttpService/error-scope is required per form — two forms sharing one service both fill from any 422. - messages[0] cast (⚪): skip the runtime guard by design; instead document that fs-form targets Laravel's 422 contract (errors: Record<string,string[]>) and does not defensively handle other backend shapes. Revisit if a non-Laravel consumer ever appears. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq
Goosterhof
left a comment
There was a problem hiding this comment.
💬 Self-review R3 (COMMENT — own PR) — both my caveats now documented
Delta since 174f2c65 = 979c54fa, a docs-only addition to docs/packages/form.md (new Scoping & Backend Contract section) that closes both first-review caveats — both inline threads already resolved:
- 🟡 cross-form bleed → "One error-scope per form" note: a 422 on a shared
HttpServicefills both bags; give each form its own service/scope. Exactly the note I asked for. - ⚪ unchecked
messages[0]on a non-array → "Laravel 422 contract" note: the parse deliberately targets Laravel's{message?, errors: Record<string,string[]>}shape; a non-string[]field is passed through unguarded because Laravel guarantees arrays, with a "revisit the parse for a non-Laravel backend" pointer.
Nothing outstanding. useForm aggregator + primitives sound (R2), Principle #8 guarded() intact, and the two residual caveats are now explicit contract documentation rather than latent gaps.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
What
Forges
@script-development/fs-form— the extracted, shared version of the form-submit composable pair that BIO and entreezuil independently ran side-by-side (Commander called the forge 2026-07-08).The one-call entry point is
useForm:It wires two primitives together so a page composes one call, not two:
useValidationErrors(httpService, {keyMapper?})— a 422-only response-error middleware binding backend field errors into a reactivePartial<Record<field, message>>bag (first message per field), auto-unregisters on unmount.useFormSubmit(validationErrors)— double-submit guard +submittingflag (this is the form's loading state); clears prior errors, swallows a 422 (field errors already surfaced → the populated form is preserved), re-throws everything else.Both primitives stay exported for the one-half cases (a validation-less confirm action wants the submit guard without the 422 middleware).
Design decisions (settled before the forge)
useFormaggregates both; primitives kept exported. Aggregator-over-primitives, not a collapse.submitting— the per-form in-flight flag, surfaced from the one call. Deliberately NOTfs-loading: its counter is app-global, so an unrelated background request would flip this form's spinner — wrong granularity for per-form state.keyMapper(default identity).guarded()— a throwingkeyMappercannot reject a resolved request or mask the real API error.Gauntlet (all 8 CI gates green locally)
d.ctsemitted (no direct axios import — routed through fs-http re-exports)sideEffects:falseside-effect-free assertion passuseForm+useFormSubmit100%. The 3 residual survivors invalidation-errors.tsare equivalent mutants theguarded()backstop creates: a removed null-guard throws,guarded()swallows it, the result collapses to the same empty bag. Killing them would require removing the Principle feat: add @script-development/fs-adapter-store package #8 guard — correct code over a vanity 100%.The npm Trusted Publisher grant must be wired on npmjs.org before the first CI publish (org
script-development· repofs-packages· workflowpublish.yml· envnpm-publish), or the publish 404s. Commander console step — flagging it so the merge doesn't silently strand at no-publish.Adoption (follow-up, opt-in)
BIO + entreezuil migrate their local composables onto
useFormin separate PRs. Global-middleware territories (ublgenie/wijs/emmie) are retrofit targets, not drop-ins.🤖 Generated with Claude Code
https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq