Sync master to the shipped 1.6.0 release - #58
Merged
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings
masterup to the shipped 1.6.0 release. Opened fromsync/master-1.6.0rather thandevelopdirectly: 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 toorigin/develop. All four packages are published on npm at 1.6.0 with provenance, taggedv1.6.0.What shipped
Async validation you can act on.
ValidationResultgainscompleteandstatus('valid' | 'invalid' | 'pending') —validon its own read as a green light while a remote rule was still unanswered.validateFieldsAsyncforwards anAbortSignalto 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 exposesisValidating/isValidationComplete/validationStatuswith latest-run-wins.Bugs this fixed along the way:
onValid, noonInvalid, noisSubmitted, just a button that re-enabled itself. A submit now validates its own snapshot under its own controller.isValidwas seeded from an effect, so a server-rendered form shippedisValid: truefor an empty required field and never corrected it.undefined, which flipped the nested input into tracking touched itself and left it stale.touchAll()markedcontacts, notcontacts[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 throughexportsalone, which node10 resolution cannot read, and without it the package would not typecheck, test or build. ng-packagr 21 moved the emitted declarations, sotypes/exportswere repointed at the file that actually ships.Two manifest promises the packages could not keep, now corrected:
>=14while the form store importssignalandcomputed(Angular 16+). Installs on 14/15 succeeded and then threw on import. Now>=16 <22.typespointed atdist/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)
verify-package-entrypoints.jscheck-docs-api-references.jsverify-vue-peer-range.jsverify-angular-peer-range.jsVerification
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'sCJSResolvesToESMis inherent to an ESM-only Angular library) and against a realnode16ESM consumer (tscexit 0).