feat(#6): getFormDraft — imperative handle to any mounted instance by key#12
Merged
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #6.
Summary
getFormDraft<T>(key)lookup function. Returns aFormDraftHandle<T>with imperativesave/discard/submitactions and current-snapshot getters for values/status/pendingChanges/lastSavedAt/error.undefinedwhen no instance with that key is mounted; for reactive subscriptions inside React, useuseFormDraftStatus(key).Why
Use cases the existing component-scoped API doesn't cover:
beforeunload, route blockers)How it works
Map<string, RegistryEntry[]>). Two instances sharing a key co-exist correctly — most-recently-mounted wins; when it unmounts, the previous instance becomes active again.registry.notifySubscriberssouseFormDraftStatussubscribers see updates regardless of which entry on the stack is active.[key]+ separatenotifySubscriberseffect on[lastSavedAt]— eliminates the prior unregister→register flicker that showedidlebetweensavingandsaved.Vetting
4 rounds of code review + adversarial audit. Real bugs caught:
valuesRefwas inuseEffectwhile other refs were render-phase → imperative reads got stale committed-vs-effect-flush valuesuseFormDraftStatusflicker throughidleon every save → split register/notify effectsuseFormDraftStatussubscribed to entry's machine at subscribe-time → stuck on first registration after stack swap → route via notifySubscriberssubscribersMap never cleaned empty Sets → leak with dynamic per-route keysexcludeFieldsRefdeclared after the restore effect that read it → hoistedTest plan
npm test— 183 unit tests passnpx tsc --noEmit— cleannpx size-limit— 5.42 KB brotli (8 KB gate)getFormDraft('profile')?.getPendingChanges(); verify it sees true when typing, false after save/discard