Changelog
All notable changes to @dynamicforms/vue-forms will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.17.1] - 2026-08-21
Changed
An
AbortEventHandlingExceptionraised in a chain that has an asynchronous handler in it is answered with rather than raised, the way it is in a wholly synchronous one.Action.execute()resolves with the exception instead of rejecting with it, so a caller that read it off acatchbranch reads it off the answer:typescriptconst answer = await save.execute(); if (answer instanceof AbortEventHandlingException) reportRefusal(answer.message);A handler that reads an abort off its own
suprcall is unaffected: inside the chain it is a throw, and only the trigger converts it. A rejection carrying anything other than an abort still reaches the caller as a rejection.
Fixed
- A handler that ends its run from an asynchronous handler leaves no unhandled rejection behind. The
*Changed*setters discard what the trigger answers with, so an abort travelling as a rejection reached nothing but the runtime's unhandled-rejection reporting - under node's default settings, the end of the process. ActionsMap.triggerEager()ends only the identifier group the abort was raised in where that group is asynchronous, as it does where it is synchronous. The other groups run, and the abort is not reported as unhandled.
[0.17.0] - 2026-08-20
Added
Extras, an empty exported interface that is what theXtype argument of every element defaults to. A UI layer declares the properties it renders forms with once, by augmentingExtrasin adeclare moduleblock, and every element in the consuming application then carries them — including the fields written inline in aGroupdeclaration, which no type argument on the group can annotate. Where an element states anXof its own it replaces the default, soField<string, Extras & Local>is how it carries both, andActiondefaults toExtraswithout the keys ofActionValuebecauselabelandiconare members it declares itself. The default reachesFieldBase<T>as well, so a validator or an action handler reads the augmented properties off the element it receives without a cast. Nothing changes for existing code:Extrasis empty until something augments it, and an element that states its ownXis unaffected.effectiveEnabledon every element:truewhere the element and every container above it are enabled. A rendering layer binds it to draw the inputs of a disabled section disabled, instead of walking the parent chain for each of them. It is a read —enabledon each element stays what was written to it, a write to a member of a disabled container is accepted as before, and what a container serializes is decided by the members' ownenabled.
Changed
Breaking.
ActionValuedeclareslabelandiconasunknown, andAction's accessors for them read their type off the value rather than fixingstring | undefined. What a label and an icon are is the rendering library's to say, andunknownis what lets it say so:interface RichValue extends ActionValue { label?: string | MdString }is legal where astringin the base refused it, and a subclass cannot widen an accessor the base class typed — that isTS2416, which no cast on the subclass's side reaches. A subclass therefore restates the two members in its value type and the inherited accessors answer at that type, with no accessor override needed unless the read is to be filtered.What moves for a consumer: on an
Actionthat states no value type,action.labelandaction.iconread asunknown, soaction.label?.toUpperCase()no longer compiles and the reader states what it expects —action .label as string | undefined, or a value type on the action. An action built from a literal is narrower on its own, becauseTis inferred from it:new Action({ value: { label: 'Save' } })readslabelasstring. Writes are unaffected at every shape, and nothing changes at runtime.
[0.16.1] - 2026-08-20
Fixed
- An
Actionkeeps a value that states something other than a label or an icon.Actionis meant to be subclassed with a wider value type, and the emptiness test readlabelandiconalone: a value naming a render style, a name or a set of per-breakpoint options and neither of those two was taken for empty and replaced by the pair ofundefineds, so the action came out holding nothing at all and a button rendered from it came out blank. The question is asked over every member the object carries, which answers the same for the shape the base class declares. - An
Action's baseline carries exactly what it was declared with.params.originalValuewas copied into{ label, icon }, so a widened baseline lost every member beyond those two and the action it belonged to was measured against a fraction of what was declared for it. - An
Actiondeclared with a value and a matchingoriginalValuereportsisChangedfalse from construction, and so does everyGroupholding it. This is the base class and not only a subclass:new Action({ value: { label: 'S' }, originalValue: { label: 'S' } })reportedisChangedtrue, becauseisChangedis a structural comparison and reads own-key sets, and the two objects were built differently - the value kept by identity, carrying the one key it was declared with, and the baseline reshaped into the pair the base class names. The baseline is a frozen copy of what was declared, and the value itself where nothing declared one. - A bare
new Action()reportsisChangedfalse. Its baseline was never assigned, so the pair ofundefineds it holds was measured againstundefinedand every action constructed without parameters reported itself changed, its containers with it.
Added
FieldBase.constructed(params), the hook a subclass overrides to complete what the element was built with.Field,Action,GroupandListcall it at the end of a construction, inside the transaction the construction is and before the element records what it was built as, so what the override writes -_value, a member,originalValue- belongs to the construction rather than being a change of it: noValueChangedActionannounces it, an element builtenabled: falsedoes not refuse a write to_value, and what the hook leaves is what the eager actions and the validators then run over. Where the parameters named nooriginalValuethe element is baselined on the value the hook left, so it starts unchanged; where they named one it is measured against that, as any element is. It receives the parameter object the constructor was given, where it was given one. A write made aftersuper.init()returns is none of that, being a change to a finished element.- Three specs holding the library from a consumer's seat rather than from its own: a binding layer's computed over
valueand the reads it repaints from, anActionand aFieldsubclassed with a widened value, and a serializer readingvalueandfullValueback and writing a record into a form.
Documentation
- Widening the value in a subclass on the
Actionreference: what a subclass adds and what it keeps, and that a subclass readinglabeloriconin a shape of its own declares the getter and the setter together, the setter delegating to the base withsuper.label = newValue. A getter declared alone defines the whole property, which then has no setter at all, soaction.label = 'Save'- the documented way to write either member - throws aTypeErrorin module code. A narrowed read and an unnarrowed write do not answer each other;action.value.labelis the unfiltered read. - The value rules on the same page state what a construction settles on: a
params.valuecounts as empty when every member it carries isnullor absent, andparams.originalValueis frozen carrying every member it was given. - Extended properties and the extended properties example hold the two rules apart: an action's presentation property of another name is an extended property, while a differently shaped read of
labeloriconis an accessor pair on the subclass. The Action example points at the same section.
[0.16.0] - 2026-08-20
Changed
Breaking: a trigger answers with the
AbortEventHandlingExceptiona handler threw, where it answerednull. A run a handler ended and a run that reached no handler at all were the same answer, so neither the library nor a caller could tell them apart.triggerAction()hands the exception back, and code testing fornullsees the difference - which is the point, because the two mean different things.Breaking:
AbortEventHandlingExceptionthrown from anEnabledChangingActionor aVisibilityChangingActionrefuses the write. Those handlers are asked before the value is written, and the exception was caught and the write went through anyway, so a handler that meant to stop a change watched it happen. Nothing is written and nothing is announced now. Thrown from a*Changed*handler it is unchanged: the value is already written by then, and ending the run stops the handlers below it and nothing else.Breaking: an action belongs to the element's declaration, and a binding reads that one rather than a copy of it. A rule registered on a
List's item template therefore drives every row - the rows that already exist as much as the ones added later - and registering on one row registers on the template, because a row is a binding of it.unregisterAction()andclearValidators()read the same way, so a call on one row names the rule every row reads. What stays per row is the data: the value, the errors the rule produces there, the verdict. A handler that does not callsuprnow ends the run for every handler registered before it on that declaration, since the handlers of one declaration stand in one chain.Memory per field drops by about half, from roughly 1700 bytes to roughly 870: a row carried its own map of actions and now carries the declaration's. A 1000-row list of 8 fields goes from about 13.3 MB to about 6.8 MB.
ActionsMap.clone()is gone with the copying.ActionsMapholds one array and no maps. The two it kept - one grouping the actions of an identifier, one grouping the eager ones - indexed what registration order already states, and a trigger now walks the array backwards and passes over the actions of other identifiers. Measured over three actions the walk is about twice as fast asMap.get, and a standalone field carrying one validator falls from about 1420 bytes to about 655.Operator.NOTis stated with one operand. It reads its first alone, and the constructor asked for a second one that it never read, sonew Statement(field, Operator.NOT)did not compile. An operator held in a variable - whatOperator.fromString()answers with - still names both, because the compiler cannot tell it fromNOT, and a second operand underNOTis accepted as it always was.Action.execute()documents the failure path it always had: the promise it answers with rejects where the handler throws, so awaiting it is how a failure is reported, and a call that neither awaits nor catches leaves the rejection to the runtime.busyis cleared either way. The template case has its own example, since an event handler there is not awaited.
[0.15.0] - 2026-08-19
Changed
Breaking:
EmptyFieldis gone. It was a module-level singletonFieldthe package exported, shared by everyone who touched it, and nothing used it - not the library, not its tests beyond the one asserting its own warning, not any of the packages built on it. A missing element isnull:Group.field()answers with it andNullableField<T>names the type.Breaking:
Validatorand the types that go with it -ValidationFunction,ValidationFunctionResult,ValidatorBindingState- are reached through theValidatorsnamespace alone. They were exported from the package root as well, while every concrete validator was in the namespace only, so one member of the set had two spellings and the rest had one.import { Validator }becomesValidators.Validator. The error classes,MdStringandbuildErrorMessageare unaffected: they are what a field hands back rather than what validates it, and they stay at the root, where they were never duplicated.Breaking: nothing in
DisplayModefalls back toDisplayMode.FULLany more.fromString()throws for a string that names no constant,fromAny()throws for a number that is none of them, for a string that names none, and for input that is neither a number nor a string; all three errors read<value> is not a DisplayMode constant, so a caller recognises one wherever it was raised. A mode nobody defined is an error where it arrives, rather than a field that renders fully and is never questioned. Code that fed a wire payload tofromString()/fromAny()and relied on the fallback has to catch the error and choose the mode it wants, or askisDefined()first.Breaking:
DisplayMode.isDefined()judges a string against the constant names instead of routing it throughfromString(), soisDefined('HIDEN')answersfalsewhere it answeredtrue. It is the one member that does not throw - it answersfalsefor a number that is no constant, for a misspelled name, and for input of any other type. Thevisibilitysetter asks it, which makes both spellings of the same mistake throw:field.visibility = 'HIDEN'andfield.visibility = 999alike raiseError('visibility must be a DisplayMode constant')and leave the property as it was, where a misspelled name silently becameDisplayMode.FULL. A constant's name is still accepted, case insensitive.A form element whose parameters name no visibility still starts at
DisplayMode.FULL. That is a starting value, and it no longer stands in for input a parse could not read.Breaking:
parentis typed per class.FieldBase.parentisGroup | List | undefined, which is what the link holds: a row of aListgets theList.Fieldnarrows it toGroup | undefinedandActioninherits that narrowing, because aListholds rows and a row is aGroup, so a field is never aList's child. The sibling lookupfield.parent?.fields.othertherefore compiles unchanged on a field, androw.parent.fieldsis a compile error where it was a promise the type could not keep - the declaredGroupwas the container's type for a member of a group and the wrong one for a row. Code holding an element as aFieldBase- which is the type an action executor and aValidationFunctionreceive - narrows the container itself:(field.parent as Group)?.fields.other.Breaking: a structural comparison of two elements answers identity.
isEqual(fieldA, fieldB)read nothing either element holds - the state is in private class fields - and answeredtruefor any two instances of the same class; it isfalsenow unless they are the same element, and what they hold is compared asisEqual(a.value, b.value).FieldBasecarries aSymbol.toStringTagaccessor naming the element's class, which is the first thing such a comparison reads and a tag it does not know ends it there. The accessor is on the prototype, so an element carries nothing for it, andObject.prototype.toString.call(field)answers[object Field]where it answered[object Object].The build tooling moves to
eslint-config-velis3, which states its plugins as peer dependencies rather than carrying them, so the thirteen it names are declared here: eslint 10,@typescript-eslint8.67,eslint-plugin-unicorn73, prettier 3.9,@types/node26 and the rest.npm auditgoes from 18 findings to 3, and the three that remain are VitePress's pinned dev server with no fix published. None of it reaches the published package, which declareslodash-esandvue.A rule written against a field of an enclosing row now reads the row it runs in. Resolution answered within one record and took an element belonging to any other for the one element every record reads alike - true of a form field above a list, false of a field of the row a nested list sits in - so the lines of an order compared against the item template's
totalrather than against that order's. It walks the containers of the record outward before it settles for that, which is what the name form always did, so the two forms agree on the same rule.Breaking: writing what an element already holds is not a change.
visibilityandenabledran their*Changing*handler, enrolled the element in the open transaction and fired their*Changed*event for a write of the value already there; they now return before any of it, the way the value setter always has. A handler that answers with a value of its own is therefore not reached by such a write.A validator that re-runs and produces the message the field already carries leaves the instance standing.
ValidationError.sameAs(other)is what it asks - same class, same code, and the same component, bindings, body and classes - because a structural comparison of two errors answers nothing useful:ValidationErrorRenderContentholds a Vuecomputed, and two of those are never structurally equal, so every run replaced the error and every reader offield.errorsre-rendered over a verdict that had not moved.isSimpleComponentDef(null)answersfalserather than raising.typeof nullis'object'andinrefuses null, so the guard reached the operator and threw where it was asked a question it can answer.AbortEventHandlingExceptionis covered by tests: what a run it ends leaves unreached, that it does not escape the setter and leaves the value that was written standing, thattriggerAction()answers null for that run, that it ends only the run it was thrown in, that every other exception reaches the caller and unwinds the transaction, and that a*Changing*handler throwing it does not veto the write.CI loads the built ESM artifact and exercises it - the export list, a list composing and validating,
valueagainstfullValueover a disabled member, and a transaction announcing once and rolling back on a throw. The specs importsrc/, so nothing else reached what the package actually publishes.defaultDisplayModeis exported from the package. It names the mode an element starts at, so code that has to choose one for input it could not parse states the same constant the library does rather than repeatingDisplayMode.FULL.Breaking: a disabled
Listis serialized by theGroupabove it while its rows compose something, the way a disabledGroupalready was. The exception is one rule now - a disabled container is kept where its composed value is non-empty and left out where it is empty - and it holds whichever container the member is. A disabled leaf is left out as before. A form that readgroup.valueto submit it now carries the rows of a disabled list it previously dropped.Breaking:
List.valuerefuses a value that is neither an array nor null with aTypeError('Invalid value provided: a list takes an array of rows, or null to empty it'), where such a value was accepted and silently did nothing. The setter is typed, so this reaches a JavaScript caller or one writing throughas any; the constructor'svalueandoriginalValueare refused the same way.
Fixed
- A
Listconstructed with anoriginalValueand novaluetakes its rows from it, the way aFieldand aGroupalready did: it held no rows, read backnulland reportedisChangedastrueagainst the very value it was declared with. An explicitvalue: nullstill leaves the list empty - null is a value the caller means - and an absent value with nooriginalValuebeside it still starts the list empty.
[0.14.0] - 2026-08-19
Changed
- Breaking:
IBindParamsnamesoriginalValue,enabled,visibilityand the extended properties, where it named everything a constructor takes but the value.bind()read three of those and dropped the rest, sof.bind(v, { validators: [...] }),{ actions },{ touched }and{ errors }compiled and did nothing. They are refused by the type now:validatorsandactionsare carried from the declaration rather than supplied, andtouchedanderrorsare what a binding establishes for itself as it validates. - Breaking:
Group.bind()andList.bind()construct throughthis.constructor, asField.bind()already did, so a subclass binds into its own class instead of into the base one. A subclass whose constructor does not take(fields, params)- or(itemTemplate, params)on aList- never sees what it is handed and would answer with the declaration's data instead of the record's; both refuse that with aTypeErrornaming what to do about it, rather than returning a binding whose data is quietly wrong.
[0.13.0] - 2026-08-19
Added
settled()on every element answers with a promise that resolves once nothing at or below it is running - no asynchronous validation, noAction.execute()yet to settle. It resolves at once where nothing is running, so a submit path awaits it instead of readingvalidatingandbusyand reading them again. It answers for the moment it resolves and states nothing about the one after: work started later leaves the element running again.
Changed
Breaking:
Group.fullValueis typedFieldsToFullValues<T>andList.fullValueisFieldsToFullValues<T>[], where both answeredRecord<string, any>or the list's ownvaluebefore.fullValuestates what an element holds wherevaluestates what it serializes, so every key is present and none is null - reading through a nested group needs no?..Listgains an override of its own: it maps its rows through theirfullValuerather than answering withvalue, so a field disabled inside a row is in it, and an empty list reads back as[]rather than as null.Breaking:
busystates an execution and nothing else. It istruewhile anAction.execute()at or below the element has yet to settle; an element that is not an action executes nothing and answersfalse. An asynchronous validation is whatvalidatinganswers for, on the element and on everything below it. The two questions are separate, so a form that gates on an idle tree reads both or awaitssettled().Breaking:
Requiredtrims a string before it measures it, so a value of spaces alone is no value and the field is invalid. Where the spaces are part of what the field holds,trimturns it off. The constructor takes the options beside the message or on their own:typescriptnew Validators.Required(); // whitespace-only fails new Validators.Required({ trim: false }); // whitespace-only passes new Validators.Required('Please enter a name'); // message, trimming still on new Validators.Required('Please enter a name', { trim: false });RequiredOptionsis exported. Only strings are trimmed; an array, an object or any other value is measured as it stands. The two first arguments are told apart by shape, so an object naming a component is still a message.Breaking: the
Statementconstructor refuses an operand it cannot compare. An operand that isundefined— whatgroup.fields.typoNameanswers with — or a function — a field accessor handed over uncalled — throws aTypeErrornaming the position it was written at. Everything else stands: a field, a nested statement,null,NaN,0,'', an array, an object withincludes.Operator.NOTnever reads its second operand, so that operand is not checked under it. A name the group does not hold reachesStatementasundefinedonly throughgroup.fields;group.field('typoName')answersnull, which is a value a statement may legitimately compare against.Breaking:
validatinganswers for the whole subtree: a group or a list reports true while an asynchronous validation is in flight anywhere below it, where it used to answer for its own runs alone. A form asks one element what the tree is doing. The answer is a pair of counters rather than a walk, so the read costs nothing and a run that starts or settles costs the nesting depth.Breaking:
busyis a member of every element, so a parameter of that name is no longer an extended property:new Field({ busy: true })throws aTypeErrorthe wayvalidandvalidatingalready did.lengthanditemsare members ofListfor the same reason, sonew List(tpl, { length: 3 })andnew List(tpl, { items: [] })throw as well. A presentation layer that carried any of the three as a property of its own states it under another name.Breaking:
GroupValue<T>isPartial<FieldsToValues<T>> | null. A group leaves a disabled member out of the value it builds, so every member reads as possiblyundefined— which is what the runtime always handed out.Breaking: the five validators that never read their type parameter no longer take one:
Required,Pattern,MinLength,MaxLengthandLengthInRange.new Validators.Required<string>()becomesnew Validators.Required().InAllowedValues,MinValue,MaxValue,ValueInRangeandCompareTokeep theirs, where it types an argument or a callback.InAllowedValuestakesAllowedValues<T>— an array, aRef<T[]>or a() => T[]— and reads the list at each validation instead of at construction. A list that arrives from a server after the validator is built, or one another field's value leaves open, is the list the value is measured against and the one{allowedAsText}and{allowedValues}name.group.fieldshands out a guarded view of the member map. Reading it reaches the members themselves; assigning, deleting and now alsoObject.definePropertythrow aTypeErrornamingaddField()orremoveField()as the way to change the set.list.valueacceptsListValue, solist.value = null— the write that clears a list, and the onegroup.value = nullmakes into every member — type-checks.
Added
ValidationError.codenames what failed, so a program reacting to a particular failure does not have to match message text that is translated and configurable. The built-in validators state theirs:required,pattern,min,max,range,min-length,max-length,range-length,in-allowed-values,compare-to, andvalidation-failedon the error the library raises when a validation promise rejects.ValidationErrorTextandValidationErrorRenderContenttake it as a third constructor argument; an error built by hand carries whatever its author gives it, or nothing.- A
ValidationFunctionreceives a fourth argument,signal: AbortSignal, and hands it to the work it commissions. It aborts the moment the verdict the run would reach stops counting: a newer run over the same field, a field the validator was taken off withunregisterAction()orclearValidators(), or a transaction that was unwound. A cancelled run reaches no verdict at all, so a check that rejects on abort says nothing and reports nothing. The cancellation an unregistration brings waits for the commit, so a transaction that rolls back puts the validator, its epoch and the run in flight all back, and the field ends up carrying the verdict that run reaches rather than reporting itself valid over a value nothing checked. A function with nothing to cancel ignores the argument. busyon every element: true while anything at or below it is still running — an asynchronous validation, or anAction.execute()below it that has yet to settle. On anActionit answers for that action's ownexecute()runs, and an asynchronous validation of the action itself is reported byvalidatingalone. It is what a form asks to disable a submit button while the tree is still deciding.Group.addField(name, field)andGroup.removeField(name)change the member set after construction. Both are transactional and announce the value once the transaction closes; the group's verdict re-forms over the members it holds, and a rule of the added field that names another member of the form reaches it.addFieldthrowsErrorwhere the group already holds that name andTypeErrorwhere the field belongs to another container — pass abind()of it.removeFieldhands the field back, detached and free to be taken elsewhere, and answersundefinedfor a name the group does not hold. Neither rewrites the baseline behindisChanged.List.lengthandList.items.lengthis the number of rows.itemsis a frozen array of the live rows, built once per change of the set: a write inside a row leaves the array a reader took as it is, and so does an assignment tovaluethat every row survives, while apush,insert,removeorclearreplaces it.getConfig(),setConfig()and theFormsConfigtype are exported from the package entry point beside the plugin, so the global options can be read and written without the plugin.
Fixed
CompareTowithdraws the errors it placed on a field when it is taken off that field.unregisterAction()on aCompareTodropped the registration and left the error standing, so the field stayed invalid on an error no validator was left to take back.
[0.12.1] - 2026-08-19
Changed
- The build target is ES2022, up from ES2015. The only thing it changed is
field-base.ts: it is the sole file holding private class fields, and an ES2015 output has to lower each of its 51 access sites to a WeakMap lookup guarded by an access check. The artifact goes from 92 715 to 87 902 bytes, 25 728 to 24 547 gzipped, and the private fields ship as themselves. Every runtime the package already declares support for — Node 22 and up — runs ES2022 natively.
[0.12.0] - 2026-08-18
Changed
- Breaking: the package is ESM-only. The
requirecondition, themainfield, the UMD artifact (dynamicforms-vue-forms.umd.cjsand its map) and theindex.d.ctscopy of the declarations are gone;exportsresolves one build with one set of types. A CommonJS consumer reaches the library throughrequire()of an ES module, which Node supports from 20.19 and 22.12.lodashleavesdependencies— it was there only so the UMD artifact could require the CJS packaging oflodash-es— leavinglodash-esas the single runtime dependency. Shipping one format also removes the failure a mixed graph produced: the library compares elements withinstanceofand keys its internals with module-levelSymbol(), so a program holding both copies rejected a validFieldwithInvalid fields object provided. - Breaking:
engines.nodeis>=22, up from>=18. Node 18 reached end of life in April 2025, andrequire()of an ES module — what a CommonJS consumer now relies on — is stable from 22.12. - Breaking: the
vuepeer range is^3.5.2, down from^3.4. The declarationsvue-tscemits writeMessagesWidgetas aDefineComponentwith 20 type arguments; the type takes 19 through Vue 3.5.1 and 20 from 3.5.2, so anything below that floor reportsTS2707in a consumer type-checking withskipLibCheck: false. Nothing in the source needs a Vue newer than 3.0 — the range states what the shipped artifact is known to work against, and CI now type-checks the emitted declarations against exactly that floor on every run. - Breaking:
ActionsMapholds the actions of one identifier as a list rather than composing them into nested closures, and no longer extendsMap<symbol, FieldActionExecute>.triggerChain()andcloneWithoutValidators()are gone —trigger()is the one way to run a group and no longer runs the eager pass on the quiet, andclearValidators()unregisters its validators instead of rebuilding the map.unregister(),triggerEagerFor()andhasEagerare new. The order handlers run in is unchanged: newest registration first, reaching the ones before it throughsupr. - Breaking:
eageris read per action instead of perclassIdentifier. A lazy action standing under the same identifier as an eager one was previously dragged into every eager pass; now only the eager ones run. unregisterFrom(binding)runs inside the operation that drops the registration rather than after it commits, so an operation that unwinds puts back both the registration and what the action released.
Added
unregisterAction(action)on every element drops one action and answers whether the element held it. The instance goes on serving every other element it was registered on, so unregistering a validator from one row of aListleaves the other rows validating. AValidatorwithdraws the errors it put on the element as it goes, so the element cannot be left invalid on an error no validator is around to take back.registerActionBefore(action, before)putsactioninside a handler already registered:beforewraps it and reaches it throughsupr. Registration order alone could not arrange that, so an action added to a form someone else built could only ever become the outermost handler.- A rollback takes back the actions its transaction registered and puts back the ones it unregistered, so "a transaction undoes everything it did" holds without an exception for registrations.
[0.11.0] - 2026-08-18
Changed
Breaking:
clone()is nowbind(), and takes the data it binds as its first argument. The call does what it always did — a new element of the same class, carrying the same registered action and validator instances and the same extended properties, detached and withoriginalValuebaselined to the data — and it is now named for that. The value moves out of the override object and into an argument of its own:typescriptconst copy = field.clone(); // before const copy = field.bind(field.value); // after; field.bind() is the same thing const cleared = field.clone({ value: null }); // before const cleared = field.bind(null); // after const row = template.clone({ value: { name: 'John' } }); // before const row = template.bind({ name: 'John' }); // afterThe second argument is the rest of the overrides, typed
IBindParams<T, X>—IFieldParams<T, X>withoutvalue.originalValueis still read by key presence,enabledandvisibilitystill fall back to the element bound, extended properties it names are still written over the ones carried over, andvalidatorsandactionsare still accepted and ignored. Data ofundefinedcounts as none supplied and an explicitnullclears, onField,GroupandListalike.Breaking:
List.remove()andList.pop()hand back the row itself, released of the list, rather than a copy of it.ListItemRemovedActionreceives that same instance, and it is the instancelist.get(index)answered with before the call. What the row holds comes with it: its values, its errors, and the change history behindisChanged— which the copy erased, so an edited row used to come back reportingisChangedasfalse. The row carries noparent, so it can be pushed straight into another list, or back into this one.
Added
rebind(data)on every element: the exchangebind()makes, made in place. The element is the same instance afterwards — its identity, its actions, its extended properties and its place in whatever container holds it all stand — and it ends up over the new record, withoriginalValuebaselined to it,touchedback tofalseand the validators run. It is what recycles one element across records, which is what a virtualised renderer does with the rows it keeps:typescriptconst row = list.get(0)!; row.rebind({ name: 'Jane', age: 25 }); // same instance, same component, next recordThe element announces nothing of its own about the exchange: no
ValueChangedActionfires for it, the way none fires for an element that was just built. Its members do announce theirs, and a verdict that moves is announced as always, so a rebound row that is invalid says so to the list holding it. Inside an opentransaction(), a change the element is already owed an announcement for stands, and the commit reports it from where the element stood when the transaction opened. On aGroupthe record need not name every member: a key it leaves out is taken from the element'sdeclaration.IBindParams<T, X>, the exported type of the second argument ofbind().
[0.10.2] - 2026-08-18
Added
Extended properties. Every form element now carries whatever properties your application declares for it beyond the members of its class — a label, a hint, a css class, a permission flag — so a form whose shape arrives from a server has somewhere to put them and a UI layer has somewhere to read them from. Declare them as the element's second type argument, pass them to the constructor alongside everything else, read them through
extraand write them withsetExtendedValues():typescriptinterface Presentation { label: string; hint?: string } const name = new Field<string, Presentation>({ value: 'John', label: 'First name' }); name.extra.label; // 'First name' name.setExtendedValues({ hint: 'as in your passport' }); // label stays as it wasField<T, X>,Action<T, X>,Group<Fields, X>andList<Fields, X>all take it,Xdefaults to{}and is never inferred, so existing code compiles unchanged and an element that declared no extended properties still rejects one. The read is tracked like every other read through an element, so a template renderingfield.extra.labelre-renders when the property is written, and a write inside atransaction()that rolls back is put back with it.clone()carries them over, and overrides it is given are written over them — which also gives every row aListbuilds the properties its item template carries.extrareads back asReadonly<Partial<X>>: a parameter object carries as few of them as it likes andsetExtendedValues()writes as few as it likes, so a property is there once something has put it there.IFieldParams<T, X>, the exported type of the parameter object taken by every constructor and everyclone().IFieldConstructorParams<T>goes on naming the members every element takes.
Changed
- A constructor parameter naming something the element's class does not declare is now an extended property rather than a property written onto the element itself. It was already a compile error to pass one; code that passed one past the type system with
as anyand read it back asfield.myPropnow reads it asfield.extra.myProp. Parameters naming a member the class does declare are unaffected:enabledstill setsenabled,validstill throws aTypeError, and anAction'slabelandiconstill reach its value. A subclass of your own is a member the class declares when it declares an accessor; a class field is defined on the instance after the base constructor has applied the parameters, so a parameter of that name becomes an extended property and the field keeps its initializer.
[0.10.1] - 2026-08-17
Documentation only. No behaviour changes, and no source under src/ was touched.
Added
- The model, a new guide page placed ahead of the API reference. It states the whole design in one place — elements, declarations and clones, how a
Listbuilds rows, what a record is and how a shared rule resolves within one, when each event fires, where validity comes from and where a value comes from — for a reader who has never used the library. The reference pages carry the per-symbol truth; this is the shape they belong to. - An
Actionexample, showing an action end to end: declared with a label and an icon, enabled by the form's validity through aConditionalEnabledAction, executed, and reportingbusythrough an asynchronous submit that can fail. It says plainly whyActionis the one deliberate exception to the library being UI-agnostic and that a UI library is expected to extend it, and it cross-links to@dynamicforms/vuetify-inputs. - A single upgrade path from 0.6.1 to 0.10.x at the top of the migration guide, leading with the three breaks that fail with nothing in the console —
watch(field, cb),readonly(field)andisEqualover two elements — and ending in a checklist. The per-release sections stay below it for a project crossing one release. - A versioning and support statement in Getting Started: what a
0.xminor means, and the supported Vue, Node, module-format and browser matrix. The changelog is now reachable from the site navigation.
Fixed
Field.valueno longer claims that assigning an object firesValueChangedActioneven for the same reference. Values are compared by identity: a new object announces a change even when it is deeply equal to the old one, and the very object the field already holds announces nothing. Mutate a copy and assign it.field.errorsis documented as it behaves.validfollows an error pushed into the array immediately, on the field and on every container above it; what waits forvalidate()is theValidChangedActionannouncing the transition. The array is reactive, sofield.errors[0] === myErrorisfalsefor the error a validator returned — compare by content, or unwrap withtoRaw().- The reactivity claims are corrected everywhere they appeared. A form element has not been a Vue proxy of itself since 0.7.0; every read through it is tracked, and the watch form to use is
watch(() => field.value, cb). List.value's setter is described as it behaves: an array assigns, anull— which is whatgroup.value = nullwrites into a nested list — releases every row, and any other value leaves the rows untouched.ActionsMap's documented surface liststriggerChain()andwillTrigger(), which it has always had, andStatement'soperand1Value/operand2Valueare documented.- A hand-written validator that reads a sibling is shown calling
field.markRecordIncomplete(), which is what aListrow needs to carry its own verdict from the moment it exists. TheListexample no longer revalidates each row from aListItemAddedAction, which coveredpush()andinsert()and missed every other way a row is built. MessagesWidgetrenders acomponentNamedirectly only for the common HTML tag names; every other name, including an uncommon tag, is resolved as a globally registered component.
[0.10.0] - 2026-08-17
Added
field.declarationnames the element a field was declared as: itself for a field built from parameters, and the field it was cloned from for a clone, transitively. Every row aListbuilds from an item template is a clone, solist.get(0).fields.a.declaration === template.fields.a.field.bindingsOf(declaration)lists the elements of a subtree that were declared as the given one —list.bindingsOf(template.fields.a)is theafield of every row.Statement.evaluate(scope?)takes the element whose record the field operands are read in, so one statement serves every row of aList. Without an argument it reads the fields it was built from, as before.CompareToaccepts the field to compare against as a name or as a callback receiving the field being validated, next to the field itself. The exported type of the parameter isValidators.CompareToTarget.FieldActionBase.state(key, init)holds what an action remembers between runs against the element it ran over, or against the record that element belongs to. An action instance is shared by every clone of the element it was registered on, so what it keeps on itself is shared by every row; what it keeps here is not, and is released with the element.field.markRecordIncomplete()lets an eager action say that it looked for a second element of the record and the record was not assembled yet. The container that completes the record runs the element's eager actions again, and so does a container that takes the record in afterwards.
Fixed
- Conditional actions work inside a
List. Registered on the item template, a conditional action serves every row, each row holds a result of its own, and a change in one row reaches that row alone. Previously the rows shared a single result and a row built by the list never had the action bound at all, so the condition was silently dead. CompareTocompares within the row it is validating. It read the item template's field, so a row whose two fields matched was reported invalid and a row where they differed was reported valid — password/confirmation and date-from/date-to being the cases it exists for.clearValidators()on one element leaves the same validator instance validating every other element it was registered on. Dropping the validators of one row of aListsilenced the validator in every row, so the form reported itself valid when it was not.- A statement's fields are read once per record rather than once per row of listeners: one handler is registered on each field the statement reads, however many rows read it.
- A row that holds the very values its item template holds carries its own verdict. A cross-field rule is run again once the record is assembled, so a
Listrow, an item the list builds to fill a gap, the groupremove()hands back and aclone()all report what their own fields support instead of reporting themselves valid. - A cross-field rule registered on a single row is that row's rule. A change of the field it compares against, or of a field its statement reads, no longer plants errors on the rows that never took the rule on — errors those rows had no validator to clear.
- Registering a validator again after
clearValidators()re-arms it fully: it listens to the field it compares against again, where before it only re-validated on writes of the field's own value. - A
CompareTonaming a field the form above the list holds resolves it. A row reaches the form only once the list takes it in, which is now where the rule is run again.
Changed
- Breaking:
CompareTo's first constructor parameter widens fromFieldBasetoFieldBase | string | ((field: FieldBase) => FieldBase | null | undefined). Passing a field keeps working, and is now resolved within the record being validated rather than read as the one field it names. - Breaking: the two optional hooks of
FieldActionBaseare renamed:boundToField(field)isboundToBinding(binding), and it now runs for every element the action comes to serve rather than only for the one it was registered on;unregister()isunregisterFrom(binding), which names the element the validator was dropped from. An action that overrode neither is unaffected. - Breaking: a cross-field rule that was inert inside a
Listnow applies, so a form holding one may report errors, hide fields or disable fields it did not before. The verdicts are the ones the data always called for. ActionsMap.bindTo(owner)tells every action in a map that it servesowner; cloning an element calls it, which is what makes an action registered on an item template serve every row.Validatorkeeps its per-field run sequence throughbindingState(field), and a subclass widens that record by overridingnewBindingState(). The exported type isValidatorBindingState.
[0.9.0] - 2026-08-17
Added
Action.busyreports whether an execution of the action has yet to settle.execute()raises it as the chain is entered and clears it once the run has settled, whether it resolves or rejects; overlapping runs are counted, so it stands until the last of them settles. It is a form-state flag a button binds to in order to disable itself while a submit runs.
Changed
- Breaking:
Action.execute(params?)is asynchronous. It answers what theExecuteActionchain returned, as a promise, where it previously discarded the result and answeredundefined. The chain is still entered synchronously, so a handler has already run by the time the call returns, but a handler that throws now rejects the promise instead of throwing out of theexecute()call - a caller that wrappedexecute()intry/catchhas toawaitit or attach a.catch(). A call that does neither leaves the rejection unhandled, which under node's default settings ends the process; a Vue template handler needs no change, because Vue attaches its own catch to the promise a handler returns.paramsis now optional. - Breaking: writing
Action.labelorAction.iconassigns a new value object instead of writing into the one the action holds. Both are now ordinary value changes:ValueChangedActionfires,isChangedanswers over them, and a disabled action refuses the write, where previously none of the three happened. A value object you passed asparams.valueand kept a reference to no longer follows the action once either setter has run; reading still sees your writes to that object until then. Writing the label or icon the action already holds changes nothing and announces nothing, and clearing one withundefinedleaves the action reporting itself unchanged against a baseline that never carried it.
Fixed
labelandiconare assignable on an action constructed without a value.new Action({}).label = 'X'threw aTypeError, because the action held the frozenoriginalValuebaseline as its value.
[0.8.0] - 2026-08-17
Added
transaction(fn)runs several writes as one atomic change. The events they produce are announced once, at the end, over the net result: two writes to two members of a group announce oneValueChangedActionon the group instead of two, and a value that goesA->B->Ainside one transaction announces nothing at all. A nested call joins the transaction it found. The callback must be synchronous -transaction()throws aTypeErrorthe moment it returns a thenable - so a transaction structurally cannot cross anawait.- A transaction can be undone.
tx.rollback()unwinds it without an error and the call answersundefined, and a throw out of the callback rolls back and rethrows. A rollback puts back the whole of every element the transaction modified -value,originalValue,touched,errors,enabled,visibility, the validators aclearValidators()dropped, and aList's rows, with the rows the transaction created dropped and the ones it removed re-adopted at their old positions - and announces nothing. An asynchronous validation started inside a transaction that was rolled back runs to the end and its verdict is discarded, so a field is never left invalid over a value the form never held. What a rollback cannot undo are side effects - a handler that called a server already did - and actions registered while it was open, which stay registered. - The handle
transaction(fn)hands its callback is usable only for the duration of that call. Callingrollback()on a handle kept beyond it throws aTypeErrorrather than unwinding whatever transaction happens to be open at the time.
Changed
- Breaking: events are announced when the operation carrying them finishes rather than as it runs, and each is announced once. Every mutating operation is a transaction; where you open none, the operation is the transaction, so a single write still announces exactly one change. What moves is the timing and the count for everything else:
- an operation that changed one element several times announces the net change, and announces nothing where the element ends where it started;
- a handler is never shown a half-applied state.
insert()past the end of a list pads the gap first and announces each addition over the set the operation ended on, where it used to announce each over the set that stood at the time; - the order within one operation is now the causal one: an element announces its value before the verdict formed over it, and the deepest element announces before the container above it. A field carrying a validator used to announce its new verdict before its new value.
- Breaking: a handler that throws no longer leaves a container half-applied. The operation rolls back and the throw propagates, so
group.value = {...}whose members' handlers fail partway leaves the group exactly as it was, with its verdict and its value caches intact.AbortEventHandlingExceptionis unchanged and still stops the chain without undoing anything. - Validators run at the write that triggers them, which is inside the transaction, so a validator reading a sibling sees the sibling's working value. Their verdict is what the commit announces.
- Breaking:
ActionsMap.cloneWithoutValidators()returns the copy without releasing the validators it left out;unregister()on each of them is now the caller's to make, and the newActionsMap.validatorslists them.clearValidators()on a field is unchanged for a caller: it still releases them, once the operation it ran in has finished rather than as it runs, so an operation that unwinds leaves the field's validators intact. - Measured on 1000 rows of 8 fields, against 0.7.0: a whole-list assignment 18.8 ms to 24.3 ms, one field write 0.0081 ms to 0.0097 ms,
remove()andpush()-filling within a few per cent. The added cost is the record a rollback restores from, taken once per element an operation modifies, plus the commit's own bookkeeping.
[0.7.0] - 2026-08-17
Changed
- Breaking:
watch(field, cb)andwatch([field], cb), with a form element passed directly as the watch source, no longer fire. A form element is no longer a Vue proxy of itself - its mutable state is held in a reactive object beside it, and the element carries__v_skip- so the deep traversal Vue starts for a reactive source stops immediately and the watcher subscribes to nothing. Nothing is logged and nothing throws, so this is worth searching for:watch(() => field.value, cb)is the replacement, and every other read is unchanged. Templates,computed,watchEffectand a getter passed towatchall track field members exactly as before.toRaw(field)now returns the field itself. A write the value setter refuses - the same value, or any value on a disabled field - no longer re-runs the effects that read the value. - Breaking:
readonly(element)no longer protects anything, and fails as silently as the watch above. Vue'sreadonly()stops on__v_skipand hands the element straight back, soreadonly(field) === field,isReadonly()on the result isfalse, and a write through it reaches the field. Hand out the value -field.valueandgroup.valueare frozen - or acomputedover it. - Breaking: an element's state is held in private class fields, so nothing outside the element reaches it.
parentandfieldNameare read-only accessors over that state instead of own properties - assigning either yourself throws aTypeErrorrather than being silently accepted.Object.keys(element),Object.getOwnPropertySymbols(element),JSON.stringify(element)and lodashisEqualsee none of an element's state: a structure that contains its own descendants' back-references is walked by all four without climbing back up, andisEqualover two elements answerstruefor any two instances of the same class. Comparea.valuewithb.valueinstead. - A
Listno longer walks itself to answer a change. Every mutation and every field edit anywhere inside a list used to rebuild the whole list's value, deep-compare it with the previous one and re-check the validity of every field of every row, so oneinsert()cost work proportional to the entire list and filling a list was quadratic in its length.Group.valueandList.valueare now cached behind a version that a write raises along its own branch,validis read through a lazily built computed and maintained for events as a tally, and a mutation that nothing listens to no longer builds a value at all. Measured on 1000 rows of 8 fields: filling bypush()13.1 s to 0.37 s, writing one field 33 ms to 0.017 ms, readinglist.valid11.3 ms to 0.0006 ms,remove()27 ms to 0.75 ms.list.value = rowsreuses the row objects positionally instead of rebuilding them, so a same-length assignment keeps each row's identity and a keyedv-forstops remounting every row. A reused row is reset to the state the row built for that position would have been in, down tooriginalValue,isChanged,touchedand its errors, and the new set is installed whole, so a validator readinglist.valueduring the assignment sees the whole list at every point. - A
DisplayModelookup no longer rebuilds the enum's value list on every call, and a form element allocates its action map only when it has an action to hold. - The object
Group.valueandList.valueread back is frozen, rows included: the same object answers every read until the next change, and writing into it throws in strict mode. Assign a new value instead.originalValueholds a copy of its own, so it is never the objectvaluereads back. - A
Listreleases the rows it drops -remove(),pop(),clear()and an assignment that shortens the list. A released row loses itsparent, stops counting towards the list's validity and can be pushed into another list or back into the one that held it. A container still refuses an element that belongs to one, with aTypeError.remove()returns a clone of the row, as before.
[0.6.1] - 2026-08-17
Added
FieldBase.validationEpoch, a read-only counter of the validator generations a field has been through.clearValidators()raises it, and a validator that reads it when a run starts can tell that the result it is about to apply belongs to validators the field no longer carries. Custom asynchronous validators can use it the same way the built-inValidatordoes.
Fixed
ListItemAddedActionreports the real index of every elementinsert()creates. The elements inserted as padding, to reach an index past the end of the list, were each announced with the index one past their own position, solist.insert(item, 3)on an empty list reported1, 2, 3, 3instead of0, 1, 2, 3.Statement.evaluate()returns a boolean for every operator.AND,ORandXORreturned the operand itself andINreturned whatever the container'sincludes()produced, so a conditional callback received e.g. the number0instead offalse, andConditionalStatementActionfired again when that operand changed tofalse- a non-transition. For these operators the truth values are unchanged.NOT_INis the negation ofIN. Anoperand2that carries no callableincludes-null,undefined, a number, a plain object - made both operators reportfalse, so a statement and its negation agreed.INisfalsefor such an operand andNOT_INistrue.- One
ValidationErrorinstance may be reported by more than one validator. The second validator to receive it threwTypeError: Cannot redefine property: source. Each validator now holds an error of its own - the instance itself, or a copy that keeps its prototype and every property and therefore renders identically - and withdraws only that one, in whatever order the fields are cleared. Two validators on the same field that both report one instance consequently leave two errors on it, as two equal instances always have. - A
Groupconstructed with parameters that carry novaluekeeps the values its member fields were built with.new Group({ a: new Field({ value: 1 }) }, { visibility: DisplayMode.HIDDEN })cleared every member tonull; it is now{ a: 1 }, andisChangedisfalse. Avalueofundefinedcounts as no value, so spreading an optional property empties nothing;{ value: null }is a value the caller means and does clear the members, andoriginalValuealone still seeds the value. new Field({ value: null })keeps thenull. An explicitly null value fell back tooriginalValue; only an absent orundefinedone does now.clone()takes a value override only from a value the caller supplied.clone({ value: null })clones with thenullinstead of falling back to the value of the field it was cloned from, sogroup.clone({ value: null })clears the clone's members and aListcloned that way comes out empty. Avalueofundefinedis not a value the caller supplied and behaves like an absent key:field.clone(),field.clone({})andfield.clone({ value: undefined })all keep the current value, so spreading an optional property blanks nothing.originalValueis still read by the presence of its key.- A child that turns invalid without its value changing propagates its validity to the enclosing
GrouporList, soValidChangedActionfires there and the container'svalidfollows. The climb stops at the first ancestor whose own validity does not change. - A
Groupand aListannounce at most the net validity transition per operation, never a verdict over a state they held only halfway through one. Assigning{ a: 'x', b: '' }to a group whoseawas empty andbfilled firedValidChangedActiontwice,trueand thenfalse; so did assigning to a single member of a group that carries a validator of its own, assigning a whole list, andvalidate(true)on either container - each of which walks the members one at a time. The container now forms its verdict once, over the finished state: one event when the operation flips its validity, none when it leaves it where it found it, and the same at every level of nesting. Each member still announces its own transition. Group.value = nullclears a nestedList. AListignored an assignment that was not an array, so a list nested in a group kept its rows while every sibling field was emptied, andgroup.clone({ value: null })cloned it with its rows intact.ValueChangedActioncarries the real previous value. The first change of aGroupmember, and every change of aListafter an assignment, reportednullas the previous value.- Asynchronous validators are sequenced per field. A verdict from a run that a newer run has superseded is discarded instead of overwriting the newer one, and a rejected validation promise no longer surfaces as an unhandled rejection: if its run is still the current one, the validator replaces its own errors on the field with a single
Validation could not be completederror and reports the reason once asconsole.error('Validation failed', reason); if it has been superseded it is dropped silently, with nothing logged. A validator that cannot reach its server therefore produces a failed validation and not a passing one, so a value that was never checked cannot be submitted. The failure error is an ordinary error of that validator: the next successful run of the same validator withdraws it.field.validatingreturns tofalsein every case. clearValidators()cancels validation that is still in flight. A result arriving from a cleared validator is dropped, leaving the field with no errors,valid === trueandvalidating === false.clearValidators()announces the validity it produces. It setvalidsilently, so noValidChangedActionfired and an enclosingGrouporListwent on reporting itself invalid over a field that no longer had a validator. The transition now takes the ordinary path: the event fires on the field when the verdict changes, nothing is announced when it does not, and the containers above re-evaluate.- Eager actions passed to a
Field,Group,ListorActionconstructor - validators among them - run exactly once during construction, over the constructed value. Each registration ran them once more, over whatever the element held at that moment:undefinedon aFieldor anAction,nullon aList, so a validator reading the value had to guard against a value the element never held. - A validator message given as a
Refor acomputedkeeps its reactivity: it is unwrapped where it is read, so a change to the ref changes the rendered message. A ref holding anMdStringstill renders as markdown, with itsoptionsandplugins. - The CJS/UMD artifact requires
lodashinstead of the ESM-onlylodash-es, sorequire()of the package resolves on every supported Node version and under Jest. The ES artifact still importslodash-es. - The
repositoryandbugsURLs point at the current location,https://github.com/dynamicforms/vue-forms.
Changed
List.insert()resolves a negative index before it reports one. The number it returns, and the index it announces withListItemAddedAction, are the position the item occupies rather than the argument as given: on a two-element listinsert(item, -1)reports1andinsert(item, -99)reports0, where both used to report the argument back. Where the item lands is unchanged - the argument has always reachedsplice, which counts back from the end and stops at the start - so a caller who ignores both the return value and the event payload sees the same list.- A
Groupconstructor no longer firesValueChangedActionfor the value it is given.new Group(fields, { value, actions: [new ValueChangedAction(fn)] })calledfnonce during construction, withnullas the old value, because the members were filled in through the public setter; they are now filled in directly and the cache is primed from the result. A handler that relied on that call to see the initial value should readgroup.valueafter construction instead. - The UMD build exposes the global
DynamicFormsVueForms, withvueread asVueand lodash as_. lodashandlodash-esare declared asdependencies, so they install with the package.vueis the only peer dependency.engines.nodedeclares>= 18.prepackbuilds the package, sonpm packandnpm publishcannot produce a tarball whosedistpredates the sources it was cut from.package-lock.jsonis tracked in the repository, so a checkout resolves the dependency tree the release was built and tested against and CI can install withnpm ci.
Documentation
- The API reference describes what this release settles: the order in which a constructor applies its parameters and the single eager run that closes it,
validationEpoch, the reach ofclearValidators(), the value rule ofclone(), the boolean result ofStatement.evaluate()andIN/NOT_INover an operand withoutincludes, the resolved indexinsert()returns and announces, and whenValidChangedActionfires on a container. The readme documents the two build flavours and their lodash packaging, the Node requirement, and asynchronous validation. - The GitHub links in the readme, on the documentation home page and in the site's social links point at
https://github.com/dynamicforms/vue-forms.
[0.6.0] - 2026-08-15
Removed (breaking)
Field.create()andAction.create(). Fields are constructed withnew, the way groups and lists always were:new Field({ value: 1 }),new Action({ value: { label: 'Save' } }). The constructor guard that used to makenew Field()throw is gone as well. Type inference is unchanged -new Field({ value: 'a' })isField<string>.- The
reactiveValuemember onField,GroupandList. Every field is now a Vue reactive object from construction on, sofield.valueis itself reactive and needs no computed wrapper. Replaceconst out = form.reactiveValueplus{{ out }}with{{ form.value }}. - The
IFieldandIFieldActioninterfaces. Use the classesFieldBase(orFieldBase<T>) andFieldActionBaseinstead. The interfaces duplicated the class surface and promised a structural implementability the runtime never allowed:Grouprejects a field that is notinstanceof FieldBase, andActionsMaprejects an action that is notinstanceof FieldActionBase.
Changed (breaking)
IFieldConstructorParamsnow lists only the writable members -value,originalValue,enabled,visibility,touched,errors, plusactionsandvalidators. Passing a derived member such asnew Field({ value: 1, valid: true })used to type-check and then throw at runtime; it is now a type error.IFieldConstructorActionsListlost its type parameter and its members are typedFieldActionBase[].- A
Fieldsubclass applies its parameters from the constructor, so the protectedinit(params)hook runs before the subclass's own class field initializers - the factory used to run it after the instance was fully constructed. An initializer now overwrites whatinitassigned to the same member, andinitreads such a member asundefined. Move that state into theinitoverride. field.validatingis a getter over the number of validators still running, so it is read-only. Asynchronous validators bracket their work with the newbeginValidating()/endValidating()methods. It is typedbooleanrather than the literalfalse, soif (field.validating === true)no longer reports TS2367.Group.valueandList.valuecarry their real value types: forGroup<{ name: Field<string> }>,group.valueis{ name: string } | nullwhere it used to collapse toany, andList.valueisListValue.Group.value's setter accepts a partial value structure, which is what it has always done at runtime.
Added
FieldBase.beginValidating()andFieldBase.endValidating(), the pair an asynchronous validator brackets its work with to report that it is running.- The value types
FieldsToValues,GroupValue,GroupValueInputandListValueare exported.
Fixed
GroupandListare reactive from construction, asFieldandActionalready were.Group.errors- which is where a group-level validator writes -Group.visibility,Group.enabled, group-levelGroup.valid, and the structural changes made byList.push(),insert(),remove(),pop()andclear()now trigger a Vue re-render. They were plain properties on a plain object, so a group-level validation message, aConditionalVisibilityActionon a group and av-forover a list never repainted.List.clone()on an empty list. It spreadvalue, which isnullwhile the list holds no rows, so bothnew List(template).clone()and cloning aGroupthat holds an empty list threw aTypeError.- A
Groupfield may be named after anObject.prototypemember,__proto__included.new Group({ toString: new Field() })used to throw "Field toString is already in this form", and a field named__proto__- whichJSON.parsedoes produce - was dropped fromfieldsand fromvalue. The group's own value setter reads only own keys of the object it is given, so a field named after a prototype member is no longer assigned that member's value. Action.labelandAction.iconare settable on an action constructed without a value, such asnew Action({ actions: [new ExecuteAction(...)] }). Its value object was the frozen baseline used fororiginalValue, so assigning either threw "Cannot assign to read only property".- Add
typesconditions to packageexports, so consumers usingmoduleResolution: bundler,node16ornodenextresolve the library's types instead of falling back toany(TS7016). A separateindex.d.ctsis emitted for therequirebranch. - Expose the stylesheet as
@dynamicforms/vue-forms/style.css- it was shipped indistbut unreachable throughexports. Documented in the getting started guide and theMessagesWidgetreference.
Changed
- Set
rootDirexplicitly intsconfig.build.jsonand verify declaration output size in CI. TS 6.0 stops inferring it, and without it the rolled upindex.d.tscomes out empty with a green build.
Documentation
- Audited the whole of
docs/against the source. Corrected statements that no longer matched the code (enabledcascading,Statementreactivity, theEnabledChangingActionreturn value, list defaults and every built-in validator's default message), and documented previously undocumented public API:Action,FieldBase,AbortEventHandlingException,buildErrorMessage,EmptyField,RenderableValueand theNullable*type aliases. - Removed passages that narrated the library's own history - a superseded claim left standing next to its correction, reassurances about doubts the reader never had, and leftovers of a mechanical API rename.
[0.5.0] - 2026-01-28
- Remove default class from MessagesWidget
[0.4.7] - 2025-10-29
Added
- Support functions for
RenderContentto support dynamically retrieving text and allow for translations.
[0.4.6] - 2025-09-24
Added
- Add plugins and options to MdString supporting markdown-it extensibility
[0.4.5] - 2025-09-19
Added
- Add field touched property to indicate when user had at least visited a field
[0.4.4] - 2025-09-08
Added
- Add revalidate parameter to field validate method forcing revalidation
[0.4.2,0.4.3] - 2025-09-06
Added
- add classes to ValidationError (and descendants)
- add messages-widget
[0.4.0] - 2025-09-02
Changed - breaking changes
- renamed in error-message-builder: CustomModalContentComponentDef → SimpleComponentDef
Added
- Alias for ValidationErrorRenderContent: RenderableValue (it's not only usable for errors)
- Support for v-html content in RenderableValue
[0.3.5] - 2025-06-03
Added
- Support type generics on group.value / group.reactiveValue
[0.3.3 - 0.3.4] - 2025-05-30
Added
- Extending classes: Support for extending the vue-forms classes, particularly the Action class
[0.3.0] - 2025-05-19
Removed
- Removed all async versions of functions due to implementation complexity
Added
- Async Validator Support: Re-introduced async validation with loading states
- Improved Reactivity: Enhanced reactivity for Field and Action components
- Simplified validator architecture for better maintainability
Fixed
- Better reactive behaviour for field state management
[0.2.6] - 2025-05-19
Added
- Field.clearValidators(): Method to dynamically clear all validators from a field
[0.2.5] - 2025-05-17
Added
- CompareTo Validator: Cross-field validation for comparing values between fields
[0.2.4] - 2025-04-19
Fixed
- Updated repository URL in package configuration
Added (dev helpers)
- Package visualizer for bundle analysis
- Enhanced IDE configuration for Vite
[0.2.3] - 2025-04-18
Fixed
- Fixed undefined value comparisons in MinValue/MaxValue/ValueInRange validators
- Fixed ValidationErrorRenderContent reactivity issues
- Improved documentation static build process
[0.2.2] - 2025-04-17
Added
- Validator System: Built-in validators
- Required, Pattern, MinValue/MaxValue/ValueInRange validators
- MinLength/MaxLength/LengthInRange validators for text/array validation
- InAllowedValues validator for restricted value sets
- Async Operations: Support for asynchronous field operations and validation
- Eager Actions: Actions that execute immediately on registration and value changes
- Enhanced Action System:
- Refactored classIdentifier system for better performance
- Added boundToField method for action-field relationship tracking
- Conditional Logic: Complete implementation of conditional form behavior
- Statement and Operator system for complex conditions
- ConditionalVisibilityAction, ConditionalEnabledAction, ConditionalValueAction
- Extensive Testing: Comprehensive unit test coverage for all validators
- Documentation Examples: Interactive demos for validators and conditional statements
- Markdown Support: Configurable markdown rendering in validation error messages
Improved
- Enhanced type safety in Group field methods
- Fixed circular import issues
[0.1.5] - 2025-03-21
Added
- Export of FieldBase class to allow extending
[0.1.4] - 2025-03-19
Fixed
- DisplayMode export issues in type declarations
[0.1.3] - 2025-03-19
Improved
- Type Declarations: Enhanced TypeScript support without requiring Reactive<> wrappers
- Simplified type usage for end users
[0.1.2] - 2025-03-19
Added
- Forced Reactivity: Field and Action instances are now automatically reactive
- reactiveValue Property: Available on all field types for computed value access
- Enhanced Exports: Better export organization with default namespace and individual imports
Improved
- Reorganized module exports for better developer experience
- Enhanced reactivity system across all components
[0.1.1] - 2025-03-12
Changed
- Dependencies: Migrated from lodash to lodash-es for better tree shaking
[0.1.0] - 2025-03-06
Added
- Core Architecture: FieldBase abstraction with Field, Group, List, and Action implementations
- Reactive State Management: Built on Vue's reactivity system
- Event System: Action-driven architecture with event chains
- Validation Framework: Error tracking and validation state management
- Display Modes: SUPPRESS, HIDDEN, INVISIBLE, and FULL visibility control
- Form Relationships: Parent-child relationships with value change propagation
- Development Setup: Vite build system, Vitest testing, ESLint configuration
- TypeScript Support: Full generic type support for Field components
- Documentation: Complete VitePress documentation site with interactive examples
