Problem Statement
When editing array fields in a form, the form's two internal data structures — the reactive form data and the field cache (the store of materialized FormField instances) — drift out of sync on any array mutation. Today the field cache is a nested tree with array items stored positionally (_array[i]), and an on-change proxy hook (onValidate) tries to keep it aligned by replaying each individual array method (splice, sort, reverse, unshift, fill, pop, shift) onto the cache with undefined placeholders, while plain index assignments/deletes simply blow away the cache subtree.
This is fragile and hard to maintain:
- Every array mutation method needs a dedicated, hand-written case; a missing or wrong case silently desyncs the cache.
- The scheme is positional, so a
FormField does not travel with its datum. Reordering an array (e.g. drag-and-drop) leaves each field bound to its old index — its key, dirty flag, validation error, and therefore its DOM row and focus stay with the slot, not the value. For editable object rows this is a latent correctness/UX bug.
- The dual path grammar (data path
items[2].a vs cache path items._array[2].a) and the .exhaustive() match over a closed set of array methods are ongoing foot-guns.
The developer wants either (a) a streamlined, more reliable, easier-to-maintain sync, or (b) to store field state inline with the data so no separate sync exists — and prefers (b) if all data types (objects, arrays, primitives) can be represented.
Solution
Adopt an approach where array-element identity is maintained by the reactivity engine itself, so field state travels with its datum and there is no sync step to get wrong.
Concretely: replace @vue/reactivity with the @solidjs/signals store (SolidJS 2.0, next / @solidjs/signals@next) as the reactive engine for form-core. The store keeps one stable reactive node per raw object (a global storeLookup WeakMap: raw object → node). Reordering an array moves the same underlying objects, so each object keeps its node identity with no resync. The field cache is re-keyed by that engine-maintained object identity, which makes the entire hand-written mutation-mirror disappear.
From the consumer's perspective:
- Reordering, splicing, sorting, or moving array elements keeps each element's
FormField — its key, dirty state, errors, and bound DOM/focus — attached to the element, not the index (identity semantics).
- The public form API is largely preserved:
form.data remains readable and writable, field accessors and $use() are unchanged, and a new form.setData(recipe) is added for atomic batch edits.
- Because the store batches writes and flushes on a microtask, the form's public contract becomes asynchronous: value-level derived state is guaranteed current only after the next flush. Framework bindings render post-flush so UI is always fresh; imperative/test code uses a hidden
form['~'].flush() escape hatch.
Primitives cannot carry identity (they are immutable and the store wraps only objects/arrays), so the model is a hybrid: reference nodes (objects/arrays) get true identity; primitive-leaf array elements remain positional (index-keyed). This is an accepted, principled limitation — a primitive leaf has no sub-fields and its value is effectively its identity.
User Stories
- As a form developer, I want a
FormField over an object array element to keep its identity when I reorder the array, so that the row's dirty state, validation error, and focus follow the moved item instead of staying on the old index.
- As a form developer, I want to drag-and-drop reorder a list of editable rows without any row remounting, so that the user does not lose focus or in-progress input.
- As a form developer, I want
field.key to remain stable across an array reorder, so that I can safely use it as a framework list key (:key / React key) and rows are not needlessly re-created.
- As a form developer, I want
array.delete(key) to remove the correct element by identity, so that deletion works correctly even after the array has been reordered.
- As a form developer, I want
splice, sort, reverse, unshift, fill, pop, shift, push, and move-style reorders to all keep field state consistent, so that I never hit a mutation shape the library forgets to handle.
- As a form developer, I want to insert an element into the middle of an array and have the existing elements' fields keep their identity, so that only the new element gets a fresh field.
- As a form developer, I want to remove an element from the middle of an array and have the remaining elements' fields keep their identity, so that unrelated rows are undisturbed.
- As a form developer, I want
sort() to reorder both the data and the corresponding fields by value, so that after sorting each field is still attached to its original datum.
- As a form developer, I want to continue reading
form.data as the pure schema shape, so that nothing about the new internals leaks into the data I read, serialize, or validate.
- As a form developer, I want to keep writing directly to
form.data (form.data.name = 'Jane', form.data.tags.push(x), .splice(...), delete form.data.x), so that my existing call sites and mental model are preserved.
- As a form developer, I want a new
form.setData(recipe) that applies a batch of mutations atomically with a single validation, so that bulk programmatic edits are efficient and consistent.
- As a form developer, I want
field.handleChange, field.model, Vue v-model, and React { value, onUpdate } to behave exactly as before, so that per-input binding is unaffected by the engine change.
- As a form developer editing a primitive array (e.g. a list of tags), I want each element to have a working field with its own value and errors, so that primitive arrays remain fully usable.
- As a form developer, I accept that reordering a primitive array keeps field state with the index rather than the value, so that the library can stay simple where identity is intrinsically impossible.
- As a form developer, I want
reset() to restore the form to its source values, keeping fields stable positionally (no remount, no focus loss on reset), so that reset behaves like today.
- As a form developer, I want a pristine form to pick up new
sourceValues when they change, so that background data refreshes flow into the form.
- As a form developer, I want a dirty form to not be overwritten when
sourceValues change (except during submit), with a warning, so that in-progress edits are never silently discarded.
- As a form developer whose list elements have stable ids, I want an optional key resolver so that identity is preserved across an external
sourceValues refresh even if rows were reordered server-side.
- As a form developer, I want
submit({ values }) to still receive the validated, non-null output, so that my submit handler contract is unchanged.
- As a form developer, I want validation to still run whole-schema and filter issues to each field's path, so that error reporting per field is unchanged.
- As a form developer, I want the form to remain reactive in Vue, so that reads in templates/computeds update when the form changes.
- As a form developer, I want the form to trigger React re-renders when relevant state changes, so that components reflect the latest form state.
- As a form developer writing imperative code or tests, I want a way to force the pending batch to apply synchronously, so that I can read derived state immediately after a write.
- As a form developer, I want that synchronous force-flush to be a hidden internal escape hatch (
form['~'].flush()), so that the public API stays async-pure and I am not tempted to build on it.
- As a form developer, I want UI bindings to always read fresh state, so that the async write model never shows stale values on screen.
- As a form developer, I understand that after a programmatic write, reading
form.data/field.value/isDirty synchronously may be stale until the next flush, so that I know to await a tick or force-flush in imperative code.
- As a library maintainer, I want the
on-change mirror, the nested _array field cache, and the data-path→cache-path transform removed, so that there is no mutation-type-specific code left to maintain.
- As a library maintainer, I want the field cache keyed by engine-maintained object identity, so that array mutations require zero cache-sync logic.
- As a library maintainer, I want the reactive helper layer that exists only for
@vue/reactivity removed or replaced, so that the codebase reflects the new engine.
- As a library maintainer, I want both the React and Vue adapters rewritten against the new engine, so that framework integration is correct and idiomatic for the store.
- As a form developer, I want nested object fields (
form.fields.user.email.$use()) to work unchanged, so that non-array parts of the form are unaffected.
- As a form developer, I want
field.schema metadata (required, min/max, etc.) to keep working, so that schema-driven UI is unaffected.
- As a form developer, I want
field.isDirty/field.isChanged/form.isDirty/form.isChanged to keep their semantics, so that dirty tracking is unchanged.
- As a form developer, I want a File/Blob or class-instance value to remain an opaque leaf (not descended into), so that non-plain values are handled safely.
- As a form developer, I accept that
field.key's string format changes and array.delete(key) no longer parses an index out of the key, so that identity-based behavior is possible (a breaking change, acceptable pre-1.0).
Implementation Decisions
-
Reactive engine swap. form-core is rebuilt on the @solidjs/signals store (next channel) instead of @vue/reactivity. The @vue/reactivity-specific reactive helper layer (toReactive/refEffect and friends) is removed or replaced with engine-native equivalents. (See ADR: Adopt the @solidjs/signals store as the reactive engine.)
-
Identity semantics for array-element fields. A FormField over a reference node travels with its datum across reorder/splice/sort/move; at(i) after a move returns the moved element's original field. (See ADR: Identity semantics for array-element fields.) This is the chosen behavior over the previous positional semantics.
-
Field cache re-keyed by engine identity. The nested _array field cache, getFieldCachePath, and the on-change onValidate/onChange array-method mirror are deleted. The field cache becomes a lookup keyed by the engine's per-raw-object identity (conceptually WeakMap<rawObject, { self?: Field; children: Map<propOrIndex, Field> }>):
- A reference-node field (object/array) is stored on that node's entry (
self) and rides along with the object through any mutation — no sync.
- A primitive-leaf field is stored on its parent's entry under
children, keyed by property name for objects (stable) or by index for arrays (positional — the accepted hybrid limitation). Primitive-leaf fields are cached (not recreated per $use) so their state survives re-render; on a primitive-array reorder their state stays with the index (accepted).
-
Value and path derivation. field.value for a reference node reads the store node directly (always current); for a primitive leaf it reads through the parent node + key. field.path is derived from the engine (store path / parent links) rather than a stored string.
-
field.key redefinition (breaking). field.key becomes a stable per-node id (stable across reorder), replacing the previous path@time-rand format that embedded the index.
-
array.delete(key) by identity (breaking). Deletion looks the field up by key and removes its element by identity, instead of regex-parsing an index out of the key string.
-
Writable form.data facade + setData recipe. form.data stays readable and directly writable via a thin path-tracking proxy that routes set/delete/array-mutator calls into the engine's setter (this facade replaces the removed on-change proxy). Additionally, form.setData(recipe) applies a batch of draft mutations atomically with a single validation.
-
Asynchronous flush contract. Writes are microtask-batched; value-level derived state (form.data, field.value, isDirty, isChanged) is guaranteed current only after the next flush. There is no public settled promise and writes do not return settle-promises. (See ADR: Asynchronous flush contract.)
-
Hidden force-flush. A synchronous force-flush is exposed only on a hidden internal namespace: form['~'].flush() (echoing the ~standard convention). It is not part of the public contract; it forces pending values current (validation remains async).
-
Whole-form validation trigger. Replaces the on-change global callback with a single engine effect that observes the whole data tree (Solid deep(store) pattern) and schedules validation. Validation stays whole-schema with per-field issue filtering by path; on-change/on-blur/on-submit timing semantics are preserved. Validation remains asynchronous.
-
Reset and source updates via reconcile. reset() and non-dirty sourceValues updates apply the new snapshot with the engine's reconcile, using positional key (key = null) by default (reproduces current reset behavior and assumes nothing about the schema). An optional per-form key resolver is exposed for consumers whose list elements have stable ids and who want identity preserved across external refreshes; this is a documented extension point. The dirty-guard is unchanged (skip source-update while dirty, except during submit).
-
Framework adapters rewritten. The React adapter bridges the store into re-renders via useSyncExternalStore (subscribe/getSnapshot). The Vue adapter bridges store reactivity into Vue (e.g. a trigger/triggerRef bump or leaf getters), and preserves field.model as a writable v-model computed and React's { value, onUpdate }. enableExternalSource may be used where Vue refs must feed the store.
-
Data purity preserved. form.data presents the exact schema shape; any engine metadata stays on non-enumerable/internal symbols invisible to enumeration, spreads, JSON.stringify, and Standard Schema validation. submit({ values }) continues to pass the re-parsed validated output.
Testing Decisions
-
What makes a good test here: assert only externally observable form behavior through the public handle — form.data, field accessors + $use(), field.value/handleChange/key/isDirty/errors, form.setData, form.reset, submit, form.isDirty/isChanged. Do not assert internal cache structure, symbols, or engine nodes. In particular, assert identity via observable proxies for behavior: after a reorder, the field for the moved element is the same field (same stable key, preserved dirty/error state), and at(newIndex) returns it.
-
Primary seam — useFormCore (packages/core/src/core.test.ts). The single highest existing seam; all designed behavior is observable here. Prior art: the existing arrays, accessor (dot in object key, array value without array itself), and readonly tests. Update/extend these and add:
- identity preserved across
splice insert/remove, sort, reverse, unshift, move/reorder (object arrays): same key, preserved dirty/error, correct at(i) after move;
- primitive-array reorder is positional (documented behavior);
array.delete(key) removes the right element after a reorder;
form.setData(recipe) atomic batch + single validation;
reset() restores source and keeps fields stable positionally; dirty-guard on sourceValues change; optional key resolver preserves identity across an id-keyed refresh.
-
Async test convention (new, one primitive): because the contract is async, tests read derived state only after form['~'].flush(). This is the single new testing primitive; apply it consistently.
-
reactive.test.ts is replaced, not extended — it targets @vue/reactivity helpers removed by the engine swap.
-
Adapter seams stay narrow (packages/react/src/index.test.ts, packages/vue/src/index.test.ts): test only framework-binding behavior (React re-render via useSyncExternalStore; Vue v-model/reactivity bridge), not core semantics. Note: React adapter tests are pre-existing failures on master and should be brought back to green as part of the rewrite.
Out of Scope
- Per-element identity for primitive array elements — impossible without boxing; explicitly accepted as positional.
- Boxing primitives into wrapper objects or building a shadow/unwrapping data layer — rejected in favor of the hybrid.
- A public
settled promise or promise-returning writes — rejected; only the hidden form['~'].flush() exists.
- Keyed-by-
id reconcile as the default — positional is the default; keyed is an optional resolver.
- Changes to the Standard Schema / JSON Schema metadata pipeline, discriminated unions, translation, and hooks beyond what the engine swap requires.
- New form features unrelated to the sync/identity redesign.
Further Notes
- This is a deliberate, breaking change set, acceptable while the library is pre-1.0 (
v0.23.x): field.key's format changes, array.delete(key) no longer parses an index, and the write/read contract becomes asynchronous.
@solidjs/signals@next is pre-release (beta, breaking changes expected); the developer has explicitly accepted the pre-release dependency and the blast radius of rewriting both framework adapters.
- The engine's
storeLookup (raw object → node) is the exact mechanism the earlier hand-rolled design would have built (a node id + memoized node index); using the engine's version removes that machinery entirely.
- The domain glossary (
CONTEXT.md) has been updated with the vocabulary used here: reactive engine, store node, reconcile, writable facade, recipe, flush, internal bag, identity/positional semantics, reference node, primitive leaf.
- Related ADRs to be recorded: Identity semantics for array-element fields, Adopt the @solidjs/signals store as the reactive engine, Asynchronous flush contract.
Problem Statement
When editing array fields in a form, the form's two internal data structures — the reactive form data and the field cache (the store of materialized
FormFieldinstances) — drift out of sync on any array mutation. Today the field cache is a nested tree with array items stored positionally (_array[i]), and anon-changeproxy hook (onValidate) tries to keep it aligned by replaying each individual array method (splice,sort,reverse,unshift,fill,pop,shift) onto the cache withundefinedplaceholders, while plain index assignments/deletes simply blow away the cache subtree.This is fragile and hard to maintain:
FormFielddoes not travel with its datum. Reordering an array (e.g. drag-and-drop) leaves each field bound to its old index — itskey, dirty flag, validation error, and therefore its DOM row and focus stay with the slot, not the value. For editable object rows this is a latent correctness/UX bug.items[2].avs cache pathitems._array[2].a) and the.exhaustive()match over a closed set of array methods are ongoing foot-guns.The developer wants either (a) a streamlined, more reliable, easier-to-maintain sync, or (b) to store field state inline with the data so no separate sync exists — and prefers (b) if all data types (objects, arrays, primitives) can be represented.
Solution
Adopt an approach where array-element identity is maintained by the reactivity engine itself, so field state travels with its datum and there is no sync step to get wrong.
Concretely: replace
@vue/reactivitywith the@solidjs/signalsstore (SolidJS 2.0,next/@solidjs/signals@next) as the reactive engine for form-core. The store keeps one stable reactive node per raw object (a globalstoreLookupWeakMap: raw object → node). Reordering an array moves the same underlying objects, so each object keeps its node identity with no resync. The field cache is re-keyed by that engine-maintained object identity, which makes the entire hand-written mutation-mirror disappear.From the consumer's perspective:
FormField— itskey, dirty state, errors, and bound DOM/focus — attached to the element, not the index (identity semantics).form.dataremains readable and writable, field accessors and$use()are unchanged, and a newform.setData(recipe)is added for atomic batch edits.form['~'].flush()escape hatch.Primitives cannot carry identity (they are immutable and the store wraps only objects/arrays), so the model is a hybrid: reference nodes (objects/arrays) get true identity; primitive-leaf array elements remain positional (index-keyed). This is an accepted, principled limitation — a primitive leaf has no sub-fields and its value is effectively its identity.
User Stories
FormFieldover an object array element to keep its identity when I reorder the array, so that the row's dirty state, validation error, and focus follow the moved item instead of staying on the old index.field.keyto remain stable across an array reorder, so that I can safely use it as a framework list key (:key/ Reactkey) and rows are not needlessly re-created.array.delete(key)to remove the correct element by identity, so that deletion works correctly even after the array has been reordered.splice,sort,reverse,unshift,fill,pop,shift,push, andmove-style reorders to all keep field state consistent, so that I never hit a mutation shape the library forgets to handle.sort()to reorder both the data and the corresponding fields by value, so that after sorting each field is still attached to its original datum.form.dataas the pure schema shape, so that nothing about the new internals leaks into the data I read, serialize, or validate.form.data(form.data.name = 'Jane',form.data.tags.push(x),.splice(...),delete form.data.x), so that my existing call sites and mental model are preserved.form.setData(recipe)that applies a batch of mutations atomically with a single validation, so that bulk programmatic edits are efficient and consistent.field.handleChange,field.model, Vuev-model, and React{ value, onUpdate }to behave exactly as before, so that per-input binding is unaffected by the engine change.reset()to restore the form to its source values, keeping fields stable positionally (no remount, no focus loss on reset), so that reset behaves like today.sourceValueswhen they change, so that background data refreshes flow into the form.sourceValueschange (except during submit), with a warning, so that in-progress edits are never silently discarded.sourceValuesrefresh even if rows were reordered server-side.submit({ values })to still receive the validated, non-null output, so that my submit handler contract is unchanged.form['~'].flush()), so that the public API stays async-pure and I am not tempted to build on it.form.data/field.value/isDirtysynchronously may be stale until the next flush, so that I know to await a tick or force-flush in imperative code.on-changemirror, the nested_arrayfield cache, and the data-path→cache-path transform removed, so that there is no mutation-type-specific code left to maintain.@vue/reactivityremoved or replaced, so that the codebase reflects the new engine.form.fields.user.email.$use()) to work unchanged, so that non-array parts of the form are unaffected.field.schemametadata (required, min/max, etc.) to keep working, so that schema-driven UI is unaffected.field.isDirty/field.isChanged/form.isDirty/form.isChangedto keep their semantics, so that dirty tracking is unchanged.field.key's string format changes andarray.delete(key)no longer parses an index out of the key, so that identity-based behavior is possible (a breaking change, acceptable pre-1.0).Implementation Decisions
Reactive engine swap. form-core is rebuilt on the
@solidjs/signalsstore (nextchannel) instead of@vue/reactivity. The@vue/reactivity-specific reactive helper layer (toReactive/refEffectand friends) is removed or replaced with engine-native equivalents. (See ADR: Adopt the @solidjs/signals store as the reactive engine.)Identity semantics for array-element fields. A
FormFieldover a reference node travels with its datum across reorder/splice/sort/move;at(i)after a move returns the moved element's original field. (See ADR: Identity semantics for array-element fields.) This is the chosen behavior over the previous positional semantics.Field cache re-keyed by engine identity. The nested
_arrayfield cache,getFieldCachePath, and theon-changeonValidate/onChangearray-method mirror are deleted. The field cache becomes a lookup keyed by the engine's per-raw-object identity (conceptuallyWeakMap<rawObject, { self?: Field; children: Map<propOrIndex, Field> }>):self) and rides along with the object through any mutation — no sync.children, keyed by property name for objects (stable) or by index for arrays (positional — the accepted hybrid limitation). Primitive-leaf fields are cached (not recreated per$use) so their state survives re-render; on a primitive-array reorder their state stays with the index (accepted).Value and path derivation.
field.valuefor a reference node reads the store node directly (always current); for a primitive leaf it reads through the parent node + key.field.pathis derived from the engine (store path / parent links) rather than a stored string.field.keyredefinition (breaking).field.keybecomes a stable per-node id (stable across reorder), replacing the previouspath@time-randformat that embedded the index.array.delete(key)by identity (breaking). Deletion looks the field up by key and removes its element by identity, instead of regex-parsing an index out of the key string.Writable
form.datafacade +setDatarecipe.form.datastays readable and directly writable via a thin path-tracking proxy that routesset/delete/array-mutator calls into the engine's setter (this facade replaces the removedon-changeproxy). Additionally,form.setData(recipe)applies a batch of draft mutations atomically with a single validation.Asynchronous flush contract. Writes are microtask-batched; value-level derived state (
form.data,field.value,isDirty,isChanged) is guaranteed current only after the next flush. There is no publicsettledpromise and writes do not return settle-promises. (See ADR: Asynchronous flush contract.)Hidden force-flush. A synchronous force-flush is exposed only on a hidden internal namespace:
form['~'].flush()(echoing the~standardconvention). It is not part of the public contract; it forces pending values current (validation remains async).Whole-form validation trigger. Replaces the
on-changeglobal callback with a single engine effect that observes the whole data tree (Soliddeep(store)pattern) and schedules validation. Validation stays whole-schema with per-field issue filtering by path; on-change/on-blur/on-submit timing semantics are preserved. Validation remains asynchronous.Reset and source updates via
reconcile.reset()and non-dirtysourceValuesupdates apply the new snapshot with the engine'sreconcile, using positional key (key = null) by default (reproduces current reset behavior and assumes nothing about the schema). An optional per-form key resolver is exposed for consumers whose list elements have stable ids and who want identity preserved across external refreshes; this is a documented extension point. The dirty-guard is unchanged (skip source-update while dirty, except during submit).Framework adapters rewritten. The React adapter bridges the store into re-renders via
useSyncExternalStore(subscribe/getSnapshot). The Vue adapter bridges store reactivity into Vue (e.g. a trigger/triggerRefbump or leaf getters), and preservesfield.modelas a writablev-modelcomputed and React's{ value, onUpdate }.enableExternalSourcemay be used where Vue refs must feed the store.Data purity preserved.
form.datapresents the exact schema shape; any engine metadata stays on non-enumerable/internal symbols invisible to enumeration, spreads,JSON.stringify, and Standard Schema validation.submit({ values })continues to pass the re-parsed validated output.Testing Decisions
What makes a good test here: assert only externally observable form behavior through the public handle —
form.data, field accessors +$use(),field.value/handleChange/key/isDirty/errors,form.setData,form.reset,submit,form.isDirty/isChanged. Do not assert internal cache structure, symbols, or engine nodes. In particular, assert identity via observable proxies for behavior: after a reorder, the field for the moved element is the same field (same stablekey, preserved dirty/error state), andat(newIndex)returns it.Primary seam —
useFormCore(packages/core/src/core.test.ts). The single highest existing seam; all designed behavior is observable here. Prior art: the existingarrays,accessor(dot in object key,array value without array itself), andreadonlytests. Update/extend these and add:spliceinsert/remove,sort,reverse,unshift,move/reorder (object arrays): samekey, preserved dirty/error, correctat(i)after move;array.delete(key)removes the right element after a reorder;form.setData(recipe)atomic batch + single validation;reset()restores source and keeps fields stable positionally; dirty-guard onsourceValueschange; optional key resolver preserves identity across an id-keyed refresh.Async test convention (new, one primitive): because the contract is async, tests read derived state only after
form['~'].flush(). This is the single new testing primitive; apply it consistently.reactive.test.tsis replaced, not extended — it targets@vue/reactivityhelpers removed by the engine swap.Adapter seams stay narrow (
packages/react/src/index.test.ts,packages/vue/src/index.test.ts): test only framework-binding behavior (React re-render viauseSyncExternalStore; Vuev-model/reactivity bridge), not core semantics. Note: React adapter tests are pre-existing failures on master and should be brought back to green as part of the rewrite.Out of Scope
settledpromise or promise-returning writes — rejected; only the hiddenform['~'].flush()exists.idreconcile as the default — positional is the default; keyed is an optional resolver.Further Notes
v0.23.x):field.key's format changes,array.delete(key)no longer parses an index, and the write/read contract becomes asynchronous.@solidjs/signals@nextis pre-release (beta, breaking changes expected); the developer has explicitly accepted the pre-release dependency and the blast radius of rewriting both framework adapters.storeLookup(raw object → node) is the exact mechanism the earlier hand-rolled design would have built (a node id + memoized node index); using the engine's version removes that machinery entirely.CONTEXT.md) has been updated with the vocabulary used here: reactive engine, store node, reconcile, writable facade, recipe, flush, internal bag, identity/positional semantics, reference node, primitive leaf.