fix: one renderer prop contract, unique field ids, and a touched state the form store owns - #53
Merged
vannt-dev merged 6 commits intoSep 3, 2026
Conversation
…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`
vannt-dev
deleted the
fix/renderer-prop-contract-and-touched-ownership
branch
September 3, 2026 13:18
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.
Fixes the five issues reported against 1.5.1, on all three adapters.
Every claim below was checked against the actual source, not just the published
dist/. Two things the report did not catch turned up on the way:placeholder, five more props were silently dropped on React and Vue:min,max,step,accept,multiple— all declared on bothFieldDescriptionandFieldRendererProps.MultiFieldInput.isTouched()was dead code: nothing in the template called it, andtouchedwas not inDynamicInput'sKNOWN_PROPS, so it had no path to a renderer even if it had been.The two root causes
Gaps 4 and 5 are one bug. Each adapter hand-wrote the object it handed the registered renderer, so three lists drifted apart with nothing to stop them. Core now owns the list (
FIELD_RENDERER_PROP_KEYS), builds the bag once (buildFieldRendererProps), and all three adapters call it.scripts/check-renderer-prop-parity.jsfails the build if an adapter stops carrying a key across its own component boundary — verified by deletingtouchedfrom Angular andplaceholderfrom React and watching it exit 1 naming both.Bugs 2 and 3 are one design flaw. Touched had two trackers that never met:
useDynamicForm's, and a private one insideMultiFieldInputthat only blur could set — and that was the one renderers saw.MultiFieldInputnow acceptstouchedas a controlled prop, so the form store owns it exactly asproperties/onChangealready owned data.handleSubmitcalls the newtouchAll()before validating, andreset()clears it. Omittouchedand the old internal tracker still runs, so nothing breaks; for that mode a ref (resetTouched()) clears it without akeyremount.Bug 1 was ids built from the field name alone. They are now namespaced per
MultiFieldInputinstance — ReactuseId(SSR-safe), Vue's instance uid, a counter on Angular.idPrefixpins them;FieldDescription.idpins one field.One deliberate deviation
Vue delivers
classNameasclass, and only asclass. ForwardingclassNameas well lets it fall through to a renderer's root element, where Vue assignsel.className— an undefined value becomes''and wipes the class the renderer set on itself. The repo's own radio-group test caught this. It is a framework constraint, not drift, and is documented in the code, the READMEs and the parity script's exception list.Behaviour changes
dfk-field-*. Anything pinned to one in CSS or a test needsidPrefix="dfk-field"or a per-fieldid.MultiFieldInputloses four undocumented template helpers —getResolvedOptions,getDisabled,getReadOnly,getError— orphaned by this change. Keeping 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 the package.FieldInputwithholds a dynamic options callback, which encoded the old limitation that it had no form data. It does now, so the callback resolves — matching React and Vue.Verification
distRegression tests live in
packages/{react,vue,angular}/test/reportedIssues.*, one file per adapter reconstructing each reported scenario: two forms sharing a field name, submitting an untouched form, an error still showing red afterreset(), and a vanishingplaceholder. Each also covers the worst case of bug 1 that the report did not reach — a repeatable group, where three items sharing a field name previously produced three identical ids.Changeset: minor across all four packages.