diff --git a/README.md b/README.md index c92a871..2ace6e3 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,10 @@ import { useFormDraftFormik } from 'formdraft/formik'; const formik = useFormik({ initialValues: { name: '', bio: '' }, - onSubmit: async (v) => api.submitProfile(v), + onSubmit: async (values) => { + await api.submitProfile(values); + discard(); // ← clears storage + broadcasts to other tabs + resets formik + }, }); const { status, lastSavedAt, discard } = useFormDraftFormik(formik, { @@ -89,21 +92,30 @@ const { status, lastSavedAt, discard } = useFormDraftFormik(formik, { Restore happens once on mount when storage has a valid draft AND the user hasn't started typing (gated on `formik.dirty`); after that, formik is the source of truth and every value change is persisted automatically. -**On successful submit, call `discard()`** to clear the stored draft and broadcast to other tabs: +**On successful submit, call `discard()`** to clear the stored draft and broadcast to other tabs. Without this, the draft survives in storage and reappears on next mount even though the user has already submitted it. (RHF users have the same responsibility — formdraft never assumes submit "happened" until the host form library tells us.) -```tsx -const { discard } = useFormDraftFormik(formik, options); +## TanStack Form integration -const formik = useFormik({ - initialValues: { name: '', bio: '' }, - onSubmit: async (values) => { - await api.submitProfile(values); - discard(); // ← clears storage + broadcasts to other tabs + resets formik +```tsx +import { useForm } from '@tanstack/react-form'; +import { useFormDraftTanstack } from 'formdraft/tanstack-form'; + +const form = useForm({ + defaultValues: { name: '', bio: '' }, + onSubmit: async ({ value }) => { + await api.submitProfile(value); + discard(); // ← clears storage + broadcasts + resets form }, }); + +const { status, lastSavedAt, discard } = useFormDraftTanstack(form, { + key: 'profile-form', + schema: zodAdapter(Schema), + sync: api.saveProfile, +}); ``` -Without this, the draft survives in storage and reappears on next mount even though the user has already submitted it. (RHF users have the same responsibility — formdraft never assumes submit "happened" until the host form library tells us.) +Restore happens once on mount when storage has a valid draft AND the user hasn't already started typing. Restore calls `setFieldValue` per top-level key with `{ dontValidate: true }` so onChange validators don't paint errors against text the user never typed. (We deliberately do *not* pass `dontUpdateMeta`: TanStack's `FieldApi.update` reseeds any field whose `isTouched` is still `false` back to its `defaultValue` prop on the next render, which would silently wipe restored data on the idiomatic ``.) As with the Formik adapter, **call `discard()` in your `onSubmit`** after a successful API submit so the just-submitted draft doesn't reappear on next page load. ## What it handles diff --git a/package-lock.json b/package-lock.json index b9a5c39..3a67449 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@playwright/test": "^1.60.0", "@size-limit/preset-small-lib": "^11.0.0", + "@tanstack/react-form": "^1.32.1", "@testing-library/react": "^16.0.0", "@types/node": "^20.19.41", "@types/react": "^18.3.0", @@ -1444,6 +1445,100 @@ "size-limit": "11.2.0" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "dev": true, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.32.1.tgz", + "integrity": "sha512-5yTCJ1/0bBjdVDsZsqPpLMVZLLN/G39b+ONnwv4vjz2jDes4YAd63cVwti5RtWuGuS1yLc5tVrGl1rWyVYsNGw==", + "dev": true, + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-form": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.32.1.tgz", + "integrity": "sha512-GQ6IdIFnAJvhVaBZJIyVzi14c8b02W4SopJFzFZCjYFIAb5CfTZCbvHZ4Cnd5byd0OzTwLLW/R95noRKD+2+ZA==", + "dev": true, + "dependencies": { + "@tanstack/form-core": "1.32.1", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "dev": true, + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -5258,6 +5353,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index 3b990ef..741306c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "import": "./dist/formik/index.mjs", "require": "./dist/formik/index.js" }, + "./tanstack-form": { + "types": "./dist/tanstack-form/index.d.ts", + "import": "./dist/tanstack-form/index.mjs", + "require": "./dist/tanstack-form/index.js" + }, "./storage/indexedDB": { "types": "./dist/storage/indexedDB.d.ts", "import": "./dist/storage/indexedDB.mjs", @@ -52,6 +57,7 @@ "react": ">=18", "react-hook-form": ">=7.0.0", "formik": ">=2.4.0", + "@tanstack/react-form": ">=1.0.0", "zod": ">=3.0.0" }, "peerDependenciesMeta": { @@ -61,6 +67,9 @@ "formik": { "optional": true }, + "@tanstack/react-form": { + "optional": true + }, "zod": { "optional": true } @@ -68,6 +77,7 @@ "devDependencies": { "@playwright/test": "^1.60.0", "@size-limit/preset-small-lib": "^11.0.0", + "@tanstack/react-form": "^1.32.1", "@testing-library/react": "^16.0.0", "@types/node": "^20.19.41", "@types/react": "^18.3.0", diff --git a/src/tanstack-form/__tests__/useFormDraftTanstack.test.tsx b/src/tanstack-form/__tests__/useFormDraftTanstack.test.tsx new file mode 100644 index 0000000..6c07561 --- /dev/null +++ b/src/tanstack-form/__tests__/useFormDraftTanstack.test.tsx @@ -0,0 +1,387 @@ +import { StrictMode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useForm } from '@tanstack/react-form'; +import { z } from 'zod'; +import type { StorageAdapter } from '../../types'; +import { useFormDraftTanstack } from '../useFormDraftTanstack'; +import { zodAdapter } from '../../internal/schemaValidation'; +import { localStorageAdapter } from '../../storage/localStorage'; +import { _clearRegistryForTests } from '../../internal/registry'; + +const Schema = z.object({ name: z.string() }); + +function Inner({ onSync }: { onSync: ReturnType }) { + const form = useForm({ + defaultValues: { name: '' as string }, + onSubmit: () => {}, + }); + const { status, discard } = useFormDraftTanstack(form, { + key: 'tanstack-test', + schema: zodAdapter(Schema), + storage: localStorageAdapter(), + sync: onSync, + syncDebounceMs: 50, + multiTab: false, + }); + return ( +
+ + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + {status} + +
+ ); +} + +function Probe(props: { onSync: ReturnType }) { + return ( + + + + ); +} + +describe('useFormDraftTanstack', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + localStorage.clear(); + _clearRegistryForTests(); + }); + afterEach(() => vi.useRealTimers()); + + it('persists TanStack form values on input change', async () => { + const onSync = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByTestId('name'), { target: { value: 'Alice' } }); + await vi.advanceTimersByTimeAsync(200); + const stored = JSON.parse(localStorage.getItem('formdraft:tanstack-test')!); + expect(stored.values).toMatchObject({ name: 'Alice' }); + }); + + it('restores into TanStack form on mount when storage has a valid draft', async () => { + localStorage.setItem( + 'formdraft:tanstack-test', + JSON.stringify({ __v: 1, values: { name: 'Restored' } }), + ); + const onSync = vi.fn().mockResolvedValue(undefined); + render(); + await waitFor(() => { + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('Restored'); + }); + }); + + it('discard clears storage AND resets the visible form', async () => { + localStorage.setItem( + 'formdraft:tanstack-test', + JSON.stringify({ __v: 1, values: { name: 'WillBeCleared' } }), + ); + const onSync = vi.fn().mockResolvedValue(undefined); + render(); + await waitFor(() => { + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('WillBeCleared'); + }); + fireEvent.click(screen.getByTestId('discard')); + await vi.advanceTimersByTimeAsync(100); + expect(localStorage.getItem('formdraft:tanstack-test')).toBeNull(); + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe(''); + }); + + it('deleting input back to default still persists the deletion', async () => { + // F2-equivalent: typing then clearing back to defaults must persist + // the cleared state, not let stale storage survive. + const onSync = vi.fn().mockResolvedValue(undefined); + render(); + const input = screen.getByTestId('name') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'Alice' } }); + await vi.advanceTimersByTimeAsync(200); + expect(JSON.parse(localStorage.getItem('formdraft:tanstack-test')!).values.name).toBe('Alice'); + fireEvent.change(input, { target: { value: '' } }); + await vi.advanceTimersByTimeAsync(200); + expect(JSON.parse(localStorage.getItem('formdraft:tanstack-test')!).values.name).toBe(''); + }); + + it('restore does NOT trigger redundant sync (no flicker on page load)', async () => { + // F1-equivalent: restore must not push values through the persist + // pipeline, otherwise every page load triggers an unnecessary sync. + localStorage.setItem( + 'formdraft:tanstack-test', + JSON.stringify({ __v: 1, values: { name: 'Stored' } }), + ); + const onSync = vi.fn().mockResolvedValue(undefined); + render(); + await waitFor(() => { + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('Stored'); + }); + await vi.advanceTimersByTimeAsync(2000); + expect(onSync).not.toHaveBeenCalled(); + expect(screen.getByTestId('status').textContent).toBe('idle'); + }); + + it('restore survives across re-renders when form.Field has defaultValue prop (D2 regression)', async () => { + // Round-3 audit (D2): TanStack's FieldApi.update runs on every render + // and reseeds the field to `opts.defaultValue` when `!isTouched`. Our + // initial implementation passed `dontUpdateMeta: true` to setFieldValue, + // which kept isTouched=false → reseed condition matched → restored + // values silently wiped on next render. Fix: drop dontUpdateMeta so + // setFieldValue marks isTouched=true and the reseed condition fails. + localStorage.setItem( + 'formdraft:reseed-test', + JSON.stringify({ __v: 1, values: { name: 'Restored' } }), + ); + + function ReseedInner() { + const form = useForm({ + defaultValues: { name: '' as string }, + onSubmit: () => {}, + }); + useFormDraftTanstack(form, { + key: 'reseed-test', + schema: zodAdapter(Schema), + storage: localStorageAdapter(), + syncDebounceMs: 50, + multiTab: false, + }); + return ( + // Idiomatic TanStack usage — defaultValue prop on form.Field. + // Before the D2 fix, this would silently reseed restored 'Restored' + // back to '' on every render after restore. + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + ); + } + + render( + + + , + ); + await waitFor(() => { + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('Restored'); + }); + // Force additional re-renders and verify restore still holds + await vi.advanceTimersByTimeAsync(500); + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('Restored'); + }); + + it('restore handles nested defaultValues (replaces top-level subtree)', async () => { + // Pin behavior for non-flat defaults: setFieldValue replaces the entire + // subtree at the top-level key, so nested objects are restored atomically. + const NestedSchema = z.object({ + user: z.object({ name: z.string(), email: z.string() }), + }); + localStorage.setItem( + 'formdraft:nested-test', + JSON.stringify({ + __v: 1, + values: { user: { name: 'Stored Name', email: 'stored@x.com' } }, + }), + ); + + function NestedInner() { + const form = useForm({ + defaultValues: { user: { name: '', email: '' } }, + onSubmit: () => {}, + }); + useFormDraftTanstack(form, { + key: 'nested-test', + schema: zodAdapter(NestedSchema), + storage: localStorageAdapter(), + syncDebounceMs: 50, + multiTab: false, + }); + return ( + <> + + {(field) => field.handleChange(e.target.value)} />} + + + {(field) => field.handleChange(e.target.value)} />} + + + ); + } + + render( + + + , + ); + await waitFor(() => { + expect((screen.getByTestId('u-name') as HTMLInputElement).value).toBe('Stored Name'); + }); + expect((screen.getByTestId('u-email') as HTMLInputElement).value).toBe('stored@x.com'); + }); + + it('restore does NOT trigger field-level validators (no errors on un-typed data)', async () => { + // Round-2 audit (B5): setFieldValue's dontUpdateMeta only suppresses + // meta writes; validation still runs unless dontValidate is also true. + // A form with onChange validators would otherwise paint errors against + // restored text the user never typed. + localStorage.setItem( + 'formdraft:validate-test', + JSON.stringify({ __v: 1, values: { name: 'Hi' } }), + ); + + let capturedErrors: unknown[] = []; + function ValidInner() { + const form = useForm({ + defaultValues: { name: '' as string }, + onSubmit: () => {}, + }); + useFormDraftTanstack(form, { + key: 'validate-test', + schema: zodAdapter(z.object({ name: z.string() })), + storage: localStorageAdapter(), + syncDebounceMs: 50, + multiTab: false, + }); + return ( + + value.length < 5 ? 'too short' : undefined, + }} + > + {(field) => { + capturedErrors = field.state.meta.errors; + return ( + field.handleChange(e.target.value)} + /> + ); + }} + + ); + } + + render( + + + , + ); + await waitFor(() => { + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('Hi'); + }); + // No validation error painted — user hasn't typed anything yet + expect(capturedErrors).toEqual([]); + }); + + it('restores even when useForm omits defaultValues (empty-defaults fallback)', async () => { + // Round-2 audit (B1): when defaults is `{}` (user didn't pass any), + // validKeys was empty and restore became a silent no-op. Fall back to + // the restored keys in that case so the restore still works. + localStorage.setItem( + 'formdraft:no-defaults', + JSON.stringify({ __v: 1, values: { name: 'FromStorage' } }), + ); + + function NoDefaultsInner() { + // Intentionally no defaultValues passed to useForm + const form = useForm({ + defaultValues: {} as { name?: string }, + onSubmit: () => {}, + }); + useFormDraftTanstack(form, { + key: 'no-defaults', + schema: zodAdapter(z.object({ name: z.string() })), + storage: localStorageAdapter(), + syncDebounceMs: 50, + multiTab: false, + }); + return ( + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + ); + } + + render( + + + , + ); + await waitFor(() => { + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('FromStorage'); + }); + }); + + it('user-types-before-restore race: user input wins (deferred storage)', async () => { + let releaseRead: (raw: string | null) => void; + const readGate = new Promise((r) => { releaseRead = r; }); + const slowAdapter: StorageAdapter = { + name: 'slow', + async read() { + const raw = await readGate; + return raw === null ? null : JSON.parse(raw); + }, + async write() {}, + async remove() {}, + }; + + function SlowInner() { + const form = useForm({ + defaultValues: { name: '' as string }, + onSubmit: () => {}, + }); + useFormDraftTanstack(form, { + key: 'slow-tanstack', + schema: zodAdapter(Schema), + storage: slowAdapter, + syncDebounceMs: 50, + multiTab: false, + }); + return ( + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + ); + } + + render( + + + , + ); + + const input = screen.getByTestId('name') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'User-typed' } }); + expect(input.value).toBe('User-typed'); + + releaseRead!(JSON.stringify({ __v: 1, values: { name: 'Stored' } })); + await vi.advanceTimersByTimeAsync(200); + expect(input.value).toBe('User-typed'); + }); +}); diff --git a/src/tanstack-form/index.ts b/src/tanstack-form/index.ts new file mode 100644 index 0000000..5cb1bcd --- /dev/null +++ b/src/tanstack-form/index.ts @@ -0,0 +1 @@ +export { useFormDraftTanstack } from './useFormDraftTanstack'; diff --git a/src/tanstack-form/useFormDraftTanstack.ts b/src/tanstack-form/useFormDraftTanstack.ts new file mode 100644 index 0000000..0e89acb --- /dev/null +++ b/src/tanstack-form/useFormDraftTanstack.ts @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useStore } from '@tanstack/react-form'; +import type { FormDraftOptions } from '../types'; +import { useFormDraft } from '../useFormDraft'; + +/** + * TanStack Form adapter — wraps a `useForm(...)` instance with formdraft's + * persistence, sync queue, and multi-tab coordination. Mirrors the + * `useFormDraftRHF` / `useFormDraftFormik` shape. + * + * const form = useForm({ defaultValues: { name: '' }, onSubmit }); + * const { status, lastSavedAt, discard } = useFormDraftTanstack(form, { + * key: 'profile-form', + * schema: zodAdapter(Schema), + * sync: api.saveProfile, + * }); + * + * Restore happens once on mount when storage has a valid draft AND the user + * hasn't already started typing. Restore writes use `dontValidate: true` (to + * skip onChange validators against text the user never typed) but DO update + * field meta — see the long comment on the restore effect for why + * `dontUpdateMeta` would silently wipe restored data on the next render. + */ +// We intentionally accept the form via a loose interface here — +// `ReactFormExtendedApi`'s 12 generic parameters are not worth surfacing +// through this adapter. Internal type-safety is recovered by `T`. +type TanstackUpdateMetaOptions = { dontUpdateMeta?: boolean; dontValidate?: boolean }; +type TanstackFormApi = { + options: { defaultValues?: T }; + store: unknown; + setFieldValue: (field: string, value: unknown, opts?: TanstackUpdateMetaOptions) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reset: (values?: T, opts?: any) => void; +}; + +export function useFormDraftTanstack>( + form: TanstackFormApi, + options: Omit, 'defaultValues'>, +): { + status: ReturnType>['status']; + lastSavedAt: ReturnType>['lastSavedAt']; + pendingChanges: ReturnType>['pendingChanges']; + error: ReturnType>['error']; + save: ReturnType>['save']; + discard: ReturnType>['discard']; + onConflictData: ReturnType>['onConflictData']; + resolveConflict: ReturnType>['resolveConflict']; +} { + const defaultValues = (form.options.defaultValues ?? ({} as T)) as T; + const draft = useFormDraft({ + ...options, + defaultValues, + }); + + // TanStack Form holds state in a TanStack Store. useStore subscribes to a + // selector slice and re-renders the consuming component when it changes. + // Reference is stable until the underlying slice changes. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const formValues = useStore(form.store as any, (s: any) => s.values) as T; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isDirty = useStore(form.store as any, (s: any) => s.isDirty) as boolean; + + // Sticky "user has touched the form" flag — sticky for the same reason + // as the Formik adapter: form.isDirty flips false again when the user + // deletes their input back to defaults, but the deletion still needs to + // be persisted (otherwise stale stored draft survives forever). + const userTouchedRef = useRef(false); + + // Set by the restore effect right before pushing values into the form. + // The value-watcher effect runs next, sees this flag, skips its patch + // (the restore is not user input), and resets the flag. + const ignoreNextRef = useRef(false); + + const hasRestoredRef = useRef(false); + const initialDraftValuesRef = useRef(draft.values); + const originalDefaultsRef = useRef(defaultValues); + const formRef = useRef(form); + formRef.current = form; + + // Watch form values + isDirty; patch the draft for persistence. + useEffect(() => { + if (ignoreNextRef.current) { + ignoreNextRef.current = false; + return; + } + if (isDirty) userTouchedRef.current = true; + if (!userTouchedRef.current) return; + draft.patch(formValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formValues, isDirty]); + + // When draft.values changes from its initial reference, storage has been + // asynchronously restored. Push the stored values into the form exactly + // once, but only if the user hasn't already started typing. + useEffect(() => { + if (hasRestoredRef.current) return; + if (draft.values === initialDraftValuesRef.current) return; + if (isDirty) { + // User typed before restore landed — useFormDraft's own + // userTouchedRef prevents the stored data from reaching draft.values + // in this case, so we never need to restore. Latch hasRestoredRef so + // subsequent draft.patch updates don't re-enter this branch. + hasRestoredRef.current = true; + return; + } + hasRestoredRef.current = true; + ignoreNextRef.current = true; + // Push each top-level key via setFieldValue. `dontValidate: true` skips + // field-level onChange validators (we don't want errors painted on the + // restored value just for being non-default at mount). + // + // We INTENTIONALLY do NOT pass `dontUpdateMeta`. With dontUpdateMeta, + // per-field `isTouched` stays false → on the next render, TanStack's + // FieldApi.update overwrites the value back to `opts.defaultValue` + // (the field's own defaultValue prop) because the reseed condition + // `!isTouched && opts.defaultValue !== undefined` matches. The + // common idiomatic `` would then + // silently wipe the restored data on every render. + // setFieldValue replaces the entire subtree at that key, so nested + // objects are handled by passing the whole sub-object as the value. + // + // Filter against originalDefaults so a stored draft from a previous + // schema version with extra keys can't be forwarded to setFieldValue. + // Exception: when defaults are an empty object (user omitted + // defaultValues from useForm), fall back to the restored keys. + const restored = draft.values as Record; + const defaultKeys = Object.keys(originalDefaultsRef.current as object); + const validKeys = + defaultKeys.length > 0 ? new Set(defaultKeys) : new Set(Object.keys(restored)); + Object.keys(restored).forEach((key) => { + if (!validKeys.has(key)) return; + formRef.current.setFieldValue(key, restored[key], { + dontValidate: true, + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draft.values]); + + // Wrap discard so the visible form also clears AND so per-field meta + // (dirty/touched/errors) resets. We pass the originalDefaults captured + // at mount via the ref so that: + // - a future `form.update({ defaultValues: ... })` call by the user + // doesn't make discard revert to the new defaults instead of the + // original ones the user expected. + // - restore (which uses setFieldValue, NOT form.reset) doesn't change + // options.defaultValues, but we'd still be insulated if it ever did. + const discard = useCallback(() => { + draft.discard(); + formRef.current.reset(originalDefaultsRef.current); + userTouchedRef.current = false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draft.discard]); + + return { + status: draft.status, + lastSavedAt: draft.lastSavedAt, + pendingChanges: draft.pendingChanges, + error: draft.error, + save: draft.save, + discard, + onConflictData: draft.onConflictData, + resolveConflict: draft.resolveConflict, + }; +} diff --git a/tsup.config.ts b/tsup.config.ts index 9b5b94d..5b1b621 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ index: 'src/index.ts', 'rhf/index': 'src/rhf/index.ts', 'formik/index': 'src/formik/index.ts', + 'tanstack-form/index': 'src/tanstack-form/index.ts', 'storage/indexedDB': 'src/storage/indexedDB.ts', 'storage/sessionStorage': 'src/storage/sessionStorage.ts', }, @@ -12,7 +13,7 @@ export default defineConfig({ dts: true, sourcemap: true, clean: true, - external: ['react', 'react-dom', 'react-hook-form', 'formik', 'zod'], + external: ['react', 'react-dom', 'react-hook-form', 'formik', '@tanstack/react-form', 'zod'], target: 'es2020', splitting: false, });