Skip to content

feat(validation): cancellable, status-aware async validation on Angular 21 - #55

Merged
vannt-dev merged 3 commits into
developfrom
feat/async-validation-status-and-angular-21
Sep 3, 2026
Merged

feat(validation): cancellable, status-aware async validation on Angular 21#55
vannt-dev merged 3 commits into
developfrom
feat/async-validation-status-and-angular-21

Conversation

@vannt-dev

@vannt-dev vannt-dev commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Three commits, meant to be read in order.

1. chore(deps): upgrade to Angular 21 and TypeScript 5.9

Angular 19 → 21 across the package, the demo app and the root, plus TypeScript 5.9.3, zone.js 0.16, jest-dom 7 and @types/node 26. Three things blocked the bump:

  • Angular 21 exposes every entry point through exports alone, and tsconfig.base.json asks for node10 resolution, which cannot read an exports map. tsc -p packages/angular reported 16 × TS2307 and all 11 spec files died in test/setup.ts. The Angular tsconfig now uses moduleResolution: "Bundler".
  • ng-packagr 21 emits dist/types/<name>.d.ts, not dist/index.d.ts, so the manifest's types pointed at a file the build no longer produces — the package would have published with types resolving to nothing. Fixed, and scripts/verify-package-entrypoints.js now fails the build on any manifest path the build does not emit (wired into the quality-gates verify job).
  • The lockfile was unusable: ten entries under packages/angular/node_modules had no resolved/integrity, and there was no entry for injection-js, which ng-packagr 21 requires. npm ci refused it and the Angular build died on the missing module. Regenerated with npm 10 (npm 11 prunes other platforms' optional binaries on Windows); every platform binary the Linux runners need is still present.

The two vitest.config.ts files Vite warned about are renamed to .mts, contents unchanged.

2. feat(validation): make async validation cancellable and status-aware

  • ValidationResult gains complete and status ('valid' | 'invalid' | 'pending'). valid alongside pending read as a green light while remote rules were still in flight.
  • FieldDescription.validationMode: 'async' declares a Promise-returning validator that detection cannot see, keeps the sync pass from invoking it, and silences the dev warning (which exists for the accidental case).
  • validateFieldsAsync forwards an AbortSignal to validators, runs independent ones in parallel, skips them once aborted, and reports an aborted run as incomplete. A validator that honours the signal by rejecting with AbortError no longer rejects the caller's handleSubmit; other errors still propagate.
  • Every adapter exposes isValidating / isValidationComplete / validationStatus with latest-run-wins. A submit now survives typing: previously, editing a field mid-submit cancelled it outright — no onValid, no onInvalid, no isSubmitted, just a button that re-enabled itself.
  • touchAll() expands to real leaf paths (contacts[0].email) via collectFieldPaths, skipping what validation skips. Group items receive touched and report blur with their full path. An item with no touched keys still receives a map — handing it undefined flipped the nested input into tracking touched itself, which survived the owner clearing the map.
  • React isValid is seeded from the initial data, not an effect. Effects do not run on the server, so a server-rendered form shipped isValid: true for an empty required field and never corrected it.
  • React stops handing the touched map to every field (each blur re-rendered the whole form); Angular's per-item maps return a shared frozen object instead of a fresh literal on every change-detection pass.

Changeset included: minor on all four packages.

3. docs: bring the READMEs in line with the validation API

The docs still described ValidationResult as { valid, errors } and said nothing about what the adapters now expose, so a reader had no way to know that valid alone is not the answer.

  • core: the full result shape, why status is the member to read, validationMode: 'async', and the ValidationContext / AbortSignal argument with a fetch example. Export list gains collectFieldPaths, indexGroupPathMap, ValidationContext.
  • react / vue / angular: state tables gain isValidating, isValidationComplete, validationStatus; the live-validation paragraph now states latest-run-wins and that a submit is not cancelled by typing.
  • root README: validate signature includes the context argument, a validationMode row sits beside it, the example reads status, and the touched section explains full-path keying inside repeatable groups.
  • ui-kit recipes: a table of which member to bind for what.
  • angular README: the (Angular 19+) heading is replaced by the range the package declares (>=14 <22) and the version CI exercises (21).

Verification

Gate Result
npm ci clean (was: out of sync, refused)
lint / format-check pass / pass
typecheck + core test:types pass, 9 tests, no type errors
core / react / vue / angular 151 / 143 / 134 / 110 (angular ran 0 before)
smoke / test:scripts 2 / 44
build, all four packages pass (angular failed to build before)
verify scripts 5/5, including the new entry-point check
example apps (react / vue / angular) all three build (angular-app needed the same moduleResolution fix)

Every behaviour fix landed test-first; the Vue and Angular submit fixes were re-verified by temporarily reverting the guard to confirm the new tests fail without it.

@vannt-dev
vannt-dev force-pushed the feat/async-validation-status-and-angular-21 branch from a76787f to c58e288 Compare September 3, 2026 18:29
Angular 19 -> 21 across the package, the demo app and the root, plus
TypeScript 5.9.3, zone.js 0.16, @testing-library/jest-dom 7 and
@types/node 26. Three things had to change for the bump to actually work.

Angular 21 ships every entry point through `exports` alone, with no `main`
or `types`. `tsconfig.base.json` still asks for node10 resolution, which
cannot read an `exports` map, so the Angular package stopped resolving its
own dependencies: `tsc -p packages/angular` reported 16 x TS2307, and every
spec died in `test/setup.ts` on `@angular/core/testing`. Both the
Angular package tsconfig and the demo app's now use `moduleResolution:
"Bundler"`, which is what the Angular CLI itself uses - the demo app failed
the same way, on `@angular/common/http` and `@angular/core/primitives/di`.

ng-packagr 21 emits the type declarations as `dist/types/<name>.d.ts`
instead of `dist/index.d.ts`, so the manifest's `types` pointed at a file
the build no longer produces - the package would have published with types
that resolve to nothing. `types` and `exports` now name the file that
actually ships. `scripts/verify-package-entrypoints.js` fails the build on
any manifest path that the build does not emit, so this cannot come back
quietly; it runs in the quality-gates verify job next to the other checks.

The lockfile was regenerated with npm 10 (npm 11 prunes other platforms'
optional binaries on Windows). The previous one had ten entries under
packages/angular/node_modules with no `resolved`/`integrity` at all and no
entry for `injection-js`, which ng-packagr 21 requires - `npm ci` refused
it outright and the Angular build died on the missing module. Every
platform binary the Linux runners need is still present.

The two `vitest.config.ts` files that Vite warned about are renamed to
`.mts`; the contents are unchanged and nothing referenced them by path.
`ValidationResult` gains `complete` and `status`. Combining `valid` with
`pending` was the only way to tell "nothing is wrong" from "nothing is
wrong yet", and it reads as a green light either way, so a form with a
remote rule still in flight looked valid. `status` is the single answer.

`FieldDescription.validationMode: 'async'` declares a validator that
returns a Promise without the `async` keyword, which detection cannot see.
Declaring it keeps the sync pass from invoking the validator at all, and
silences the dev warning, which exists to catch the accidental case.

`validateFieldsAsync` takes a `ValidationContext`, forwards its
`AbortSignal` to every validator, runs independent validators in parallel,
skips validators once the signal is aborted, and reports an aborted run as
incomplete. A validator that honours the signal the conventional way - by
rejecting with an `AbortError` - no longer rejects the caller's
`handleSubmit`; an error that is not an abort still propagates.

Every adapter exposes `isValidating`, `isValidationComplete` and
`validationStatus`, and applies latest-run-wins so a stale result cannot
overwrite a newer one. A submit is not collateral damage of that: it
validates the snapshot the user submitted under a controller of its own.
Before this, typing while a submit was in flight cancelled it outright -
no `onValid`, no `onInvalid`, no `isSubmitted`, just a button that
re-enabled itself.

`touchAll()` expands to the leaf paths that exist in the data
(`contacts[0].email`, not `contacts`) through the new `collectFieldPaths`,
which skips what validation skips - fields hidden by `appearCondition` and
disabled ones. Group items receive `touched` and report blur with their
full path, so "show the error once the field is touched" works inside a
repeatable group. An item with no touched keys still receives a map: handing
it `undefined` flipped the nested input into tracking touched by itself,
which then survived the owner clearing the map. `indexGroupPathMap` indexes
those maps by item and is exported for custom renderers.

React seeds `isValid` from the initial data rather than from an effect.
Effects do not run on the server, so a server-rendered form shipped
`isValid: true` for an empty required field and never corrected it. React
also stops handing the whole touched map to every field - only repeatable
groups read it, and passing it everywhere re-rendered every field on each
blur - and its `isValidationComplete` now matches Vue and Angular.

Angular's per-item error and touched maps return a shared frozen object
rather than a fresh literal, which was a new binding identity on every
change detection pass.
@vannt-dev
vannt-dev force-pushed the feat/async-validation-status-and-angular-21 branch from c58e288 to 349eeeb Compare September 3, 2026 18:36
The docs still described `ValidationResult` as `{ valid, errors }` and said
nothing about what the adapters now expose, so a reader had no way to know
that `valid` alone is not the answer.

Core: documents the full result shape, why `status` is the member to read,
`validationMode: 'async'` for a validator that returns a Promise without the
keyword, the `ValidationContext`/`AbortSignal` argument with a fetch example,
and that independent validators run in parallel. The export list gains
`collectFieldPaths`, `indexGroupPathMap` and `ValidationContext`.

React, Vue and Angular: their state tables gain `isValidating`,
`isValidationComplete` and `validationStatus`, and the paragraph about live
validation now says what actually happens - latest-run-wins, typing cancels a
live run, a submit is not cancelled by typing.

Root README: the `validate` signature includes the context argument, there is
a `validationMode` row beside it, the validation example reads `status`, and
the touched section says the map is keyed by full path, so it reaches inside
repeatable groups and skips fields validation itself skips.

The UI-kit recipes gain a table of which member to bind for what, and the
Angular README replaces its "(Angular 19+)" heading with the range the package
actually declares and the version CI exercises.
@vannt-dev
vannt-dev merged commit 9055971 into develop Sep 3, 2026
10 checks passed
@vannt-dev
vannt-dev deleted the feat/async-validation-status-and-angular-21 branch September 3, 2026 18:43
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