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..bdd180a --- /dev/null +++ b/src/__tests__/getFormDraft.test.tsx @@ -0,0 +1,343 @@ +import { StrictMode } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { useFormDraft } from '../useFormDraft'; +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