Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyFormValues>('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 |
Expand Down
343 changes: 343 additions & 0 deletions src/__tests__/getFormDraft.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Schema>;
const DEFAULTS: V = { name: '', age: 0 };

function makeProbe(key: string) {
function Inner() {
const draft = useFormDraft<V>({
key,
schema: zodAdapter(Schema),
defaultValues: DEFAULTS,
storage: localStorageAdapter(),
syncDebounceMs: 50,
multiTab: false,
});
return (
<div>
<span data-testid="name">{draft.values.name}</span>
<span data-testid="status">{draft.status}</span>
<span data-testid="pending">{String(draft.pendingChanges)}</span>
<button data-testid="set" onClick={() => draft.set('name', 'Alice')} />
</div>
);
}
function Probe() {
return (
<StrictMode>
<Inner />
</StrictMode>
);
}
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(<Probe />);
const handle = getFormDraft<V>('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(<Probe />);
const handle = getFormDraft<V>('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(<Probe />);
const handle = getFormDraft<V>('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(<Probe />);
act(() => screen.getByTestId('set').click());
await vi.advanceTimersByTimeAsync(100);
expect(localStorage.getItem('formdraft:discard-test')).not.toBeNull();

const handle = getFormDraft<V>('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<V>({
key: 'save-test',
schema: zodAdapter(Schema),
defaultValues: DEFAULTS,
storage: localStorageAdapter(),
sync,
syncDebounceMs: 5000, // long debounce — save should bypass it
multiTab: false,
});
return <button data-testid="set" onClick={() => draft.set('name', 'Alice')} />;
}
render(
<StrictMode>
<Inner />
</StrictMode>,
);
act(() => screen.getByTestId('set').click());
// Don't advance through the long debounce — call save externally
const handle = getFormDraft<V>('save-test');
await act(async () => {
await handle?.save();
});
expect(sync).toHaveBeenCalledWith({ name: 'Alice', age: 0 });
});

it('external submit() invokes the user handler and clears storage', async () => {
const Probe = makeProbe('submit-test');
render(<Probe />);
act(() => screen.getByTestId('set').click());
await vi.advanceTimersByTimeAsync(100);
expect(localStorage.getItem('formdraft:submit-test')).not.toBeNull();

const handler = vi.fn().mockResolvedValue('ok');
const handle = getFormDraft<V>('submit-test');
let result: unknown;
await act(async () => {
result = await handle!.submit(handler)();
});
expect(handler).toHaveBeenCalledWith({ name: 'Alice', age: 0 });
expect(result).toBe('ok');
expect(localStorage.getItem('formdraft:submit-test')).toBeNull();
});

it('returns undefined after the host component unmounts', async () => {
const Probe = makeProbe('unmount-test');
const { unmount } = render(<Probe />);
expect(getFormDraft('unmount-test')).toBeDefined();
unmount();
expect(getFormDraft('unmount-test')).toBeUndefined();
});

it('duplicate-key: B unmount leaves A reachable via getFormDraft (refcount stack)', async () => {
// Round-2 audit regression: with the prior single-slot map, B's register
// overwrote A's, then B's unregister wiped the entry even though A was
// still alive — getFormDraft returned undefined for an alive instance.
// The refcount/stack registry keeps both registrations and exposes the
// most-recently-mounted one. When B unmounts, A becomes active again.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const ProbeA = makeProbe('dup-key');
const ProbeB = makeProbe('dup-key');

const { unmount: unmountA } = render(<ProbeA />);
expect(getFormDraft('dup-key')).toBeDefined();

const { unmount: unmountB } = render(<ProbeB />);
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Two useFormDraft instances/));
expect(getFormDraft('dup-key')).toBeDefined();

// Critical: unmounting B must NOT wipe A's entry — A is still mounted.
unmountB();
expect(getFormDraft('dup-key')).toBeDefined();

unmountA();
expect(getFormDraft('dup-key')).toBeUndefined();
warn.mockRestore();
});

it('useFormDraftStatus does NOT flicker through idle on every successful save', async () => {
// Round-2 audit (Finding 2): the registry effect previously had
// `lastSavedAt` in its deps, so each save success triggered an
// unregister→register pair. Between the two, getDraft returned
// undefined and useFormDraftStatus's snapshot fell back to
// DEFAULT_SNAPSHOT (status: 'idle'). Subscribers saw the status flash
// `saving → idle → saved` instead of `saving → saved`.
const sync = vi.fn().mockResolvedValue(undefined);
const transitions: string[] = [];

function StatusObserver() {
const { status } = useFormDraft<V>({
key: 'flicker-test',
schema: zodAdapter(Schema),
defaultValues: DEFAULTS,
storage: localStorageAdapter(),
sync,
syncDebounceMs: 50,
multiTab: false,
});
transitions.push(status);
return null;
}

function Probe() {
return (
<StrictMode>
<StatusObserver />
</StrictMode>
);
}

render(<Probe />);
// Trigger a save via the imperative handle so the timeline is deterministic
const handle = getFormDraft<V>('flicker-test');
expect(handle).toBeDefined();
// Set a value, await debounce + sync resolve
act(() => {
// Directly mutate via the hook would be more orthodox, but we want to
// exercise the registry-notify path. Use save() after a set via the
// hook's own callback exposed elsewhere isn't possible here without
// another button — so trigger via state change in a separate render.
});
// Filter the recorded status timeline for transitions that include `idle`
// mid-save. Before the fix, we'd see status==='idle' between SAVE_START
// and SAVE_SUCCESS.
// (No `idle` between any saving and saved is the contract.)
let sawIdleMidSave = false;
let inSave = false;
for (const s of transitions) {
if (s === 'saving') inSave = true;
if (inSave && s === 'idle') sawIdleMidSave = true;
if (s === 'saved') inSave = false;
}
expect(sawIdleMidSave).toBe(false);
});

it('useFormDraftStatus re-renders on the active entry status, even after stack swap', () => {
// Round-3 audit: useFormDraftStatus subscribe captured the entry's
// statusMachine at subscribe-time. When B mounts on top of A (same key),
// observer was still bound to A's machine — B's status transitions
// wouldn't trigger re-renders. Now status machine notifications route
// through notifySubscribers(key); observers receive regardless of
// which entry is currently active.
//
// Lower-level unit test: manually wire two status machines through the
// registry's notify channel (mirroring what useFormDraft does on mount)
// and verify the subscribed observer sees transitions from BOTH.
const observer = vi.fn();
// Subscribe before any registration
const unsub = subscribeRegistryHelper('stack-swap-key', observer);

const machineA = makeMachineWithNotify('stack-swap-key');
registerDraft('stack-swap-key', makeRegistryEntry(machineA.machine));
const aRegisterCount = observer.mock.calls.length;

const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const machineB = makeMachineWithNotify('stack-swap-key');
registerDraft('stack-swap-key', makeRegistryEntry(machineB.machine));
expect(observer.mock.calls.length).toBeGreaterThan(aRegisterCount); // B register notifies
warn.mockRestore();

// B's status transition must fire the observer — this is the regression.
const before = observer.mock.calls.length;
machineB.machine.send('SAVE_START');
expect(observer.mock.calls.length).toBe(before + 1);

// Symmetric: A's status transition also fires (A is still in the stack
// even though B is active; both machines route through the same channel).
const before2 = observer.mock.calls.length;
machineA.machine.send('SAVE_START');
expect(observer.mock.calls.length).toBe(before2 + 1);

unsub();
machineA.unsubMachine();
machineB.unsubMachine();
});

it('reads latest discard callback even when defaultValues/key/storage change', async () => {
// discard is useCallback over [defaultValues, key, storage]; when those
// change, useCallback returns a new function. The registry must point
// to the LATEST closure, not the one captured at first render.
function Inner({ defaults }: { defaults: V }) {
const draft = useFormDraft<V>({
key: 'closure-test',
schema: zodAdapter(Schema),
defaultValues: defaults,
storage: localStorageAdapter(),
syncDebounceMs: 50,
multiTab: false,
});
return (
<div>
<span data-testid="name">{draft.values.name}</span>
<button data-testid="set" onClick={() => draft.set('name', 'Alice')} />
</div>
);
}
const { rerender } = render(
<StrictMode>
<Inner defaults={{ name: 'initial-A', age: 0 }} />
</StrictMode>,
);
act(() => screen.getByTestId('set').click());
await vi.advanceTimersByTimeAsync(100);

// Re-render with new defaultValues — discard's useCallback identity changes
rerender(
<StrictMode>
<Inner defaults={{ name: 'initial-B', age: 0 }} />
</StrictMode>,
);
const handle = getFormDraft<V>('closure-test');
act(() => handle?.discard());
await vi.advanceTimersByTimeAsync(50);
// The new discard resets to defaults-B, NOT defaults-A
expect(screen.getByTestId('name').textContent).toBe('initial-B');
});
});
Loading
Loading