Skip to content

feat(#6): getFormDraft — imperative handle to any mounted instance by key#12

Merged
mayrang merged 2 commits into
mainfrom
feat/issue-6-programmatic-ref
May 27, 2026
Merged

feat(#6): getFormDraft — imperative handle to any mounted instance by key#12
mayrang merged 2 commits into
mainfrom
feat/issue-6-programmatic-ref

Conversation

@mayrang
Copy link
Copy Markdown
Owner

@mayrang mayrang commented May 27, 2026

Closes #6.

Summary

  • New getFormDraft<T>(key) lookup function. Returns a FormDraftHandle<T> with imperative save/discard/submit actions and current-snapshot getters for values/status/pendingChanges/lastSavedAt/error.
  • Returns undefined when no instance with that key is mounted; for reactive subscriptions inside React, use useFormDraftStatus(key).

Why

Use cases the existing component-scoped API doesn't cover:

  • Header buttons outside the form (modal pattern)
  • Nav guards (beforeunload, route blockers)
  • Auto-discard after timeout
  • Dev-tools / debug panels

How it works

  • RegistryEntry extended with action refs + state refs. Hook syncs refs each render so external callers always invoke the latest closure even when defaultValues/key/storage change.
  • Registry rewritten as a per-key STACK (Map<string, RegistryEntry[]>). Two instances sharing a key co-exist correctly — most-recently-mounted wins; when it unmounts, the previous instance becomes active again.
  • Status machine transitions route through registry.notifySubscribers so useFormDraftStatus subscribers see updates regardless of which entry on the stack is active.
  • Register-once on [key] + separate notifySubscribers effect on [lastSavedAt] — eliminates the prior unregister→register flicker that showed idle between saving and saved.

Vetting

4 rounds of code review + adversarial audit. Real bugs caught:

  • valuesRef was in useEffect while other refs were render-phase → imperative reads got stale committed-vs-effect-flush values
  • Identity-guarded delete only solved half the duplicate-key problem → refcount stack
  • useFormDraftStatus flicker through idle on every save → split register/notify effects
  • useFormDraftStatus subscribed to entry's machine at subscribe-time → stuck on first registration after stack swap → route via notifySubscribers
  • subscribers Map never cleaned empty Sets → leak with dynamic per-route keys
  • excludeFieldsRef declared after the restore effect that read it → hoisted

Test plan

  • npm test — 183 unit tests pass
  • npx tsc --noEmit — clean
  • npx size-limit — 5.42 KB brotli (8 KB gate)
  • Manual: write a beforeunload guard that checks getFormDraft('profile')?.getPendingChanges(); verify it sees true when typing, false after save/discard

mayrang added 2 commits May 28, 2026 03:34
… key

Closes #6. Imperative API for external control of useFormDraft instances —
header save buttons outside the form, nav guards (beforeunload), auto-discard
timers, dev-tools panels.

  const handle = getFormDraft<MyFormValues>('profile-form');
  if (handle?.getPendingChanges()) { /* warn user */ }
  await handle?.save();
  handle?.discard();

  // As a submit handler from a button outside the form:
  const submit = handle?.submit(async (v) => api.update(v));
  await submit?.();

Returns undefined when no instance with that key is currently mounted. The
handle's getters always return CURRENT state (read via refs on every call);
for reactive subscriptions inside a component use useFormDraftStatus(key).

## How it works

- RegistryEntry extended with action refs (saveRef/discardRef/submitRef) and
  state refs (valuesRef/pendingChangesRef/errorRef/lastSavedAtRef). Hook
  syncs the refs each render so external callers always see the latest
  closures even when key/storage/defaultValues change.
- Registry rewritten as a per-key STACK (Map<string, RegistryEntry[]>), so
  two instances sharing a key co-exist correctly: most-recently-mounted
  wins for getDraft, and when it unmounts the previous instance becomes
  active again automatically.
- Status machine transitions route through registry.notifySubscribers so
  useFormDraftStatus subscribers see updates regardless of which entry on
  the stack is active.
- Registration is once per mount; lastSavedAt changes notify subscribers
  without an unregister→register pair (which previously flickered status
  pills through DEFAULT_SNAPSHOT).

## Vetting

4 rounds of code review + adversarial audit each in parallel. Real bugs
caught:

  R1 valuesRef.current was set in useEffect while other snapshot refs were
     set during render → moved to render so external imperative reads see
     committed values immediately.
  R1 duplicate-key registration: second mount overwrote first → identity-
     guarded delete first attempt.
  R2 HIGH: identity-guard only half-fix — when B unmounts before A, A
     became invisible despite being alive. Replaced single-slot map with
     a refcount stack. B unmount leaves A on top.
  R2 MEDIUM: useFormDraftStatus flickered through 'idle' on every
     successful save because the registry effect re-ran on lastSavedAt
     change, momentarily emptying the entry. Split into register-once
     plus a separate notifySubscribers effect; useFormDraftStatus reads
     lastSavedAtRef directly.
  R2 LOW: subscribers Map never cleared empty Sets — leak under per-route
     dynamic keys. Cleanup on last unsubscribe.
  R3 MEDIUM: useFormDraftStatus subscribed to entry.statusMachine at
     subscribe-time, so after a stack swap (B mounts on top of A), B's
     status transitions didn't fire observer re-renders. Route all
     statusMachine transitions through notifySubscribers(key);
     useFormDraftStatus subscribes via registry channel only.
  R4 hoisted excludeFieldsRef above the restore effect for correct
     declaration order (worked at runtime but read wrong).

## Tests

11 unit tests in getFormDraft.test.tsx covering: undefined-when-unmounted,
defined-after-mount, getters reflect current state, external save/discard/
submit, ref-of-callback stability across defaults change, duplicate-key
stack swap, useFormDraftStatus no-flicker, and the R3 active-entry
subscription regression. 183 tests total. Bundle 5.42 KB brotli.
@mayrang mayrang merged commit 9b90b5f into main May 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.2: Programmatic ref API for external control

1 participant