diff --git a/README.md b/README.md index ef8276e..08d3a05 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,42 @@ const { status } = useFormDraftRHF(form, { key: 'rhf-profile', schema: zodAdapte return
{/* form.register, etc. */}{status}
; ``` +## Formik integration + +```tsx +import { useFormik } from 'formik'; +import { useFormDraftFormik } from 'formdraft/formik'; + +const formik = useFormik({ + initialValues: { name: '', bio: '' }, + onSubmit: async (v) => api.submitProfile(v), +}); + +const { status, lastSavedAt, discard } = useFormDraftFormik(formik, { + 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 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: + +```tsx +const { discard } = useFormDraftFormik(formik, options); + +const formik = useFormik({ + initialValues: { name: '', bio: '' }, + onSubmit: async (values) => { + await api.submitProfile(values); + discard(); // ← clears storage + broadcasts to other tabs + resets formik + }, +}); +``` + +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.) + ## What it handles | Production form pain | formdraft | diff --git a/package-lock.json b/package-lock.json index cf92b7a..b9a5c39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^7.1.1", "fake-indexeddb": "^6.0.0", + "formik": "^2.4.9", "jsdom": "^25.0.0", "playwright": "^1.60.0", "react": "^18.3.0", @@ -1503,6 +1504,18 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "dev": true, + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", @@ -2312,6 +2325,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2860,6 +2882,31 @@ "node": ">= 6" } }, + "node_modules/formik": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", + "integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3106,6 +3153,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3440,6 +3502,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4034,6 +4108,12 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", + "dev": true + }, "node_modules/react-hook-form": { "version": "7.76.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz", @@ -4395,6 +4475,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "dev": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4560,6 +4646,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", diff --git a/package.json b/package.json index d32b705..3b990ef 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,11 @@ "import": "./dist/rhf/index.mjs", "require": "./dist/rhf/index.js" }, + "./formik": { + "types": "./dist/formik/index.d.ts", + "import": "./dist/formik/index.mjs", + "require": "./dist/formik/index.js" + }, "./storage/indexedDB": { "types": "./dist/storage/indexedDB.d.ts", "import": "./dist/storage/indexedDB.mjs", @@ -46,12 +51,16 @@ "peerDependencies": { "react": ">=18", "react-hook-form": ">=7.0.0", + "formik": ">=2.4.0", "zod": ">=3.0.0" }, "peerDependenciesMeta": { "react-hook-form": { "optional": true }, + "formik": { + "optional": true + }, "zod": { "optional": true } @@ -67,6 +76,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^7.1.1", "fake-indexeddb": "^6.0.0", + "formik": "^2.4.9", "jsdom": "^25.0.0", "playwright": "^1.60.0", "react": "^18.3.0", diff --git a/src/formik/__tests__/useFormDraftFormik.test.tsx b/src/formik/__tests__/useFormDraftFormik.test.tsx new file mode 100644 index 0000000..01f4e67 --- /dev/null +++ b/src/formik/__tests__/useFormDraftFormik.test.tsx @@ -0,0 +1,373 @@ +import { StrictMode } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFormik } from 'formik'; +import { z } from 'zod'; +import type { StorageAdapter } from '../../types'; +import { useFormDraftFormik } from '../useFormDraftFormik'; +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 formik = useFormik({ + initialValues: { name: '' }, + onSubmit: () => {}, + }); + const { status, discard } = useFormDraftFormik(formik, { + key: 'formik-test', + schema: zodAdapter(Schema), + storage: localStorageAdapter(), + sync: onSync, + syncDebounceMs: 50, + multiTab: false, + }); + return ( +
+ + {status} + +
+ ); +} + +function Probe(props: { onSync: ReturnType }) { + return ( + + + + ); +} + +describe('useFormDraftFormik', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + localStorage.clear(); + _clearRegistryForTests(); + }); + afterEach(() => vi.useRealTimers()); + + it('persists Formik values on input change', async () => { + const onSync = vi.fn().mockResolvedValue(undefined); + render(); + const input = screen.getByTestId('name') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'Alice' } }); + await vi.advanceTimersByTimeAsync(200); + const stored = JSON.parse(localStorage.getItem('formdraft:formik-test')!); + expect(stored.values).toMatchObject({ name: 'Alice' }); + }); + + it('restores into Formik on mount when storage has a valid draft', async () => { + localStorage.setItem( + 'formdraft:formik-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('does NOT clobber user input that arrived before restore lands', async () => { + // Race the storage read with a user keystroke. The user wins — the + // adapter must skip the restore when form.dirty is true. + localStorage.setItem( + 'formdraft:formik-test', + JSON.stringify({ __v: 1, values: { name: 'Stored' } }), + ); + const onSync = vi.fn().mockResolvedValue(undefined); + render(); + // Type before the async restore can land + const input = screen.getByTestId('name') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'User-typed' } }); + await vi.advanceTimersByTimeAsync(200); + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe('User-typed'); + }); + + it('discard clears storage AND resets the visible Formik form', async () => { + // Round-1 audit (F3): discard used to clear storage but leave the + // visible input untouched, so the next keystroke would re-persist the + // stale text — effectively undoing the discard. + localStorage.setItem( + 'formdraft:formik-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'); + }); + act(() => screen.getByTestId('discard').click()); + await vi.advanceTimersByTimeAsync(100); + expect(localStorage.getItem('formdraft:formik-test')).toBeNull(); + expect((screen.getByTestId('name') as HTMLInputElement).value).toBe(''); + }); + + it('restore does NOT trigger Formik validation (no spurious errors on un-typed draft)', async () => { + // Round-2 audit (F1): restore used to setValues(value) which defaults + // to shouldValidate=true, painting errors against text the user never + // typed. Now setValues(value, false). + const StrictSchema = z.object({ + name: z.string().min(5, 'too short'), // restored value 'Hi' fails this + }); + localStorage.setItem( + 'formdraft:formik-validation', + JSON.stringify({ __v: 1, values: { name: 'Hi' } }), + ); + + function ValidInner() { + const formik = useFormik({ + initialValues: { name: '' }, + validate: (vals) => { + const r = StrictSchema.safeParse(vals); + return r.success ? {} : { name: r.error.issues[0]?.message }; + }, + validateOnChange: true, + validateOnMount: false, + onSubmit: () => {}, + }); + useFormDraftFormik(formik, { + key: 'formik-validation', + schema: zodAdapter(z.object({ name: z.string() })), + storage: localStorageAdapter(), + syncDebounceMs: 50, + multiTab: false, + }); + return ( +
+ + {formik.errors.name ?? ''} +
+ ); + } + + render( + + + , + ); + await waitFor(() => { + expect((screen.getByTestId('vname') as HTMLInputElement).value).toBe('Hi'); + }); + // No validation error painted — user hasn't typed anything yet + expect(screen.getByTestId('verror').textContent).toBe(''); + }); + + it('submit pattern: calling discard in onSubmit clears storage + broadcasts', async () => { + // Documents and pins the recommended Formik integration pattern: in + // formik's onSubmit, after the user's API call succeeds, call discard. + let capturedDiscard: (() => void) | null = null; + + function SubmitInner() { + const formik = useFormik({ + initialValues: { name: '' }, + onSubmit: async () => { + // Pretend API succeeded + capturedDiscard?.(); + }, + }); + const { discard } = useFormDraftFormik(formik, { + key: 'formik-submit', + schema: zodAdapter(Schema), + storage: localStorageAdapter(), + syncDebounceMs: 50, + multiTab: false, + }); + capturedDiscard = discard; + return ( +
+ + +
+ ); + } + + render( + + + , + ); + + // Type and let it persist + fireEvent.change(screen.getByTestId('sname'), { target: { value: 'Alice' } }); + await vi.advanceTimersByTimeAsync(200); + expect(localStorage.getItem('formdraft:formik-submit')).not.toBeNull(); + + // Submit + fireEvent.submit(screen.getByTestId('form')); + await vi.advanceTimersByTimeAsync(200); + + // After successful submit + discard, storage is cleared + expect(localStorage.getItem('formdraft:formik-submit')).toBeNull(); + expect((screen.getByTestId('sname') as HTMLInputElement).value).toBe(''); + }); + + it('deleting input back to initial value still persists the deletion', async () => { + // Round-3 audit (F2): with the old `if (!form.dirty) return` gate, + // typing then deleting back to initialValues left stored data alive + // (dirty flips false → patch skipped). Next mount restored the deleted + // text. Now we use a sticky userTouchedRef. + 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:formik-test')!).values.name).toBe('Alice'); + // Delete back to empty (the initialValue) + fireEvent.change(input, { target: { value: '' } }); + await vi.advanceTimersByTimeAsync(200); + expect(JSON.parse(localStorage.getItem('formdraft:formik-test')!).values.name).toBe(''); + }); + + it('restore does NOT flip pendingChanges or trigger a redundant sync', async () => { + // Round-3 audit (F1): setValues(value, false) leaves dirty=true after + // restore, which would unblock the persist effect → patch redundantly + // → sync round-trip on every page load. Now the value-watcher effect + // checks ignoreNextFormChangeRef and skips the post-restore patch. + localStorage.setItem( + 'formdraft:formik-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'); + }); + // Give the persist + sync debounces plenty of time to fire if they were going to + await vi.advanceTimersByTimeAsync(2000); + // No sync was fired — restore alone shouldn't round-trip the data + expect(onSync).not.toHaveBeenCalled(); + expect(screen.getByTestId('status').textContent).toBe('idle'); + }); + + it('type → clear → late-restore: does NOT resurrect cleared user input', async () => { + // Round-4 audit (D5 trace): once the user typed and useFormDraft's own + // userTouchedRef latched, the stored data is gated from ever reaching + // draft.values. The adapter's restore-effect then only ever sees the + // user's cleared state, never the stored snapshot. + 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 formik = useFormik({ + initialValues: { name: '' }, + onSubmit: () => {}, + }); + useFormDraftFormik(formik, { + key: 'slow-type-clear', + schema: zodAdapter(Schema), + storage: slowAdapter, + syncDebounceMs: 50, + multiTab: false, + }); + return ( + + ); + } + + render( + + + , + ); + + const input = screen.getByTestId('name') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'TypedA' } }); + expect(input.value).toBe('TypedA'); + // User clears the input back to empty + fireEvent.change(input, { target: { value: '' } }); + expect(input.value).toBe(''); + // Now let the stored draft land + releaseRead!(JSON.stringify({ __v: 1, values: { name: 'StoredShouldNotShow' } })); + await vi.advanceTimersByTimeAsync(200); + // The user's clear must stick + expect(input.value).toBe(''); + }); + + it('user-types-before-restore race: skips restore via form.dirty (deferred storage)', async () => { + // Round-1 audit (F4): the previous race test was timing-dependent on + // localStorage being effectively-synchronous. Use a deferred storage + // adapter so the race is explicit and platform-independent. + 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({ onSync }: { onSync: ReturnType }) { + const formik = useFormik({ + initialValues: { name: '' }, + onSubmit: () => {}, + }); + useFormDraftFormik(formik, { + key: 'slow-formik', + schema: zodAdapter(Schema), + storage: slowAdapter, + sync: onSync, + syncDebounceMs: 50, + multiTab: false, + }); + return ( + + ); + } + + render( + + + , + ); + + // Type BEFORE the deferred read resolves + const input = screen.getByTestId('name') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'User-typed' } }); + expect(input.value).toBe('User-typed'); + + // Now let restore land + releaseRead!(JSON.stringify({ __v: 1, values: { name: 'Stored' } })); + await vi.advanceTimersByTimeAsync(200); + + // User input wins; restore was correctly skipped + expect(input.value).toBe('User-typed'); + }); +}); diff --git a/src/formik/index.ts b/src/formik/index.ts new file mode 100644 index 0000000..0c05883 --- /dev/null +++ b/src/formik/index.ts @@ -0,0 +1 @@ +export { useFormDraftFormik } from './useFormDraftFormik'; diff --git a/src/formik/useFormDraftFormik.ts b/src/formik/useFormDraftFormik.ts new file mode 100644 index 0000000..a56b713 --- /dev/null +++ b/src/formik/useFormDraftFormik.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { FormikProps, FormikValues } from 'formik'; +import type { FormDraftOptions } from '../types'; +import { useFormDraft } from '../useFormDraft'; + +/** + * Formik adapter — wraps a `useFormik(...)` instance with formdraft's + * persistence, sync queue, and multi-tab coordination. Mirrors the + * `useFormDraftRHF` shape so the docs and ergonomics are consistent. + * + * const formik = useFormik({ initialValues: { name: '' }, onSubmit }); + * const { status, lastSavedAt, discard } = useFormDraftFormik(formik, { + * 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 (`formik.dirty`). After that, formik is the + * source of truth and every `formik.values` change patches the draft for + * persistence. + */ +export function useFormDraftFormik( + form: FormikProps, + 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.initialValues; + const draft = useFormDraft({ + ...options, + defaultValues, + }); + + // Tracks the one-time "storage restore landed and we pushed it into formik" + // transition. After it flips true, draft.values is downstream of formik. + const hasRestoredRef = useRef(false); + + // Stable snapshot of the draft.values reference at construction time. Used + // to detect the async storage-restore swap (any non-initial reference = + // restore happened). + const initialDraftValuesRef = useRef(draft.values); + + // Sticky "user has touched the form at least once" flag. We can't use + // `form.dirty` directly as the gate — dirty flips BACK to false when the + // user deletes their input down to initialValues, and that deletion must + // still be persisted (otherwise stale stored draft survives forever). + // We can't use a first-run ref either — StrictMode's double-effect cycle + // makes its second invocation patch initialValues before async restore + // lands, racing the restore into a no-op. + // Instead: flip touched=true the first time we observe form.dirty=true, + // then patch on EVERY subsequent form.values change. Reset to false on + // discard so the post-discard form.resetForm() event doesn't re-patch. + const userTouchedRef = useRef(false); + + // Set by the restore effect right before it calls form.setValues. The + // value-change-watcher effect runs next, sees this flag, skips its patch + // (the restore is not user input), and resets the flag. + const ignoreNextFormChangeRef = useRef(false); + + // Watch formik.values for user changes and patch the draft for persistence. + // (Formik doesn't have a subscriber API like RHF's `watch()` — we observe + // values via the effect dep instead.) + useEffect(() => { + if (ignoreNextFormChangeRef.current) { + // form.values changed because OUR restore called setValues. Skip — the + // draft already has these values; patching would round-trip a sync. + ignoreNextFormChangeRef.current = false; + return; + } + if (form.dirty) userTouchedRef.current = true; + if (!userTouchedRef.current) return; + draft.patch(form.values); + // form.values + form.dirty are the deps; draft.patch is stable + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.values, form.dirty]); + + // When draft.values changes from its initial reference, storage has been + // asynchronously restored. Push the stored values into formik exactly once, + // BUT only if the user hasn't already started typing — otherwise we'd + // clobber their input. After that, ignore further draft.values changes + // (they originate from our own patches). + useEffect(() => { + if (hasRestoredRef.current) return; + if (draft.values === initialDraftValuesRef.current) return; + if (form.dirty) { + // User typed before restore landed — useFormDraft's own + // userTouchedRef prevents the stored data from ever 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 on every keystroke. + hasRestoredRef.current = true; + return; + } + hasRestoredRef.current = true; + // Mark the upcoming form.values change as "ours", not user input. The + // value-watcher effect runs next and skips its patch. We don't use + // resetForm({ values }) here because that would update Formik's + // internal initialValues to the restored data — and then a later + // discard() would resetForm() back to the restored value (not the + // user's original empty form). + ignoreNextFormChangeRef.current = true; + // shouldValidate=false: restore is not user input, painting validation + // errors on text the user never typed is a UX regression. + void form.setValues(draft.values, false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draft.values]); + + // Refify the form so `discard` doesn't churn identity when Formik recreates + // its handlers (which happens whenever the consumer passes inline + // `initialErrors` / `onReset` props). Keeps consumers' useEffect deps and + // memo'd buttons stable. + const formRef = useRef(form); + formRef.current = form; + + // Wrap discard so the visible formik form also clears. Without this, the + // underlying draft.discard() empties storage but formik.values still + // shows the discarded text — and the next keystroke would re-persist + // that stale text back into storage, effectively undoing the discard. + // + // Also resets `userTouchedRef` so the post-discard `form.resetForm` event + // doesn't trigger an immediate patch of the empty initial values back + // into the just-cleared storage. + const discard = useCallback(() => { + draft.discard(); + formRef.current.resetForm(); + 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 d62daa6..9b5b94d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', 'rhf/index': 'src/rhf/index.ts', + 'formik/index': 'src/formik/index.ts', 'storage/indexedDB': 'src/storage/indexedDB.ts', 'storage/sessionStorage': 'src/storage/sessionStorage.ts', }, @@ -11,7 +12,7 @@ export default defineConfig({ dts: true, sourcemap: true, clean: true, - external: ['react', 'react-dom', 'react-hook-form', 'zod'], + external: ['react', 'react-dom', 'react-hook-form', 'formik', 'zod'], target: 'es2020', splitting: false, });