From 84fc00758387b92488917b612101f4d3be453ea0 Mon Sep 17 00:00:00 2001 From: mayrang Date: Thu, 28 May 2026 03:02:09 +0900 Subject: [PATCH] feat(#4): TanStack Form adapter via formdraft/tanstack-form subpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4. Mirrors the formdraft/rhf + formdraft/formik pattern for users on TanStack Form. const form = useForm({ defaultValues: { name: '', bio: '' }, onSubmit: async ({ value }) => { await api.submit(value); discard(); // clears storage + broadcasts + resets form }, }); const { status, lastSavedAt, discard } = useFormDraftTanstack(form, { key: 'profile-form', schema: zodAdapter(Schema), sync: api.saveProfile, }); 4 rounds of code review + adversarial audit dispatched in parallel each round. Real bugs caught and fixed before merge: R1 (LOW): type intersection `any & {...}` collapsed to `any` → explicit TanstackFormApi interface with the four methods we actually call. R1 (LOW): schema-drift via Object.keys(restored) → filtered against originalDefaultsRef so stored keys outside the current schema can't be forwarded to setFieldValue. R2 (MEDIUM, B1): when useForm omits defaultValues, originalDefaults was {} → validKeys empty → restore silently no-op'd. Fixed with a fallback: if defaults are empty, use Object.keys(restored) directly (already gated by useFormDraft's schema validation). R2 (LOW-MEDIUM, B5): setFieldValue's `dontUpdateMeta: true` only suppresses meta writes — validation still runs. A form with onChange validators would paint errors against restored text the user never typed on every page load. Added `dontValidate: true`. R3 (HIGH, D2): `` is idiomatic TanStack usage. With our prior `dontUpdateMeta: true`, the field stayed `isTouched: false` after restore → TanStack's FieldApi.update re-evaluates on every render and reseeds the value back to `opts.defaultValue` when `!isTouched`. Restored data was silently wiped on every render after restore. Fixed by dropping `dontUpdateMeta` entirely — restore now marks fields as touched, blocking the reseed. R4: stale docs from D2 fix in both README and JSDoc still claimed `dontUpdateMeta` was used. Updated to describe the actual behavior + reason `dontUpdateMeta` was deliberately omitted. 10 unit tests: - persist on input change - restore from storage on mount - discard clears storage AND resets visible form - delete-back-to-default still persists deletion - restore does NOT trigger redundant sync (no flicker on page load) - restore survives across re-renders with form.Field defaultValue (D2) - restore handles nested defaultValues (subtree replacement) - restore does NOT trigger field-level validators (B5) - restore even when useForm omits defaultValues (B1) - user-types-before-restore race (deferred storage) Wired in: - package.json: ./tanstack-form export + @tanstack/react-form optional peer - tsup: tanstack-form/index entry → dist/tanstack-form/{index.mjs,.js,.d.ts} - README adds TanStack Form integration section with submit-pattern example Total 115 unit tests pass. Bundle 4.09 KB brotli (TanStack adapter in its own chunk; main entry unchanged). --- README.md | 32 +- package-lock.json | 104 +++++ package.json | 10 + .../__tests__/useFormDraftTanstack.test.tsx | 387 ++++++++++++++++++ src/tanstack-form/index.ts | 1 + src/tanstack-form/useFormDraftTanstack.ts | 164 ++++++++ tsup.config.ts | 3 +- 7 files changed, 690 insertions(+), 11 deletions(-) create mode 100644 src/tanstack-form/__tests__/useFormDraftTanstack.test.tsx create mode 100644 src/tanstack-form/index.ts create mode 100644 src/tanstack-form/useFormDraftTanstack.ts 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, });