Skip to content

Sync master to the shipped 1.6.0 release - #58

Merged
vannt-dev merged 19 commits into
masterfrom
sync/master-1.6.0
Sep 3, 2026
Merged

Sync master to the shipped 1.6.0 release#58
vannt-dev merged 19 commits into
masterfrom
sync/master-1.6.0

Conversation

@vannt-dev

Copy link
Copy Markdown
Owner

Brings master up to the shipped 1.6.0 release. Opened from sync/master-1.6.0 rather than develop directly: master carries rebased copies of develop commits, so the two had diverged by SHA and a direct PR could not merge. This branch is develop rebased onto master (git skipped the 17 already-applied commits, no conflicts) and its tree is byte-identical to origin/develop. All four packages are published on npm at 1.6.0 with provenance, tagged v1.6.0.

What shipped

Async validation you can act on. ValidationResult gains complete and status ('valid' | 'invalid' | 'pending') — valid on its own read as a green light while a remote rule was still unanswered. validateFieldsAsync forwards an AbortSignal to validators, runs independent ones in parallel, and reports an aborted run as incomplete rather than valid. FieldDescription.validationMode: 'async' declares a Promise-returning validator so the live pass skips it instead of firing a request per keystroke. Each adapter exposes isValidating / isValidationComplete / validationStatus with latest-run-wins.

Bugs this fixed along the way:

  • A submit in flight was silently cancelled by typing — no onValid, no onInvalid, no isSubmitted, just a button that re-enabled itself. A submit now validates its own snapshot under its own controller.
  • React's isValid was seeded from an effect, so a server-rendered form shipped isValid: true for an empty required field and never corrected it.
  • Clearing the touched map did not reach inside repeatable groups: an item with no touched keys received undefined, which flipped the nested input into tracking touched itself and left it stale.
  • touchAll() marked contacts, not contacts[0].email, so per-item errors stayed hidden after an invalid submit.

Angular 21 and TypeScript 5.9. moduleResolution: "Bundler" for the package and the demo app — Angular 21 exposes every entry point through exports alone, which node10 resolution cannot read, and without it the package would not typecheck, test or build. ng-packagr 21 moved the emitted declarations, so types/exports were repointed at the file that actually ships.

Two manifest promises the packages could not keep, now corrected:

  • Angular declared >=14 while the form store imports signal and computed (Angular 16+). Installs on 14/15 succeeded and then threw on import. Now >=16 <22.
  • Angular's types pointed at dist/index.d.ts, which ng-packagr 21 no longer emits — the package would have published with types resolving to nothing.

Vue moves to ^3.2.0, alongside the scope-disposal cleanup that now aborts in-flight validation when a form unmounts.

New gates (CI verify job went from 4 checks to 8)

Check Catches
verify-package-entrypoints.js A manifest path the build does not emit
check-docs-api-references.js A documented import that no package exports
verify-vue-peer-range.js The Vue floor breaking — renders under 3.2 and the newest 3.x
verify-angular-peer-range.js The Angular range breaking — imports under 16 and 21, and links the published bundle with the floor's own linker, which is what would catch a library compiled too new for its consumers

Verification

Release run 33796072518: all quality gates green, then version → commit → publish → tag. Before the release, all four tarballs were packed and checked with arethetypeswrong (clean; Angular's CJSResolvesToESM is inherent to an ESM-only Angular library) and against a real node16 ESM consumer (tsc exit 0).

vannt-dev and others added 19 commits September 4, 2026 02:30
`changeset publish` writes a git tag per package, so 1.5.0 landed as four tags
that all point at the same commit and say the same thing:
`@dynamic-field-kit/{core,react,vue,angular}@1.5.0`. With all four packages on
a single version line by design, that is noise on the tag list and on the
releases page.

Its tagging is now off, and the workflow writes one annotated `v<version>` tag
instead, named after core - the anchor of the version line. The exact version
of each package goes in the tag body, which is what keeps the tag honest in the
one case where the names could drift apart: `linked`, unlike `fixed`, lets an
angular-only patch bump angular without touching the other three.
The check fires on every pull request, so the `develop -> master` sync that
follows a release fails it: that PR carries work whose changesets were consumed
by the release itself, and there is nothing left to add. Adding one would mean
releasing the same work a second time.

It now runs only on PRs into develop, which is the branch where unreleased work
accumulates and so the only branch where a missing changeset is a real defect.
The existing exemption for the release PR changesets opens stays as it was.
Each package carried three keywords, two of which were its own name. npm ranks
search partly on keywords, so these could realistically only be found by
someone who already knew what to type. The repository has had the right
vocabulary as GitHub topics since the start - dynamic-forms, form-builder,
form-engine, form-validation, schema-driven, headless, and the framework names
- and npm never saw any of it.

Each package now carries that vocabulary plus the terms its own users would
search, including the schema libraries it genuinely adapts: zod, yup, valibot
and Standard Schema. Deliberately not json-schema, which is a different thing
and not supported.

`homepage` pointed at the package README on GitHub, which is the same text npm
already renders on the package page from the shipped README - so the link led
back to where the reader already was. It now points at the live demo, where the
forms run. `repository` still carries the source.
…g it

Why: `FieldRendererProps` was a type nobody enforced. Each adapter hand-wrote
the object it handed the registered renderer, so the three lists drifted:
React dropped `placeholder`, `min`, `max`, `step`, `accept` and `multiple`;
Vue dropped `required`, `id`, `dirty` and the aria flags; Angular dropped
`touched`, `dirty` and `id`. Setting `placeholder` on a `FieldDescription`
therefore did nothing at all on React and Vue - no error, no warning, the
value simply vanished - and a renderer written for one framework could not be
ported to another, which is the opposite of the point of a shared schema.

Field ids had a second, unrelated problem: adapters built them as
`dfk-field-${name}`, from the field name alone. Two forms holding a field of
the same name emitted the same DOM id twice, which is invalid HTML and leaves
every `label[for]` pointing at two inputs.

What:
- `FIELD_RENDERER_PROP_KEYS` names the contract; `buildFieldRendererProps`
  produces the whole bag once, resolving disabled/readOnly/options, validating
  (skipping disabled fields, whose errors a user cannot act on) and setting the
  aria flags.
- `makeFieldId(field, prefix)` returns `field.id` when set, else
  `${prefix}-${name}`, so callers can namespace ids per form instance.
- `FieldDescription` gains `id`, to pin one field's id outright.
- `ariaDescribedBy` is deliberately left unset: no adapter renders the
  description or error node, so pointing it at an id that may not exist would
  be worse than omitting it.

How to test: `npm run test --workspace=@dynamic-field-kit/core`
Why: three of the reported bugs were one design flaw. Touched state had two
independent trackers that never met - `useDynamicForm`'s, and a private one
inside `MultiFieldInput` that only blur could set, which was the one renderers
actually saw. So `setFieldTouched` from an `onInvalid` handler changed nothing
visible, submitting a form nobody had focused showed no errors at all (the
button looked broken), and `reset()` could not clear touched left behind by an
earlier round, so a form that stays mounted across submits kept showing the
previous errors. Separately, ids came from the field name alone, so two forms
holding the same field name emitted duplicate DOM ids.

What:
- `MultiFieldInput` accepts `touched` as a controlled prop, making
  `useDynamicForm` the single source of truth exactly as `properties`/`onChange`
  already were for data, plus `onTouchedChange` and a `form` shorthand that
  wires data, change, blur and touched in one prop.
- Omitting `touched` keeps the old internal tracker, so nothing breaks; a
  forwardRef handle exposes `resetTouched`/`setFieldTouched`/`getTouched` for
  that mode, so clearing it no longer needs a `key` remount.
- `handleSubmit` calls the new `touchAll()` before validating; `resetTouched()`
  and `setTouched` round out the hook.
- Ids are namespaced per instance via `useId` (SSR-safe, and its delimiters are
  stripped so the result works as a CSS selector). `idPrefix` pins them -
  `idPrefix="dfk-field"` reproduces the old ids.
- `FieldInput` builds its props through core's `buildFieldRendererProps`, and
  `DynamicInput` spreads them rather than re-listing each one, which is how
  `placeholder`, `min`, `max`, `step`, `accept` and `multiple` went missing.

How to test: `npm run test --workspace=@dynamic-field-kit/react`
Why: the Vue adapter carried the same architecture as React, so it carried the
same three bugs - a private touched tracker the form store could not drive or
reset, and ids built from the field name alone that collide when two forms hold
the same field. On top of that its renderer prop list had drifted the other
way: `required`, `id`, `dirty` and the aria flags never reached a renderer, and
neither did `placeholder`.

What:
- `MultiFieldInput` gains a controlled `touched` prop, `onTouchedChange`, a
  `form` shorthand (which unwraps the composable's refs), and exposes
  `resetTouched`/`setFieldTouched`/`getTouched` for the uncontrolled mode.
- `useDynamicForm` gains `touchAll()`/`resetTouched()`, and `handleSubmit`
  touches everything before validating.
- Ids are namespaced per instance from the component uid; `idPrefix` pins them.
- `properties` defaults to undefined rather than `{}` so the `form` shorthand
  can tell "not passed" from "passed empty".
- `FieldInput` builds props through core's `buildFieldRendererProps`, and
  `DynamicInput` declares every contract key - an undeclared key is a
  fallthrough attribute, not a prop, which is why they were unreachable.
- `className` is delivered as `class`, and only as `class`. Forwarding
  `className` too lets it fall through to a renderer's root element, where Vue
  assigns `el.className`; an undefined value becomes `''` and wipes the class
  the renderer set on itself, which broke the bundled radio group.

How to test: `npm run test --workspace=@dynamic-field-kit/vue`
Why: this adapter was the worst off. `touched` was not in `DynamicInput`'s
KNOWN_PROPS and not an input on `BaseInputComponent`, so no Angular renderer
could ever receive it - `MultiFieldInput.isTouched()` existed but nothing in
the template called it, making it dead code. An Angular renderer therefore had
no way to do "only show the error once the user leaves the field" without
reimplementing blur tracking from scratch. `dirty` and `id` were missing the
same way, and ids were not emitted at all. The store's `touched` signal was
likewise disconnected from what rendered.

What:
- `BaseInputComponent`/`FieldInputProps` and `DynamicInput`'s KNOWN_PROPS now
  carry the whole contract: `touched`, `dirty`, `id`, the aria flags and
  `min`/`max`/`step`/`accept`/`multiple`. The HTML5 fallbacks set `id` too.
- `FieldInput` resolves everything through core's `buildFieldRendererProps`,
  once per change-detection pass rather than once per binding, and takes `data`
  so cross-field validation and dynamic options resolve against the real form.
  Explicitly bound inputs still override, so mounting it directly still works.
- `MultiFieldInput` gains a controlled `touched` input, a `touchedChange`
  output, public `resetTouched()`/`setFieldTouched()`, per-field `dirty`, and
  per-instance id namespacing with an `idPrefix` override.
- `createDynamicFormStore` gains `touchAll()`/`resetTouched()`, and
  `handleSubmit` touches everything before validating.
- Drops `getResolvedOptions`/`getDisabled`/`getReadOnly`/`getError` from
  `MultiFieldInput`: the template no longer calls them, and a second copy of
  that logic beside the shared one is how the adapters drifted apart to begin
  with. The core equivalents are already re-exported from this package.

The FieldInput spec's "withholds a dynamic options callback" case is updated
rather than kept: it encoded the old limitation that FieldInput had no form
data. It does now, so the callback resolves, matching React and Vue.

How to test: `npm run test --workspace=@dynamic-field-kit/angular`
…prop

Why: core now owns the renderer prop list, but each adapter still has to carry
those keys across its own component boundary - React through DynamicInput's
Props interface, Vue through its declared props (an undeclared key becomes a
fallthrough attribute, not a prop) and its forwarding call, Angular through
KNOWN_PROPS plus a matching @input. Nothing stopped one of them from quietly
dropping a key again, which is precisely how six props went missing on React,
four on Vue and four on Angular without a single test failing.

What: `scripts/check-renderer-prop-parity.js` parses the contract out of core
and probes each adapter's source for every key, with one documented exception -
Vue's `class` in place of `className`. Wired into the quality-gates verify job
and available as `npm run lint:renderer-parity`.

How to test: `node scripts/check-renderer-prop-parity.js`, then delete a key
from any adapter's list and watch it exit 1 naming the adapter and the prop.
… ownership

Why: the READMEs described a `FieldRendererProps` that no adapter actually
delivered in full - the snippets predated `touched`, `dirty`, `error`, the aria
flags and the numeric/file props - and said nothing about which adapter forwards
what. They also still implied touched could be driven through `setFieldTouched`
alone, the pattern that quietly did nothing.

What:
- Core and root READMEs carry the real interface, say that parity is enforced by
  a build check rather than convention, and name the one deliberate deviation
  (Vue's `class`) and the one prop no adapter fills in (`ariaDescribedBy`).
- Each adapter README documents the `form` shorthand as the recommended wiring,
  the controlled `touched` prop, `touchAll`/`resetTouched`, the ref/handle for
  the uncontrolled mode, and a "Field ids" section covering `idPrefix` and
  `FieldDescription.id`.
- Changeset spells out the one behaviour change: generated ids are no longer
  the literal `dfk-field-*`.

How to test: `npm run format-check`
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.
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.
The Angular package declared `@angular/core` and `@angular/common` as
`>=14 <22` while its form store imports `signal` and `computed`, which
Angular introduced in 16. npm accepted an install on 14 or 15 and the
package then threw on import - the manifest promised something it had not
been able to do for a long time. The range is now `>=16 <22`, so the same
install is refused up front. Verified both ways: Angular 16 and 21 import
and share one registry with core, and an install against 15 is now rejected.

Vue moves from `^3.0.0` to `^3.2.0`, because `useDynamicForm` now aborts
whatever is still in flight when the owning effect scope is disposed.
Without it an unmounted form held its request open until the response came
back. `getCurrentScope` and `onScopeDispose` are both Vue 3.2, and the
`getCurrentScope()` guard is for calling the composable outside a scope,
which the tests do.

Neither range was ever exercised at its floor - the suites only ever run
against whatever the workspace installs. `scripts/verify-vue-peer-range.js`
server-renders the packed tarballs under Vue 3.2 and the newest 3.x;
`scripts/verify-angular-peer-range.js` installs them against Angular 16 and
21 and checks the package imports, its components evaluate, and the registry
is shared. Both run in the CI verify job beside the React one. Angular is
import-level rather than render-level on purpose: the published fesm2022
needs the CLI's linker to instantiate a component, and import-and-wire is
the level that breaks across majors - which is precisely how a floor of 14
survived years of `signal()`.

Angular 21 deprecates `@angular/platform-browser-dynamic`. It is gone from
the package's devDependencies and from the demo app, which never used it
(it bootstraps with `bootstrapApplication`), and the test setup initialises
through `@angular/platform-browser/testing` instead.

Removing that devDependency meant regenerating the lockfile, and npm on
Windows prunes optional entries the Linux runners need while doing it - it
dropped @emnapi/core, @emnapi/runtime, @noble/hashes and yaml, which is
enough for Unknown command: "ci"

Did you mean this?
  npm ci # Clean install a project
To see a list of supported npm commands, run:
  npm help to refuse the lockfile on CI. They are restored here.
`collectFieldPaths`, `indexGroupPathMap` and the `ValidationContext` type
shipped in core with 1.6.0 but reached none of the adapters, so typing a
validator's `context` argument meant importing `@dynamic-field-kit/core`
alongside the adapter - the exact thing the curated re-export list exists to
avoid, and a drift the three lists are supposed to stay free of.

The adapter READMEs list them, document the corrected peer ranges, and
`scripts/add-dts-extensions.js` records that ng-packagr 21 emits one
rolled-up d.ts with no relative specifiers left to rewrite, so the step is a
no-op now and stays only as a guard.
Importing the package under Angular 16 proves it resolves and evaluates, but
not the thing that actually breaks a consumer across majors. The published
fesm2022 ships partial declarations, and a consumer's build links them with
*its* Angular. If building the library on a newer Angular raised their
`minVersion`, every consumer below that version would fail in the linker
while installing and importing perfectly well - the failure would surface in
their app, never here.

So the check runs the floor's own `@angular/compiler-cli` linker over the
bundle and asserts nothing is left partial. Against the ng-packagr 21 output
the Angular 16 linker consumes all of it: the declarations still carry
`minVersion` 12 and 14, because ng-packagr stamps what the emitted code
needs, not the compiler that emitted it.

Only the floor is linked. A newer linker accepting an older declaration is
the direction that was never in doubt.
… exist

The docs carry dozens of copy-paste import statements and nothing checked
them, so a rename lands, the READMEs keep the old name, and the first person
to find out is a reader whose editor cannot resolve it.

This reads every `import { ... } from '@dynamic-field-kit/*'` inside a fenced
code block and asserts each name is exported, resolving the export list from
each package's built .d.ts through the TypeScript compiler so type-only
exports count. Prose is ignored - only fenced blocks are checked, because
they are what people copy - and a package that has not been built is skipped
rather than treated as exporting nothing.

Proven non-vacuous the way the peer range checks are: adding an import of a
name that does not exist exits 1 and names the file and line.

The docs pass as they stand. The one thing worth knowing about the extractor
is that the brace span must exclude braces - `import { h } from 'vue'` above
an import of this package was otherwise swallowed into one statement, and
every name in the first import was reported missing.
@vannt-dev
vannt-dev merged commit e936f06 into master Sep 3, 2026
10 checks passed
@vannt-dev
vannt-dev deleted the sync/master-1.6.0 branch September 3, 2026 19:36
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