Skip to content

Array-element field identity via @solidjs/signals store (replace on-change sync) #4

Description

@LouisHaftmann

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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.
  14. 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.
  15. 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.
  16. 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.
  17. 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.
  18. 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.
  19. As a form developer, I want submit({ values }) to still receive the validated, non-null output, so that my submit handler contract is unchanged.
  20. 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.
  21. As a form developer, I want the form to remain reactive in Vue, so that reads in templates/computeds update when the form changes.
  22. 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.
  23. 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.
  24. 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.
  25. 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.
  26. 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.
  27. 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.
  28. As a library maintainer, I want the field cache keyed by engine-maintained object identity, so that array mutations require zero cache-sync logic.
  29. 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.
  30. 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.
  31. 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.
  32. As a form developer, I want field.schema metadata (required, min/max, etc.) to keep working, so that schema-driven UI is unaffected.
  33. As a form developer, I want field.isDirty/field.isChanged/form.isDirty/form.isChanged to keep their semantics, so that dirty tracking is unchanged.
  34. 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.
  35. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentTriaged spec ready for an agent to implement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions