Persistence edge cases & hydration

The gotchas worth knowing before you ship: how schema changes drop stale drafts, how hydration races first paint, and how concurrent writes resolve.

Category
Module
Schema-change invalidation
automatic via fingerprint
Cross-tab
last-writer-wins (no coordination)
Hydration
pre-first-render (sync backends) / post-mount (indexeddb)

This form persists to 'session' with clearOnSubmitSuccess: false and score is a numeric field. Type a score, add tags, refresh: values hydrate before the first render and the draft survives a successful submit. Behind the scenes, the orphan-cleanup pass runs every mount, the schema fingerprint gates the read, and the numeric blank handling stays consistent across reload.

Edge Cases Demo Open in playground
Tags (array — append, watch the persisted shape grow)

clearOnSubmitSuccess: false keeps the draft after a successful submit — useful for wizards with review pages or retry-prone APIs. Refresh the page after editing; the draft hydrates before the first render.

Schema-change auto-invalidation

Storage keys carry the schema's structural fingerprint:

attaform:signup:7c3a0b   ← key on disk
                       └────┘
                       fingerprint of the current schema

When the schema changes shape (adding / removing / renaming a field, changing a leaf type, restructuring nested objects) the fingerprint changes. New writes go to a new key (attaform:signup:9d2b1f); the old key (attaform:signup:7c3a0b) becomes unreachable.

On the next mount, the orphan-cleanup pass enumerates keys under attaform:signup via FormStorage.listKeys, keeps the current-fingerprint entry, and removes the rest. No manual version bump, no possibility of forgetting it, and refinements (.refine(), .transform()) collapse to opaque sentinels in the fingerprint so tightening a refinement doesn't unnecessarily drop drafts.

Malformed entries (corrupted JSON, envelope-version mismatch, anything not matching the payload contract) wipe on read. "Truly absent" entries are a no-op; the wipe fires only when there's actually something to clean.

To force-invalidate without changing the schema (an unrelated tweak you want users to retest from scratch), call form.clearPersistedDraft() at mount or wrap the schema in a thin no-op layer that perturbs the fingerprint. Attaform deliberately doesn't expose a forceVersion knob; the schema fingerprint captures every legitimate "shape changed" signal.

Hydration timing

sync backends (local / session)        async backend (indexeddb)
─────────────────────────────          ─────────────────────────────
mount                                  mount
└─ pre-render hydration                ├─ render with schema defaults
   └─ first render with values         └─ post-mount hydration
                                          └─ render with values

'local' and 'session' hydrate before the first render: no flash, the user never sees the schema's defaults. 'indexeddb' is async; the first paint uses the schema defaults, then the hydration applies on the next microtask. For most forms this is invisible; for first-paint-critical surfaces (a credit-card form on a checkout funnel), 'session' is the safer choice.

Numeric blank-state survives reload symmetrically. A field with no defaultValues entry and a numeric type starts blank-marked at construction; the persisted envelope includes the blankPaths set, so a numeric field cleared by the user stays visually empty after rehydration instead of resurrecting its slim default.

Cross-tab semantics

localStorage writes from two tabs race; the persistence module does NOT coordinate. Last-writer-wins. Two cases worth knowing:

  • Tab A is mid-debounce; Tab B writes; Tab A's debounce overwrites.
  • The persistence module doesn't subscribe to the storage event; fresh writes from another tab don't replay into the live form.

If multi-tab consistency matters, use 'session' (tab-scoped) or layer Multi-tab sync on top. That does mirror live edits across tabs via BroadcastChannel, and works orthogonally to persistence.

Cross-SFC behavior

Two SFCs binding the same path under the same form key share the FormStore and the persistence registry. Opt-ins are per-DOM-element, not per-SFC:

  • SFC A renders <input v-register="form.register('email', { persist: true })">; A's element opted in.
  • SFC B renders <input v-register="form.register('email')"> (no opt-in); B's element NOT opted in.
  • Typing in A persists. Typing in B doesn't.

Unmount SFC A and B's typing stops persisting (no opt-ins remain). Re-mount A and the new element gets a fresh opt-in. The registry tracks elements via WeakMap; rapid mount/unmount cycles auto-clean.

Storage degradation

Backend failures (quota exceeded, Safari private mode, IndexedDB blocked) log a one-shot console.warn per form in dev mode and are silently swallowed in production. The form stays usable; writes just don't land.

If persistence appears to drop writes in dev, check the console first. For surfaces where storage failures need user-facing recovery (a wizard with a "Save Failed" toast), wrap form.persist() in your own try; that surface throws on adapter errors, unlike the implicit debounced writes.

Component binding patterns

<MyComponent v-register="register('name')" /> works through four patterns, each appropriate for different component shapes. The recommended pattern for most cases is useRegister:

PatternWhen to usePersistence works?
Native input rootComponent's root is <input> / <select>Yes, directly
useRegister (preferred)Component wraps a native input in stylingYes, on the inner native element
injectFormCompound component touching multiple fieldsYes, per-register call inside
assignKeyWeb Components / non-Vue elementsYes, but you wire the listeners

The full walkthrough on each pattern lives in useRegister for custom components.

What persistence is NOT for

  • Sensitive data. See Sensitive-name protection: the heuristic blocks the obvious cases, but per-field opt-in is the real defense.
  • Authoritative state. Persistence is for draft UX, not source-of-truth. The server still owns the canonical record.
  • Cross-form coordination. Each form persists independently. Multiple forms can share a key (sharing a FormStore and the persistence entry), but they're still one form to the persistence layer.
  • Schema migrations. Auto-invalidation handles the common case. To rename a field without losing state, read the raw entry before the schema change ships and massage it into the new shape before calling reset(). Attaform deliberately doesn't ship a renaming-aware migration helper; renames are a write-once transformation the consumer owns.

Where to next

  • Multi-tab sync: live convergence across same-origin tabs (different problem; complementary to persistence).
  • useRegister: the recommended pattern for custom components, persistence-aware.
  • Persistence overview: the two-gate model and schema-aware hydration in one page.