feat: 1.7.0 — dirty baseline, aria wiring, message catalog, async options - #60
Merged
Conversation
…Form The dirty baseline was implicit and unreachable, so nothing outside the hook could tell which values a form opened with. reset(newValues) now re-bases it, and getDirtyValues reads it for PATCH-style submits.
The baseline was captured once with useRef at mount and never reassigned,
so every field compared against pre-reset values after reset(newValues),
and against {} when properties arrived from a fetch - reporting the whole
form dirty forever. It now comes from whoever owns the values: the bound
form's baselineValues, an explicit initialProperties prop, or the first
non-undefined properties seen.
handleChange validated eagerly and the [fields, data] effect validated the identical object again after commit. The effect now skips data it has already seen, and re-arms when fields change identity so a schema swap still revalidates untouched data. Vue and Angular do not have this bug - neither store runs a watch or effect, so both already validate exactly once in handleChange.
Mirrors the React adapter so the dirty baseline is reachable from outside the composable and survives reset(newValues).
Matches the React adapter: the baseline was a const snapshot taken in setup() and never reassigned. It now tracks the first non-undefined properties, or comes from initialProperties / form.baselineValues. The first-seen tracker is a ref rather than a plain binding because the baseline is a computed, which only re-evaluates on reactive reads.
… store Mirrors the React and Vue adapters.
Completes the three-adapter fix. This adapter turned out to be the least affected: init() only records a baseline when properties is set, so values arriving after mount were already handled correctly - the late-load test added here passed before the fix. What was broken is that the initialised guard pinned the baseline permanently, so a store reset could never move it. The new initialProperties input is that escape hatch; pass store.baselineValues() into it. The private field is renamed to firstSeenProperties to free the name for the input, matching React and Vue.
It was hard-coded undefined, so focusFirstInvalidField - which selects [aria-invalid=true] - had nothing to find for consumers whose renderers followed the official recipe. makeErrorId defines the convention once, in one place, for all three adapters.
1.6.0 moved placeholder, min, max, step, accept and multiple to the top level of FieldDescription. Values left behind in props are overwritten by the resolved contract and vanish with no throw and no warning - the one upgrade hazard a consumer cannot diagnose from the outside. Fires once per field+key and only outside production.
The default renderers forwarded aria-describedby but never rendered the error they were handed, so the reference had nothing to point at. Emitted as a fragment sibling - no wrapper element, so layout is unchanged - and only where no custom renderer is registered, so consumers rendering their own message do not get a second copy.
Mirrors the React adapter's markup exactly, returned as a fragment array so no wrapper element appears. This adapter declares error as [String, Array], unlike React where core only ever supplies an array, so the message is normalised before use - indexing a raw string would have rendered its first character.
Completes the three-adapter error node, so aria-describedby resolves on every adapter instead of dangling. Two adapter-specific notes. The template uses *ngIf, not the @if block: the peer range starts at Angular 16 and block control flow is 17+. And the condition asks the registry directly rather than reading a flag set in render(), which runs in ngAfterViewInit - by then this template's bindings are already checked for the pass, and under OnPush nothing would mark them dirty again.
Also re-exports makeErrorId from all three adapters - check-docs-api- references caught that the recipe imported it from the react package, which did not have it. Angular re-exported none of the renderer-prop helpers, so it gains buildFieldRendererProps, makeFieldId and FIELD_RENDERER_PROP_KEYS alongside, matching react and vue.
Validation messages could only be set per field, per form, by passing a string to each validator - so translating a form meant touching every field description. t lives on the existing ValidationContext rather than a new parameter: FieldDescription.validate already takes that context as its fourth argument, so there was no free slot and no need to invent one. An async validator gets the resolver for free as a result.
Each validator computed its message when the field description was built, so a catalog could never reach it. Resolution moves inside the returned closure, with an explicitly passed string still winning over any catalog - every existing call site behaves identically. validators.matches lands in the same change because its default message needs that machinery; every consumer was hand-writing the same (value, data) => value !== data.other for confirm-password fields.
validateField and validateFields gain a trailing optional context, so a catalog reaches the validators - including inside repeatable groups, where the recursive call now forwards it. validateFieldsAsync needed no change: its options bag already is the ValidationContext and was already threaded recursively, so t flows there as soon as a caller supplies it.
Both validateFieldsAsync call sites spread the context before setting signal, rather than replacing the options object - dropping the signal there would silently disable run cancellation.
debounceMs was declared in FieldDescription, published in the .d.ts and read by no implementation anywhere - setting it did nothing. It now debounces this loader. Everything hard lives here rather than three times over in the adapters: debounce, abort of a superseded run, a run counter that discards an out-of-order response even when the signal is ignored, and shallow deps comparison. An AbortError is deliberately not an error state - being superseded is normal and would otherwise flash a failure on every keystroke of a search box. options takes one signature, not a union of sync and async shapes: a union defeats TypeScript's contextual inference, so every existing options: (data) => ... would have started erroring under noImplicitAny. Returning a promise is what makes a loader async. resolveOptions now returns undefined for those, so no renderer is handed a Promise.
optionsStatus and optionsError join FIELD_RENDERER_PROP_KEYS, so lint:renderer-parity now requires all three adapters to forward them - it currently fails on angular, which the next commits fix. onOptionsQuery is deliberately not in that list: it is a callback, attached alongside onValueChange and onBlur, and putting it there would make the parity script check the wrong kind of thing.
Two things this needed beyond wiring the loader in. FieldInput's memo comparator only compares this field's own slice of the data, so a field whose optionsDeps read *another* field would never re-render to notice the change - a country/city pair would load once and never again. Async-options fields now compare the whole data object. resolveOptions now drops a promise returned by a loader that detection missed, and warns with the fix. constructor.name === 'AsyncFunction' does not survive a memoiser, a spy or a transpiler helper, and handing the renderer a pending promise as its option list is worse than an empty one. This mirrors what the validate path already does.
Mirrors the React adapter. The watch is deep on the whole data object because optionsDeps can read another field's value, and the loader - not the component - decides whether anything it cares about changed.
Completes the three-adapter loader; lint:renderer-parity now reports 23 props instead of 21. The loader callback calls markForCheck: these components are OnPush, so an async arrival happens outside any event the view is checked for and the options would otherwise load and never appear. onOptionsQuery is declared on BaseInputComponent only - redeclaring it on DynamicInput is a TS4114-class error under useDefineForClassFields.
The root README covered the new API; the per-package ones did not mention any of it. Each adapter README now documents baselineValues, getDirtyValues, the messages catalog, initialProperties, async options and the default renderers' new error node, and the core README carries the full catalog key table and the async options reference the adapters link to. One paragraph in the core README had become actively wrong: it still said ariaDescribedBy is the one prop no adapter fills in. It now explains what replaced that and why the old reasoning, though sound, left focusFirstInvalidField doing nothing.
Seven defects in code added by this branch, each with a test that fails
without the fix.
react: the options loader was built in the render body and disposed by
the effect cleanup, but the ref was never cleared - StrictMode's
mount/cleanup/mount left every field holding a disposed loader, so async
options sat at 'loading' forever in any development build. It is now
created lazily and re-created after disposal.
angular: onOptionsQuery never reached a renderer. applyProps iterates
KNOWN_PROPS, which deliberately excludes callbacks, so search-remote was
dead on this adapter despite being documented. Callbacks now have their
own pass, covering the component, the sync and the fallback paths.
angular: the HTML5 fallback never set aria-invalid or aria-describedby,
so the new error node had nothing pointing at it and
focusFirstInvalidField still found nothing - the exact failure the
migration guide claims is fixed. Applied once after the fallback builds,
rather than in each of its four branches.
react: the default error node rendered error[0] of a bare string, which
is its first character. Vue and Angular already normalised; React did
not, and DynamicInput is publicly exported with error typed
string | string[].
core: a retry after a failed load emitted status 'loading' while still
carrying the previous optionsError. The loading transition now clears it.
core: isAsyncOptions returned true for optionsMode: 'async' on a field
whose options is a static array, and fetchNow then threw "load is not a
function" out of a lifecycle hook. It now requires a callable first.
angular: swapping a field from async to synchronous options left the
stale optionsState winning in buildFieldRendererProps, serving the old
list forever with the loader never disposed.
The one finding not fixed is the baseline when properties starts as {}
rather than undefined: {} is a real value, and a form that opens blank
cannot be told apart from one still waiting on a fetch. Characterised by
two tests and documented as a caveat with initialProperties as the
escape hatch.
Changing a field's name swaps the loader (adapters key each field by name, so it remounts); changing only the options closure on a same-named field does not. That asymmetry is deliberate and now has a test saying so. Rebuilding on closure identity would refetch in a loop for the very common case of a fields array built inline in a component body, which gets a fresh closure on every render.
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.
What
Everything in a consumer's 1.5.1 → 1.6.0 upgrade report that survived
verification, as one minor release. Five confirmed bugs fixed, four gaps filled:
dirtywas measured against a baseline captured at mount and neverre-based. Wrong after
reset(newValues)on all three adapters, and onReact/Vue wrong for values arriving from a fetch after mount — every field
read
dirty: trueforever. AddsbaselineValues+getDirtyValues()to theform store and an
initialPropertiesprop toMultiFieldInput.ariaDescribedBywas hard-codedundefined, sofocusFirstInvalidField(which selects
[aria-invalid="true"]) silently did nothing for anyonefollowing the official renderer recipe. Adds
makeErrorId, wires the recipe,and makes the default renderers render the message they were already handed.
debounceMswas a dead API — declared, published in the.d.ts, read bynothing anywhere. It now debounces async options loading.
propsshadowing a contract key vanished silently. Dev-mode warning added.options(dependent + search-remote), a validation messagecatalog, and
validators.matches.Why
The report came from integrating this library across a real Task Manager app.
Each claim was re-verified against the 1.6.0 source before being acted on —
three did not hold up, and are recorded as rejected in the design doc rather
than silently dropped:
MultiFieldInputvalidates without anonValidityChangelistener" — false,?.()short-circuits and never evaluates its argument. The real duplicatelived in
useDynamicForm.validateFieldsis synchronous and never invokesasyncValidate.three separate packages and
check-cross-framework-importsalready gates it.Three problems the report did not mention were found and fixed here: the
async-load half of the
dirtybug (more common than the reported reset case),FieldInput's memo comparator hiding data changes from dependent-optionsfields, and
constructor.name === 'AsyncFunction'not surviving a memoiser orspy — which let a promise reach a renderer as its option list.
How to test
The two bug-fix commits each start from a test that failed before them:
Adapter differences, all deliberate and explained in the commits that introduce
them: Angular never had the
dirtylate-load bug (itsinit()only records abaseline once
propertiesis set) and has noformshorthand, so it takesstore.baselineValues()throughinitialProperties; the duplicate validationwas React-only, since neither the Vue composable nor the Angular store runs a
watch or effect.
npx changeset) if any package underpackages/changedOne visible change
Default renderers now render the validation message they receive. They
previously accepted
errorand dropped it, so a form using the built-ins showednothing at all. Custom renderers are unaffected — the node is emitted only where
no renderer is registered, so nobody gets two copies of their own message.
docs/MIGRATING.mdcalls this out, and.dfk-field-error { display: none }restores the old silence.
The design doc and the four implementation plans are under
docs/superpowers/,which this repo gitignores by convention, so they stay local.