Skip to content

feat: async Validator support with stale-result protection (bd 9jk) - #71

Open
timkindberg wants to merge 10 commits into
mainfrom
jsonschema-form-9jk-async-validator
Open

feat: async Validator support with stale-result protection (bd 9jk)#71
timkindberg wants to merge 10 commits into
mainfrom
jsonschema-form-9jk-async-validator

Conversation

@timkindberg

Copy link
Copy Markdown
Owner

Summary

Implements bd 9jk — async validation with stale-result protection — in two logical commits:

  1. Core async seamAsyncValidator as a sibling of Validator (ADR 041), not a widening: one validation slot accepts either and branches on the returned value's Promise-shape, so sync callers are untouched. Adds toStandardSchemaAsync/fromStandardSchemaAsync, a uniformly-awaiting validator contract suite, and createZodAsyncValidator (via safeParseAsync).

  2. Store-owned React state (ADR 042–046, per ADR 044) — moves all form + validation state out of useFormTree's React state into a framework-neutral form store that also owns the async orchestration. useFormTree becomes a thin binding.

The store (zero React imports — the reuse seam ADR 008 will earn)

  • statusStore — ref-counted isValidating/isSubmitting + a failure surface, notifying only on the 0↔1 edges.
  • formStoregeneration authority (supersede-on-start staleness, no cancellation needed), retained errors (never blanked mid-flight), the dual-natured submit (errors gated by authority; onValid ungated on its own click-time verdict; isSubmitting spans an async onValid), the run-failure surface (thrown/rejected validator ≠ invalid verdict), and the sync/async branch on result shape.

React binding changes (public API churn — pre-1.0, ADR-sanctioned)

  • useFormTree owns one store, syncs the validator via an effect, and returns a bound SchemaFields that auto-provides the store — no manual <ValidationProvider> for the common path.
  • Dropped the validation return object.
  • showErrorsWhen moved to a useFormTree option and a reactive SchemaFields prop (toggle live, no input remount).
  • New fan-out-free selector hooks: useIsValidating / useIsSubmitting / useValidationFailure.
  • Exported FormStoreProvider for advanced/custom composition (e.g. an RHF bridge sits beside it, not against it).

Test plan

  • 19 framework-neutral orchestration tests (formStore.test.ts) covering the ADR 042–046 matrix: seam, authority/staleness, ref-counted pending, retained errors, dual-natured submit, failure surface.
  • 3 React async E2E tests (async-validation.test.tsx): pending signals, async success handler, live supersede.
  • 7 React test files + 4 example apps migrated to the new API.
  • npm run gate (typecheck + lint + format + test) green across all packages.

Reviewer notes

  • Best reviewed commit-by-commit: core seam first, then the React migration.
  • Out of scope (follow-ups): the RHF form-state-slot bridge (ADR 011/024 recipe) and product-facing async docs.

Made with Cursor

timkindberg added a commit that referenced this pull request Jul 15, 2026
Roadmap bookkeeping surfaced while implementing 9jk (PR #71): async docs +
example (5ss.8) and the RHF form-state-slot bridge recipe (5ss.9), both under
the V1 DX epic 5ss and discovered-from 9jk. Also records the 9jk
implementation-progress comment.

Co-authored-by: Cursor <cursoragent@cursor.com>
timkindberg added a commit that referenced this pull request Jul 16, 2026
)

From the three-lens library review (history/2026-07-15-…-tribe-review.md):

Soundness
- Single async detector: export `isThenable` from Core; use it in both
  `fromStandardSchema` (was `instanceof Promise`, missed cross-realm/library
  thenables → false valid verdict) and the React store.
- `ValidationResult<T>` is now a discriminated union on `valid` (`data?: never`
  on the invalid arm); AJV + the fake contract validator branch on the verdict.
  The submit-time `as Output` is retained as a documented assertion boundary.
- `statusStore` dec* now floors at zero so a double-settle can't wedge a
  pending boolean negative.

DX / coverage
- Add `formatValidationFailure(unknown): string|null` beside
  `useValidationFailure`; use it in example 16.
- Add a React-level test proving a rejecting validator surfaces via the hook.

Docs
- README leads with the batteries-included bound `SchemaFields`; documents the
  superseded-submit-still-fires-`onValid` hazard (+ disable-while-pending guard)
  and the swallowed `onValid` rejection; adds a migration note for the removed
  `validation` return. Fix stale `Validator` JSDoc + `displayPolicy` comment.

ADRs
- ADR 025 addendum: discriminated result + output-assertion boundary.
- ADR 046 post-implementation review addendum: fixes applied + deferred items.

Gate green (typecheck + lint + test).

Co-authored-by: Cursor <cursoragent@cursor.com>
timkindberg and others added 10 commits July 15, 2026 22:56
…9jk)

Introduce AsyncValidator as a sibling of Validator (ADR 041) rather than
widening the sync seam: a single validation slot accepts either and branches
on the returned value's Promise-shape, so synchronous callers are untouched.

- core: AsyncValidator type + re-exports; toStandardSchemaAsync /
  fromStandardSchemaAsync adapters, with shared result-mapping helpers.
- validation-contract: the contract suite awaits uniformly so sync and async
  validators run identical purity/error-mapping assertions.
- validation-zod: createZodAsyncValidator via safeParseAsync, sharing the
  Zod->ValidationResult mapping with the sync path.

Co-authored-by: Cursor <cursoragent@cursor.com>
… async orchestration (bd 9jk)

Move all form + validation state out of useFormTree's React state into a
framework-neutral form store (ADR 044), which also owns the async validation
orchestration (ADR 042-046). useFormTree becomes a thin binding.

Store (zero React imports; the reuse seam ADR 008 will earn):
- statusStore: ref-counted isValidating/isSubmitting + a failure surface,
  notifying only on the 0<->1 edges.
- formStore: generation authority (supersede-on-start staleness), retained
  errors, the dual-natured submit (errors gated by authority; onValid ungated
  on its own click-time verdict; isSubmitting spans an async onValid), and the
  sync/async branch on result Promise-shape. Validator is swappable in place.

React binding:
- useFormTree instantiates one store, syncs the validator via an effect, and
  returns a bound SchemaFields that auto-provides the store (no manual
  ValidationProvider). Dropped the `validation` return object.
- showErrorsWhen is now a hook option AND a reactive SchemaFields prop.
- New fan-out-free selector hooks: useIsValidating / useIsSubmitting /
  useValidationFailure; exported FormStoreProvider for advanced composition.

Tests/examples: 19 store-level orchestration tests + 3 React async E2E tests;
7 React test files and 4 example apps migrated to the new API. Full gate green.

Co-authored-by: Cursor <cursoragent@cursor.com>
Roadmap bookkeeping surfaced while implementing 9jk (PR #71): async docs +
example (5ss.8) and the RHF form-state-slot bridge recipe (5ss.9), both under
the V1 DX epic 5ss and discovered-from 9jk. Also records the 9jk
implementation-progress comment.

Co-authored-by: Cursor <cursoragent@cursor.com>
…rnesses

The render-count harnesses render a custom counting adapter under
FormStoreProvider (not the bound SchemaFields), so the display policy must be
set on the provider, not the useFormTree option. The validation harness needs
'always' for the errored field to render; passing it to the hook option left
the provider at the 'touched' default, so the untouched field never showed its
error and the render-count assertion failed. Move the policy to the provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add examples/basic-react example 16 demonstrating the async validation
feature from 9jk end-to-end: an async validator (createZodAsyncValidator
over a remote uniqueness rule), pending signals (useIsValidating /
useIsSubmitting), run-failure surface (useValidationFailure) distinct
from an invalid verdict, stale-result protection, and an awaited onValid.

Rewrite the README validation section onto the store-owned API
(FormStoreProvider + store, dropping the removed `validation` return) and
add an async subsection. Ratifies the public names chosen in 9jk
(useIsValidating/useIsSubmitting/useValidationFailure/FormStoreProvider).

Widen the async React test's in-flight delay so the transient pending
window is reliably observable (was racing Playwright click overhead).

Closes 5ss.8.

Co-authored-by: Cursor <cursoragent@cursor.com>
The async uniqueness rule was an object-level Zod refine, which only runs
after every field passes — so the username check didn't fire until the
email was also valid. Scope it to the username field (baseSchema.shape)
so the remote check runs as soon as the username itself is valid, and
drop the redundant onChange so it's one check per field visit (on blur).

Also file jsonschema-form-62a: ValidationSummary lists errors in
validator order, not field/DOM order.

Co-authored-by: Cursor <cursoragent@cursor.com>
Example 16: add a value-cache on the simulated remote check and a
skip-when-unchanged blur guard, so tabbing through unedited fields no
longer re-fires the async check (the library runs the whole validator per
trigger and any async rule makes the whole schema async — dedupe/debounce
are consumer-owned, ADR 021). Correct the field-level refine comment to
state the real cause: an invalid_type on any field aborts object-level
refines, and submit omits empty inputs so a blank field is undefined.

README: add a "costs and caveats" note to the async section covering the
whole-validator-per-trigger cost and the field- vs object-level .refine
gotcha.

Co-authored-by: Cursor <cursoragent@cursor.com>
)

From the three-lens library review (history/2026-07-15-…-tribe-review.md):

Soundness
- Single async detector: export `isThenable` from Core; use it in both
  `fromStandardSchema` (was `instanceof Promise`, missed cross-realm/library
  thenables → false valid verdict) and the React store.
- `ValidationResult<T>` is now a discriminated union on `valid` (`data?: never`
  on the invalid arm); AJV + the fake contract validator branch on the verdict.
  The submit-time `as Output` is retained as a documented assertion boundary.
- `statusStore` dec* now floors at zero so a double-settle can't wedge a
  pending boolean negative.

DX / coverage
- Add `formatValidationFailure(unknown): string|null` beside
  `useValidationFailure`; use it in example 16.
- Add a React-level test proving a rejecting validator surfaces via the hook.

Docs
- README leads with the batteries-included bound `SchemaFields`; documents the
  superseded-submit-still-fires-`onValid` hazard (+ disable-while-pending guard)
  and the swallowed `onValid` rejection; adds a migration note for the removed
  `validation` return. Fix stale `Validator` JSDoc + `displayPolicy` comment.

ADRs
- ADR 025 addendum: discriminated result + output-assertion boundary.
- ADR 046 post-implementation review addendum: fixes applied + deferred items.

Gate green (typecheck + lint + test).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The async-validation branch replaced ValidationProvider + useFormTree's
`errors` return with FormStoreProvider + `store`. Rebasing onto main
pulled in the renderNodeRules customize examples (16/17), which still used
the old API; update them so the combined tree typechecks.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant