Skip to content

fix: one renderer prop contract, unique field ids, and a touched state the form store owns - #53

Merged
vannt-dev merged 6 commits into
developfrom
fix/renderer-prop-contract-and-touched-ownership
Sep 3, 2026
Merged

fix: one renderer prop contract, unique field ids, and a touched state the form store owns#53
vannt-dev merged 6 commits into
developfrom
fix/renderer-prop-contract-and-touched-ownership

Conversation

@vannt-dev

Copy link
Copy Markdown
Owner

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:

  • Besides placeholder, five more props were silently dropped on React and Vue: min, max, step, accept, multiple — all declared on both FieldDescription and FieldRendererProps.
  • Angular's MultiFieldInput.isTouched() was dead code: nothing in the template called it, and touched was not in DynamicInput's KNOWN_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.js fails the build if an adapter stops carrying a key across its own component boundary — verified by deleting touched from Angular and placeholder from 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 inside MultiFieldInput that only blur could set — and that was the one renderers saw. MultiFieldInput now accepts touched as a controlled prop, so the form store owns it exactly as properties/onChange already owned data. handleSubmit calls the new touchAll() before validating, and reset() clears it. Omit touched and the old internal tracker still runs, so nothing breaks; for that mode a ref (resetTouched()) clears it without a key remount.

Bug 1 was ids built from the field name alone. They are now namespaced per MultiFieldInput instance — React useId (SSR-safe), Vue's instance uid, a counter on Angular. idPrefix pins them; FieldDescription.id pins one field.

One deliberate deviation

Vue delivers className as class, and only as class. Forwarding className as well 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. 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

  • Generated ids are no longer the literal dfk-field-*. Anything pinned to one in CSS or a test needs idPrefix="dfk-field" or a per-field id.
  • Angular MultiFieldInput loses four undocumented template helpersgetResolvedOptions, 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.
  • One Angular spec was updated rather than kept: it asserted FieldInput withholds 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

Check Result
Tests core 126, react 127, vue 126, angular 104, scripts 38, smoke 2 — all pass, coverage floors met
Build / typecheck / lint / format-check clean
Example apps (react, vue, angular) all build against the new dist
parity / cross-framework / framework-deps / cross-registry checks pass

Regression 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 after reset(), and a vanishing placeholder. 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.

…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
vannt-dev merged commit 67e4eec into develop Sep 3, 2026
10 checks passed
@vannt-dev
vannt-dev deleted the fix/renderer-prop-contract-and-touched-ownership branch September 3, 2026 13:18
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