From 0edbf914a7360ea6e0ddea0e16c1427cae7d35d9 Mon Sep 17 00:00:00 2001 From: mayrang Date: Thu, 28 May 2026 03:34:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(#6):=20getFormDraft=20=E2=80=94=20impe?= =?UTF-8?q?rative=20handle=20to=20any=20mounted=20instance=20by=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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('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), 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. --- README.md | 24 ++ src/__tests__/getFormDraft.test.tsx | 344 ++++++++++++++++++++++ src/__tests__/useFormDraftStatus.test.tsx | 28 +- src/getFormDraft.ts | 57 ++++ src/index.ts | 2 + src/internal/registry.ts | 93 +++++- src/useFormDraft.ts | 83 +++++- src/useFormDraftStatus.ts | 18 +- 8 files changed, 620 insertions(+), 29 deletions(-) create mode 100644 src/__tests__/getFormDraft.test.tsx create mode 100644 src/getFormDraft.ts diff --git a/README.md b/README.md index 6f8c3b6..cb65c9a 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,30 @@ function SavingIndicator() { Backed by `useSyncExternalStore`; SSR-safe (renders `idle` on the server). +## External control (`getFormDraft`) + +Sometimes the save/discard buttons live outside the form — a modal's header bar, a nav guard, a "discard after timeout" hook. `getFormDraft` looks up a mounted instance by its `key` and returns an imperative handle: + +```tsx +import { getFormDraft } from 'formdraft'; + +// Outside React (route guard, beforeunload listener, dev tools, etc.) +const handle = getFormDraft('profile-form'); +if (handle?.getPendingChanges()) { + // warn the user they have unsaved changes +} +await handle?.save(); +handle?.discard(); + +// As a submit handler from a header button: +const submit = handle?.submit(async (values) => { + await api.update(values); +}); +await submit?.(); +``` + +Returns `undefined` when no instance with that key is currently mounted. The handle's getters always return the **current** state, not a snapshot from when you called `getFormDraft`. For reactive subscription inside a component, use `useFormDraftStatus(key)` instead. + ## Multi-tab strategies | Strategy | What happens on remote change | diff --git a/src/__tests__/getFormDraft.test.tsx b/src/__tests__/getFormDraft.test.tsx new file mode 100644 index 0000000..a26855d --- /dev/null +++ b/src/__tests__/getFormDraft.test.tsx @@ -0,0 +1,344 @@ +import { StrictMode } from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { useFormDraft } from '../useFormDraft'; +import { useFormDraftStatus } from '../useFormDraftStatus'; +import { getFormDraft } from '../getFormDraft'; +import { zodAdapter } from '../internal/schemaValidation'; +import { localStorageAdapter } from '../storage/localStorage'; +import { + _clearRegistryForTests, + notifySubscribers, + registerDraft, + subscribeRegistry as subscribeRegistryHelper, + type RegistryEntry, +} from '../internal/registry'; +import { createStatusMachine, type StatusMachine } from '../internal/statusMachine'; + +function makeMachineWithNotify(key: string): { machine: StatusMachine; unsubMachine: () => void } { + const machine = createStatusMachine(); + const unsubMachine = machine.subscribe(() => notifySubscribers(key)); + return { machine, unsubMachine }; +} + +function makeRegistryEntry(machine: StatusMachine): RegistryEntry { + return { + statusMachine: machine, + saveRef: { current: async () => {} }, + discardRef: { current: () => {} }, + submitRef: { current: () => async () => undefined }, + valuesRef: { current: {} }, + pendingChangesRef: { current: false }, + errorRef: { current: null }, + lastSavedAtRef: { current: null }, + }; +} + +const Schema = z.object({ name: z.string(), age: z.number() }); +type V = z.infer; +const DEFAULTS: V = { name: '', age: 0 }; + +function makeProbe(key: string) { + function Inner() { + const draft = useFormDraft({ + key, + schema: zodAdapter(Schema), + defaultValues: DEFAULTS, + storage: localStorageAdapter(), + syncDebounceMs: 50, + multiTab: false, + }); + return ( +
+ {draft.values.name} + {draft.status} + {String(draft.pendingChanges)} +
+ ); + } + function Probe() { + return ( + + + + ); + } + return Probe; +} + +describe('getFormDraft', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + localStorage.clear(); + _clearRegistryForTests(); + }); + afterEach(() => vi.useRealTimers()); + + it('returns undefined when no instance is mounted', () => { + expect(getFormDraft('nothing-mounted')).toBeUndefined(); + }); + + it('returns a handle once a useFormDraft instance with that key mounts', () => { + const Probe = makeProbe('mount-test'); + render(); + const handle = getFormDraft('mount-test'); + expect(handle).toBeDefined(); + expect(typeof handle?.save).toBe('function'); + expect(typeof handle?.discard).toBe('function'); + expect(typeof handle?.submit).toBe('function'); + }); + + it('getValues reflects current state (not snapshot at handle creation)', async () => { + const Probe = makeProbe('values-test'); + render(); + const handle = getFormDraft('values-test'); + expect(handle?.getValues().name).toBe(''); + act(() => screen.getByTestId('set').click()); + // Same handle, value updated in-place + expect(handle?.getValues().name).toBe('Alice'); + }); + + it('getPendingChanges flips true on user input', async () => { + const Probe = makeProbe('pending-test'); + render(); + const handle = getFormDraft('pending-test'); + expect(handle?.getPendingChanges()).toBe(false); + act(() => screen.getByTestId('set').click()); + expect(handle?.getPendingChanges()).toBe(true); + }); + + it('external discard() clears storage and resets values', async () => { + const Probe = makeProbe('discard-test'); + render(); + act(() => screen.getByTestId('set').click()); + await vi.advanceTimersByTimeAsync(100); + expect(localStorage.getItem('formdraft:discard-test')).not.toBeNull(); + + const handle = getFormDraft('discard-test'); + act(() => handle?.discard()); + await vi.advanceTimersByTimeAsync(100); + expect(localStorage.getItem('formdraft:discard-test')).toBeNull(); + expect(screen.getByTestId('name').textContent).toBe(''); + }); + + it('external save() flushes the sync queue', async () => { + const sync = vi.fn().mockResolvedValue(undefined); + function Inner() { + const draft = useFormDraft({ + key: 'save-test', + schema: zodAdapter(Schema), + defaultValues: DEFAULTS, + storage: localStorageAdapter(), + sync, + syncDebounceMs: 5000, // long debounce — save should bypass it + multiTab: false, + }); + return