diff --git a/README.md b/README.md index 1a6c8ba..ebbcb94 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ > Production-grade form auto-save + offline survival for React. Zero runtime dependencies. [![npm version](https://img.shields.io/npm/v/formdraft?label=npm&color=cb3837)](https://www.npmjs.com/package/formdraft) -[![bundle size](https://img.shields.io/badge/size-5.4%20KB%20brotli-success)](#zero-runtime-dependencies) +[![bundle size](https://img.shields.io/badge/size-5.7%20KB%20brotli-success)](#zero-runtime-dependencies) [![license](https://img.shields.io/npm/l/formdraft)](LICENSE) [![zero deps](https://img.shields.io/badge/runtime%20deps-0-success)](#zero-runtime-dependencies) -> **v0.2.0** — 202 unit tests + 84 Playwright e2e tests (28 scenarios × Chromium / Firefox / WebKit). New in v0.2: Formik / TanStack Form adapters, `autoAdapter` (localStorage → IndexedDB), `getFormDraft` programmatic handle, `useFormDraftStatus` sibling reader, heartbeat detector, and field-level merge UI (`ConflictResolver` / `ConflictDialog` under `formdraft/ui`). +> **v0.3.0** — 216 unit tests + 87 Playwright e2e tests (29 scenarios × Chromium / Firefox / WebKit). New in v0.3: `fieldsNeedingReentry` — surfaces sensitive fields (like `excludeFields: ['password']`) that were stripped from storage and need re-entry after refresh. v0.2: Formik / TanStack Form adapters, `autoAdapter`, `getFormDraft`, `useFormDraftStatus`, heartbeat detector, conflict merge UI. ![demo](docs/assets/demo.gif) @@ -145,7 +145,7 @@ formdraft is that stack, packaged. With the 7 platform-quirk traps AI assistants | Library | Status | Persist | Restore | Server sync | Offline queue | Multi-tab | Status UI | IndexedDB | Bundle | |---|---|---|---|---|---|---|---|---|---| -| **formdraft** | active | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 5.4 KB | +| **formdraft** | active | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 5.8 KB | | react-hook-form-persist | dead 2022 | ✓ | ✓ | — | — | — | — | — | ~2 KB | | formik-persist | dead 2018 | ✓ | ✓ | — | — | — | — | — | ~2 KB | | react-autosave | partial | — | — | ✓ | — | — | — | — | ~3 KB | @@ -290,6 +290,31 @@ 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. +## Re-entry of excluded fields after restore + +`excludeFields: ['password']` keeps sensitive values out of storage, but the UX gap was: after refresh, the wizard restores at step 5 and the user clicks Submit with an empty password. They never see the field that's missing. + +`draft.fieldsNeedingReentry` (also on `useFormDraftStatus(key)` and `getFormDraft(key).getFieldsNeedingReentry()`) surfaces this list: + +```tsx +const draft = useFormDraft({ + defaultValues: { email: '', password: '', step: 1 }, + excludeFields: ['password'], + // ... +}); + +return ( + <> + {draft.fieldsNeedingReentry.includes('password') && ( + 보안을 위해 비밀번호를 다시 입력해주세요. + )} + {/* ... form */} + +); +``` + +The list is populated only when the prior session actually had a non-default value for the excluded field (tracked via a `__excludedHad` flag in the stored record — key names only, never values). Auto-clears per-field when the user re-enters a non-default value; clears entirely on `discard()` / `submit()`. Pre-v0.3 stored records without the flag restore cleanly with an empty list (backward compatible). + ## Multi-tab strategies | Strategy | What happens on remote change | @@ -365,7 +390,7 @@ Diffing is shallow object-equality (`Object.is` per key) — sufficient for v0.2 formdraft has **no** runtime dependencies. Only peer deps (which you'd install anyway): `react`, optionally one of `react-hook-form` / `formik` / `@tanstack/react-form`, optionally `zod`. -Bundle target: **≤ 8 KB brotli** (enforced in CI; current main bundle is ~5.4 KB). +Bundle target: **≤ 8 KB brotli** (enforced in CI; current main bundle is ~5.8 KB). ## Security model @@ -445,9 +470,9 @@ A: No. formdraft uses BroadcastChannel, navigator.onLine, IndexedDB — all brow ## Status -- **v0.2.0** on [npm](https://www.npmjs.com/package/formdraft) -- 202 unit tests + 84 Playwright e2e (28 scenarios × Chromium / Firefox / WebKit) -- **~5.42 KB brotli** (8 KB CI gate) — UI helpers ship as a separate `formdraft/ui` chunk +- **v0.3.0** on [npm](https://www.npmjs.com/package/formdraft) +- 216 unit tests + 87 Playwright e2e (29 scenarios × Chromium / Firefox / WebKit) +- **~5.8 KB brotli** (8 KB CI gate) — UI helpers ship as a separate `formdraft/ui` chunk - React 18+; Browser support Chrome/Edge 88+, Firefox 78+, Safari 15.4+ - Every adapter (RHF / Formik / TanStack Form) and every storage backend (localStorage / sessionStorage / IndexedDB / autoAdapter) is exercised end-to-end on all three engines - 0 runtime dependencies diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index 11103fd..2823047 100644 Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ diff --git a/example/src/pages/WizardPage.tsx b/example/src/pages/WizardPage.tsx index a2e6030..b667f04 100644 --- a/example/src/pages/WizardPage.tsx +++ b/example/src/pages/WizardPage.tsx @@ -49,6 +49,23 @@ export default function WizardPage() { + {draft.fieldsNeedingReentry.includes('password') && ( +
+ 보안을 위해 비밀번호를 다시 입력해주세요. (Storage에 저장되지 않습니다.) +
+ )} +

diff --git a/package.json b/package.json index 15ff972..d941a9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "formdraft", - "version": "0.2.0", + "version": "0.3.0", "description": "Production-grade form auto-save + offline survival for React. Zero runtime dependencies.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/__tests__/fieldsNeedingReentry.test.tsx b/src/__tests__/fieldsNeedingReentry.test.tsx new file mode 100644 index 0000000..0cc6632 --- /dev/null +++ b/src/__tests__/fieldsNeedingReentry.test.tsx @@ -0,0 +1,405 @@ +import { StrictMode } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { useFormDraft } from '../useFormDraft'; +import { zodAdapter } from '../internal/schemaValidation'; +import { localStorageAdapter } from '../storage/localStorage'; +import { _clearRegistryForTests } from '../internal/registry'; + +const Schema = z.object({ + email: z.string(), + password: z.string(), + step: z.number(), +}); +type V = z.infer; +const DEFAULTS: V = { email: '', password: '', step: 1 }; +const STORAGE_KEY = 'formdraft:reentry-test'; + +function probeFactory(overrides?: Partial>[0]>) { + function Inner() { + const draft = useFormDraft({ + key: 'reentry-test', + schema: zodAdapter(Schema), + defaultValues: DEFAULTS, + storage: localStorageAdapter(), + multiTab: false, + excludeFields: ['password'], + ...overrides, + }); + return ( +
+ {draft.fieldsNeedingReentry.join(',')} + {draft.values.email} + {draft.values.password} + {String(draft.values.step)} +
+ ); + } + return function Probe() { + return ( + + + + ); + }; +} + +describe('fieldsNeedingReentry', () => { + beforeEach(() => { + localStorage.clear(); + _clearRegistryForTests(); + }); + + it('is empty on a fresh mount with no stored draft', () => { + const Probe = probeFactory(); + render(); + expect(screen.getByTestId('reentry').textContent).toBe(''); + }); + + it('persist records __excludedHad only when an excluded field is non-default', async () => { + const Probe = probeFactory(); + render(); + // Email change persists; password is at default → no __excludedHad + act(() => fireEvent.click(screen.getByTestId('type-email'))); + await new Promise((r) => setTimeout(r, 120)); + let stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); + expect(stored.values).toMatchObject({ email: 'a@b.com' }); + expect(stored.__excludedHad).toBeUndefined(); + + // Now password is set → __excludedHad lists it + act(() => fireEvent.click(screen.getByTestId('type-pw'))); + await new Promise((r) => setTimeout(r, 120)); + stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); + expect(stored.__excludedHad).toEqual(['password']); + // Value is still excluded from storage (the point of excludeFields) + expect(stored.values.password).toBeUndefined(); + }); + + it('restore populates fieldsNeedingReentry when stored record has __excludedHad', async () => { + // Simulate a prior session where the user filled the password before refresh + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 3 }, // password stripped + __excludedHad: ['password'], + }), + ); + const Probe = probeFactory(); + render(); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + // Confirm other state restored too — the user lands at step 3, password empty + expect(screen.getByTestId('email').textContent).toBe('a@b.com'); + expect(screen.getByTestId('step').textContent).toBe('3'); + expect(screen.getByTestId('password').textContent).toBe(''); + }); + + it('user re-entering the field clears it from fieldsNeedingReentry', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 1 }, + __excludedHad: ['password'], + }), + ); + const Probe = probeFactory(); + render(); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + act(() => fireEvent.click(screen.getByTestId('type-pw'))); + expect(screen.getByTestId('reentry').textContent).toBe(''); + }); + + it('clearing the field back to default brings it back into fieldsNeedingReentry', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 1 }, + __excludedHad: ['password'], + }), + ); + const Probe = probeFactory(); + render(); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + act(() => fireEvent.click(screen.getByTestId('type-pw'))); + expect(screen.getByTestId('reentry').textContent).toBe(''); + act(() => fireEvent.click(screen.getByTestId('clear-pw'))); + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + + it('discard clears fieldsNeedingReentry', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 1 }, + __excludedHad: ['password'], + }), + ); + const Probe = probeFactory(); + render(); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + act(() => fireEvent.click(screen.getByTestId('discard'))); + expect(screen.getByTestId('reentry').textContent).toBe(''); + }); + + it('submit clears fieldsNeedingReentry', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 1 }, + __excludedHad: ['password'], + }), + ); + const Probe = probeFactory(); + render(); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + // After typing a password (so submit handler has something), submit + act(() => fireEvent.click(screen.getByTestId('type-pw'))); + act(() => fireEvent.click(screen.getByTestId('submit'))); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe(''); + }); + }); + + it('pre-0.3 records without __excludedHad restore cleanly with empty reentry list (backward compat)', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'old@user.com', step: 2 }, + // no __excludedHad + }), + ); + const Probe = probeFactory(); + render(); + await waitFor(() => { + expect(screen.getByTestId('email').textContent).toBe('old@user.com'); + }); + expect(screen.getByTestId('reentry').textContent).toBe(''); + }); + + it('multiple excludeFields with partial coverage', async () => { + const apiKeySchema = z.object({ email: z.string(), password: z.string(), apiKey: z.string() }); + type W = z.infer; + const defs: W = { email: '', password: '', apiKey: '' }; + localStorage.setItem( + 'formdraft:multi-reentry', + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com' }, + __excludedHad: ['password'], // user had only password, not apiKey + }), + ); + function Inner() { + const d = useFormDraft({ + key: 'multi-reentry', + schema: zodAdapter(apiKeySchema), + defaultValues: defs, + storage: localStorageAdapter(), + multiTab: false, + excludeFields: ['password', 'apiKey'], + }); + return {d.fieldsNeedingReentry.join(',')}; + } + render( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + }); + + it('filters non-string entries from __excludedHad on restore (F4 regression)', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 1 }, + __excludedHad: [42, null, { x: 1 }, 'password'], + }), + ); + const Probe = probeFactory(); + render(); + await waitFor(() => { + // Only the legit string entry should remain + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + }); + + it('intersects with current excludeFields, dropping stale keys (F6 regression)', async () => { + // Prior session had excludeFields: ['password', 'apiKey']. Current session + // only excludes 'password'. The stale 'apiKey' entry must not appear. + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 1 }, + __excludedHad: ['password', 'apiKey'], + }), + ); + const Probe = probeFactory(); // current excludeFields: ['password'] + render(); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + }); + + it('excludeFields with non-empty defaultValue: needs-reentry uses Object.is(value, default) comparison', async () => { + const schema = z.object({ note: z.string(), sensitive: z.string() }); + type W = z.infer; + const defs: W = { note: '', sensitive: 'placeholder' }; + localStorage.setItem( + 'formdraft:nondefault-reentry', + JSON.stringify({ + __v: 1, + values: { note: 'hi' }, + __excludedHad: ['sensitive'], + }), + ); + function Inner() { + const d = useFormDraft({ + key: 'nondefault-reentry', + schema: zodAdapter(schema), + defaultValues: defs, + storage: localStorageAdapter(), + multiTab: false, + excludeFields: ['sensitive'], + }); + return ( +
+ {d.fieldsNeedingReentry.join(',')} +
+ ); + } + render( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('sensitive'); + }); + // Setting to the same default value keeps it in needs-reentry + act(() => fireEvent.click(screen.getByTestId('set-same'))); + expect(screen.getByTestId('reentry').textContent).toBe('sensitive'); + // Setting to a new value clears + act(() => fireEvent.click(screen.getByTestId('set-new'))); + expect(screen.getByTestId('reentry').textContent).toBe(''); + }); + + it('useFormDraftStatus sibling re-renders when fieldsNeedingReentry changes (F1 regression)', async () => { + const { useFormDraftStatus } = await import('../useFormDraftStatus'); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 1 }, + __excludedHad: ['password'], + }), + ); + function Host() { + const d = useFormDraft({ + key: 'reentry-test', + schema: zodAdapter(Schema), + defaultValues: DEFAULTS, + storage: localStorageAdapter(), + multiTab: false, + excludeFields: ['password'], + }); + return ( + + ); + } + function Sibling() { + const s = useFormDraftStatus('reentry-test'); + return {s.fieldsNeedingReentry.join(',')}; + } + render( + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('sibling-reentry').textContent).toBe('password'); + }); + // User fills password — sibling must observe the cleared list WITHOUT + // waiting for a save or status transition. + act(() => fireEvent.click(screen.getByTestId('type-pw'))); + await waitFor(() => { + expect(screen.getByTestId('sibling-reentry').textContent).toBe(''); + }); + }); + + it('resolveConflict("remote") clears fieldsNeedingReentry (F2 regression)', async () => { + // Seed a restore that populates excludedHadOnRestore + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + __v: 1, + values: { email: 'a@b.com', step: 1 }, + __excludedHad: ['password'], + }), + ); + function Inner() { + const d = useFormDraft({ + key: 'reentry-test', + schema: zodAdapter(Schema), + defaultValues: DEFAULTS, + storage: localStorageAdapter(), + multiTab: 'warn', + excludeFields: ['password'], + }); + return ( +
+ {d.fieldsNeedingReentry.join(',')} +
+ ); + } + render( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('reentry').textContent).toBe('password'); + }); + act(() => fireEvent.click(screen.getByTestId('resolve-remote'))); + expect(screen.getByTestId('reentry').textContent).toBe(''); + }); +}); diff --git a/src/__tests__/getFormDraft.test.tsx b/src/__tests__/getFormDraft.test.tsx index bdd180a..d1f41d8 100644 --- a/src/__tests__/getFormDraft.test.tsx +++ b/src/__tests__/getFormDraft.test.tsx @@ -31,6 +31,7 @@ function makeRegistryEntry(machine: StatusMachine): RegistryEntry { pendingChangesRef: { current: false }, errorRef: { current: null }, lastSavedAtRef: { current: null }, + fieldsNeedingReentryRef: { current: [] }, }; } diff --git a/src/__tests__/useFormDraftStatus.test.tsx b/src/__tests__/useFormDraftStatus.test.tsx index 94bf71d..18a0731 100644 --- a/src/__tests__/useFormDraftStatus.test.tsx +++ b/src/__tests__/useFormDraftStatus.test.tsx @@ -19,6 +19,7 @@ function makeEntry(overrides: Partial = {}): RegistryEntry { pendingChangesRef: { current: false }, errorRef: { current: null }, lastSavedAtRef: { current: null }, + fieldsNeedingReentryRef: { current: [] }, ...overrides, }; } diff --git a/src/formik/useFormDraftFormik.ts b/src/formik/useFormDraftFormik.ts index a56b713..4b739c0 100644 --- a/src/formik/useFormDraftFormik.ts +++ b/src/formik/useFormDraftFormik.ts @@ -32,6 +32,7 @@ export function useFormDraftFormik( discard: ReturnType>['discard']; onConflictData: ReturnType>['onConflictData']; resolveConflict: ReturnType>['resolveConflict']; + fieldsNeedingReentry: ReturnType>['fieldsNeedingReentry']; } { const defaultValues = form.initialValues; const draft = useFormDraft({ @@ -144,5 +145,6 @@ export function useFormDraftFormik( discard, onConflictData: draft.onConflictData, resolveConflict: draft.resolveConflict, + fieldsNeedingReentry: draft.fieldsNeedingReentry, }; } diff --git a/src/getFormDraft.ts b/src/getFormDraft.ts index 497131a..d15ac9f 100644 --- a/src/getFormDraft.ts +++ b/src/getFormDraft.ts @@ -23,6 +23,11 @@ export type FormDraftHandle = { getValues: () => T; getPendingChanges: () => boolean; getError: () => Error | null; + /** + * Snapshot of `excludeFields` that need re-entry after a restore. See + * `FormDraftResult.fieldsNeedingReentry` for semantics. + */ + getFieldsNeedingReentry: () => ReadonlyArray; }; /** @@ -53,5 +58,7 @@ export function getFormDraft( getValues: () => entry.valuesRef.current as T, getPendingChanges: () => entry.pendingChangesRef.current, getError: () => entry.errorRef.current, + getFieldsNeedingReentry: () => + entry.fieldsNeedingReentryRef.current as ReadonlyArray, }; } diff --git a/src/internal/registry.ts b/src/internal/registry.ts index 85bd146..1df5e4e 100644 --- a/src/internal/registry.ts +++ b/src/internal/registry.ts @@ -25,6 +25,7 @@ export type RegistryEntry = { pendingChangesRef: { current: boolean }; errorRef: { current: Error | null }; lastSavedAtRef: { current: Date | null }; + fieldsNeedingReentryRef: { current: ReadonlyArray }; }; // Stack of registrations per key. Last-in is the "active" one (returned by diff --git a/src/rhf/useFormDraftRHF.ts b/src/rhf/useFormDraftRHF.ts index f62864c..d008cc6 100644 --- a/src/rhf/useFormDraftRHF.ts +++ b/src/rhf/useFormDraftRHF.ts @@ -15,6 +15,7 @@ export function useFormDraftRHF( discard: ReturnType>['discard']; onConflictData: ReturnType>['onConflictData']; resolveConflict: ReturnType>['resolveConflict']; + fieldsNeedingReentry: ReturnType>['fieldsNeedingReentry']; } { // RHF returns undefined when no defaultValues were passed to useForm(). Fall // back to an empty object so set()/patch() spreads don't blow up downstream. @@ -96,5 +97,6 @@ export function useFormDraftRHF( discard, onConflictData: draft.onConflictData, resolveConflict: draft.resolveConflict, + fieldsNeedingReentry: draft.fieldsNeedingReentry, }; } diff --git a/src/tanstack-form/useFormDraftTanstack.ts b/src/tanstack-form/useFormDraftTanstack.ts index 0e89acb..89f8559 100644 --- a/src/tanstack-form/useFormDraftTanstack.ts +++ b/src/tanstack-form/useFormDraftTanstack.ts @@ -45,6 +45,7 @@ export function useFormDraftTanstack>( discard: ReturnType>['discard']; onConflictData: ReturnType>['onConflictData']; resolveConflict: ReturnType>['resolveConflict']; + fieldsNeedingReentry: ReturnType>['fieldsNeedingReentry']; } { const defaultValues = (form.options.defaultValues ?? ({} as T)) as T; const draft = useFormDraft({ @@ -160,5 +161,6 @@ export function useFormDraftTanstack>( discard, onConflictData: draft.onConflictData, resolveConflict: draft.resolveConflict, + fieldsNeedingReentry: draft.fieldsNeedingReentry, }; } diff --git a/src/types.ts b/src/types.ts index 415be34..7823398 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,19 @@ export type FormDraftResult = { submit: (handler: (values: T) => Promise) => (e?: { preventDefault?: () => void }) => Promise; onConflictData: T | null; resolveConflict: (choice: 'local' | 'remote' | T) => void; + /** + * Names of `excludeFields` whose pre-persist values were non-default but + * weren't restored (because they're excluded from storage by design). + * Populated only after a storage-driven restore. Auto-clears per-field as + * the user re-enters a non-default value, and on `discard()` / `submit()`. + * + * Use to prompt re-entry of sensitive fields after refresh: + * + * {draft.fieldsNeedingReentry.includes('password') && ( + * Re-enter your password to continue + * )} + */ + fieldsNeedingReentry: ReadonlyArray; }; export type BroadcastMessage = diff --git a/src/useFormDraft.ts b/src/useFormDraft.ts index 7dfc64f..b71ee37 100644 --- a/src/useFormDraft.ts +++ b/src/useFormDraft.ts @@ -23,7 +23,33 @@ const PERSIST_DEBOUNCE_MS = 50; const BROADCAST_DEBOUNCE_MS = 200; const STORAGE_RECORD_KEY = '__v'; -type StoredRecord = { __v: number; values: T }; +type StoredRecord = { + __v: number; + values: T; + /** + * Optional hint added in v0.3: names of `excludeFields` whose values were + * non-default at persist time. Used by restore to surface "needs re-entry" + * for sensitive fields the consumer chose to strip from storage. Key names + * only — values are never persisted (that defeats the point of excludeFields). + * + * Pre-v0.3 records omit this field; restore treats them as "nothing needed + * re-entry" — safely backward compatible. + */ + __excludedHad?: string[]; +}; + +function computeExcludedHad( + values: T, + defaultValues: T, + excludeFields: Array, +): string[] { + if (excludeFields.length === 0) return []; + const out: string[] = []; + for (const k of excludeFields) { + if (!Object.is(values[k], defaultValues[k])) out.push(k as string); + } + return out; +} function generateTabId(): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); @@ -66,6 +92,11 @@ export function useFormDraft>( const [error, setError] = useState(null); const [pendingChanges, setPendingChanges] = useState(false); const [onConflictData, setOnConflictData] = useState(null); + // Excluded fields whose pre-persist values were non-default — populated + // from the stored record's `__excludedHad` on restore, cleared on + // discard/submit, and used (together with current `values`) to derive + // `fieldsNeedingReentry` per render. + const [excludedHadOnRestore, setExcludedHadOnRestore] = useState([]); const tabIdRef = useRef(generateTabId()); const mountedRef = useRef(true); @@ -93,6 +124,16 @@ export function useFormDraft>( // which would otherwise read this ref before its declaration line. const excludeFieldsRef = useRef(excludeFields); excludeFieldsRef.current = excludeFields; + // Mirror of defaultValues so the persist debounce (frozen at mount) can + // compute "is this excluded field at its default?" against the latest + // user-provided defaults without recreating the debounced function. + const defaultValuesRef = useRef(defaultValues); + defaultValuesRef.current = defaultValues; + // Ref tracking the derived `fieldsNeedingReentry` list (assigned at the + // bottom of this function after we compute the derivation). Hoisted up + // here so the registry effect can capture it on first mount — same + // pattern as the other snapshot refs. + const fieldsNeedingReentryRef = useRef>([]); // Tracks whether user has called set()/patch() since mount. Used to skip a // late-arriving storage restore so user input isn't clobbered. @@ -222,12 +263,15 @@ export function useFormDraft>( ? runCallback(() => onConflict(valuesRef.current, remote), 'onConflict') : 'remote'; if (resolved === undefined) return; // callback threw — keep current - if (resolved === 'remote') setValues(remote); - else if (resolved === 'local') { + if (resolved === 'remote') { + setValues(remote); + setExcludedHadOnRestore([]); + } else if (resolved === 'local') { // keep current — nothing to do } else if (resolved !== null && typeof resolved === 'object') { // Caller returned a merged object; trust it as the resolved state. setValues(resolved as T); + setExcludedHadOnRestore([]); } // Any other return (string typo, undefined, primitive) is ignored — // safer than coercing junk into form state. @@ -249,6 +293,7 @@ export function useFormDraft>( setPendingChanges(false); setLastSavedAt(null); setError(null); + setExcludedHadOnRestore([]); void storage.remove(key); statusMachineRef.current.send('RESET'); }); @@ -262,6 +307,7 @@ export function useFormDraft>( setValues(defaultValues); setPendingChanges(false); setError(null); + setExcludedHadOnRestore([]); statusMachineRef.current.send('RESET'); }); return () => { @@ -358,10 +404,35 @@ export function useFormDraft>( const validated = validateOrDiscard(mergeExcluded(migrated), schema, key); if (validated !== null && !userTouchedRef.current) { setValues(validated); + // Recompute __excludedHad against the migrated values + current + // defaults. A naive carry-over would persist stale field names + // forever if the migrate function renamed a key (e.g., password + // → pw). Recomputing means: the hint reflects which CURRENT + // excludeFields are non-default in the migrated state. + const newExcludedHad = computeExcludedHad( + validated, + defaultValues, + excludeFieldsRef.current, + ); + if (newExcludedHad.length > 0) { + setExcludedHadOnRestore(newExcludedHad); + } else if (Array.isArray(record.__excludedHad)) { + // Fall back to the carried-over hint only when filtered cleanly + // (string keys that match current excludeFields) — the runtime + // useMemo intersect will drop anything else. + const carried = record.__excludedHad.filter( + (k): k is string => + typeof k === 'string' && + (excludeFieldsRef.current as unknown as string[]).includes(k), + ); + if (carried.length > 0) setExcludedHadOnRestore(carried); + } // Persist with the new __v so subsequent mounts don't re-migrate // (non-idempotent migrators would otherwise corrupt data each mount). const stripped = stripExcluded(validated, excludeFieldsRef.current); - void storage.write(key, { __v: version, values: stripped } as StoredRecord); + const rewritten: StoredRecord = { __v: version, values: stripped }; + if (newExcludedHad.length > 0) rewritten.__excludedHad = newExcludedHad; + void storage.write(key, rewritten); } return; } @@ -375,7 +446,20 @@ export function useFormDraft>( const validated = validateOrDiscard(mergeExcluded(record.values), schema, key); // Skip restore if user has already typed — don't clobber their input with a // late-arriving storage read (race surfaces in StrictMode and slow I/O). - if (validated !== null && !userTouchedRef.current) setValues(validated); + if (validated !== null && !userTouchedRef.current) { + setValues(validated); + // Hydrate the "needs re-entry" hint so consumers can prompt for + // sensitive fields that were stripped from storage by `excludeFields`. + // Defensively filter to strings; hand-written or downgraded records + // could contain non-string entries that would surface as garbage in + // the consumer-facing `fieldsNeedingReentry` list. + if (Array.isArray(record.__excludedHad)) { + const cleaned = record.__excludedHad.filter( + (k): k is string => typeof k === 'string', + ); + if (cleaned.length > 0) setExcludedHadOnRestore(cleaned); + } + } })(); return () => { cancelled = true; @@ -395,11 +479,21 @@ export function useFormDraft>( debounce(async (next: T) => { if (!mountedRef.current) return; const stripped = stripExcluded(next, excludeFieldsRef.current); + // List excluded fields that currently hold non-default values, so a + // future restore can surface them as `fieldsNeedingReentry`. Key names + // only — values are not persisted (that's the whole point of excluding). + const excludedHad = computeExcludedHad( + next, + defaultValuesRef.current, + excludeFieldsRef.current, + ); + const record: StoredRecord = { + __v: versionRef.current, + values: stripped, + }; + if (excludedHad.length > 0) record.__excludedHad = excludedHad; try { - await storageRef.current.write(keyRef.current, { - __v: versionRef.current, - values: stripped, - } as StoredRecord); + await storageRef.current.write(keyRef.current, record); } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); if (mountedRef.current) setError(err); @@ -485,6 +579,7 @@ export function useFormDraft>( pendingChangesRef, errorRef, lastSavedAtRef, + fieldsNeedingReentryRef, }; registerDraft(key, entry); const unsubStatus = statusMachineRef.current.subscribe(() => { @@ -554,6 +649,7 @@ export function useFormDraft>( setLastSavedAt(null); setError(null); setOnConflictData(null); + setExcludedHadOnRestore([]); syncQueueRef.current?.cancel(); void storage.remove(key); broadcasterRef.current?.broadcastDiscarded(); @@ -587,6 +683,7 @@ export function useFormDraft>( setPendingChanges(false); setLastSavedAt(null); setError(null); + setExcludedHadOnRestore([]); statusMachineRef.current.send('RESET'); } return result; @@ -607,8 +704,13 @@ export function useFormDraft>( // keep current } else if (choice === 'remote') { if (onConflictData) setValues(onConflictData); + // The user explicitly adopted the remote snapshot — the local + // restore-hint no longer applies. Clear so we don't prompt re-entry + // for fields the user just chose to throw away. + setExcludedHadOnRestore([]); } else { setValues(choice as T); + setExcludedHadOnRestore([]); } setOnConflictData(null); statusMachineRef.current.send('RESOLVE'); @@ -625,6 +727,34 @@ export function useFormDraft>( // needs the imperative shape, not per-call return-type fidelity. submitRef.current = submit as typeof submitRef.current; + // Derive `fieldsNeedingReentry` per render: the restore hint, filtered down + // to keys that (a) are still in the current `excludeFields` config, AND + // (b) hold the default value in the live form. As the user re-enters one + // (typing → value diverges from default), it falls out of the list. The + // excludeFields intersect drops stale keys that survived a session where + // the consumer's `excludeFields` shrank or a key was renamed via migrate. + const fieldsNeedingReentry = useMemo>(() => { + if (excludedHadOnRestore.length === 0) return []; + const excludeSet = new Set(excludeFields as unknown as string[]); + const out: Array = []; + for (const k of excludedHadOnRestore) { + if (!excludeSet.has(k)) continue; + const key = k as keyof T; + if (Object.is(values[key], defaultValues[key])) out.push(k as keyof T & string); + } + return out; + }, [excludedHadOnRestore, values, defaultValues, excludeFields]); + fieldsNeedingReentryRef.current = fieldsNeedingReentry; + + // F1 fix: notify registry subscribers when `fieldsNeedingReentry` changes + // so `useFormDraftStatus` siblings re-render. The existing notify effect + // only watches `lastSavedAt` and would miss reentry transitions in between + // saves (e.g., user fills the password but no save has fired yet). + useEffect(() => { + notifySubscribers(key); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, fieldsNeedingReentry]); + return { values, set, @@ -638,5 +768,6 @@ export function useFormDraft>( submit, onConflictData, resolveConflict, + fieldsNeedingReentry, }; } diff --git a/src/useFormDraftStatus.ts b/src/useFormDraftStatus.ts index c69a6b9..739d0b1 100644 --- a/src/useFormDraftStatus.ts +++ b/src/useFormDraftStatus.ts @@ -2,8 +2,24 @@ import { useCallback, useRef, useSyncExternalStore } from 'react'; import { getDraft, subscribeRegistry } from './internal/registry'; import type { FormDraftStatus } from './types'; -type Snapshot = { status: FormDraftStatus; lastSavedAt: Date | null }; -const DEFAULT_SNAPSHOT: Snapshot = { status: 'idle', lastSavedAt: null }; +type Snapshot = { + status: FormDraftStatus; + lastSavedAt: Date | null; + fieldsNeedingReentry: ReadonlyArray; +}; +const EMPTY_REENTRY: ReadonlyArray = []; +const DEFAULT_SNAPSHOT: Snapshot = { + status: 'idle', + lastSavedAt: null, + fieldsNeedingReentry: EMPTY_REENTRY, +}; + +function arraysShallowEqual(a: ReadonlyArray, b: ReadonlyArray): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} export function useFormDraftStatus(key: string): Snapshot { // Cache the last returned snapshot OBJECT so consecutive getSnapshot() calls @@ -13,8 +29,9 @@ export function useFormDraftStatus(key: string): Snapshot { const cacheRef = useRef<{ status: FormDraftStatus | null; lastSavedMs: number | null; + reentry: ReadonlyArray; snapshot: Snapshot; - }>({ status: null, lastSavedMs: null, snapshot: DEFAULT_SNAPSHOT }); + }>({ status: null, lastSavedMs: null, reentry: EMPTY_REENTRY, snapshot: DEFAULT_SNAPSHOT }); const subscribe = useCallback( (cb: () => void) => { @@ -32,8 +49,13 @@ export function useFormDraftStatus(key: string): Snapshot { const entry = getDraft(key); if (!entry) { const c = cacheRef.current; - if (c.status === null && c.lastSavedMs === null) return c.snapshot; - cacheRef.current = { status: null, lastSavedMs: null, snapshot: DEFAULT_SNAPSHOT }; + if (c.status === null && c.lastSavedMs === null && c.reentry === EMPTY_REENTRY) return c.snapshot; + cacheRef.current = { + status: null, + lastSavedMs: null, + reentry: EMPTY_REENTRY, + snapshot: DEFAULT_SNAPSHOT, + }; return DEFAULT_SNAPSHOT; } const status = entry.statusMachine.getStatus(); @@ -41,13 +63,21 @@ export function useFormDraftStatus(key: string): Snapshot { // here, but that required unregister→register on every save and flickered // subscribers through DEFAULT_SNAPSHOT. const lastSavedMs = entry.lastSavedAtRef.current?.getTime() ?? null; + const reentry = entry.fieldsNeedingReentryRef.current; const c = cacheRef.current; - if (c.status === status && c.lastSavedMs === lastSavedMs) return c.snapshot; + if ( + c.status === status && + c.lastSavedMs === lastSavedMs && + arraysShallowEqual(c.reentry, reentry) + ) { + return c.snapshot; + } const snapshot: Snapshot = { status, lastSavedAt: lastSavedMs !== null ? new Date(lastSavedMs) : null, + fieldsNeedingReentry: reentry, }; - cacheRef.current = { status, lastSavedMs, snapshot }; + cacheRef.current = { status, lastSavedMs, reentry, snapshot }; return snapshot; }, [key]); diff --git a/tests/e2e/headline.spec.ts b/tests/e2e/headline.spec.ts index e2a50f0..6a9cc1a 100644 --- a/tests/e2e/headline.spec.ts +++ b/tests/e2e/headline.spec.ts @@ -143,4 +143,29 @@ test.describe('formdraft headline scenarios', () => { await context.close(); }); + + test('S8: reentry banner appears for excluded password after refresh', async ({ page }) => { + await freshPage(page); + await fillStep1(page, 'reentry@x.com', 'mypassword'); + await page.locator('button:has-text("Next")').click(); + await fillStep2(page, 'User', 'Some bio'); + await page.waitForTimeout(300); + + // No banner yet — we haven't refreshed, the form is in-session + await expect(page.locator('[data-testid="reentry-banner"]')).toHaveCount(0); + + // Refresh: password is excluded from storage, so its value is gone + // but the rest of the wizard state survives. The banner should appear. + await page.reload(); + await page.waitForSelector('input[placeholder="Your name"]'); + await expect(page.locator('[data-testid="reentry-banner"]')).toBeVisible(); + await expect(page.locator('input[placeholder="Your name"]')).toHaveValue('User'); + + // Go back to step 1 and re-enter the password — banner clears. + await page.locator('button:has-text("← Back")').click(); + await expect(page.locator('input[type="email"]')).toHaveValue('reentry@x.com'); + await expect(page.locator('input[type="password"]')).toHaveValue(''); + await page.locator('input[type="password"]').fill('mypassword'); + await expect(page.locator('[data-testid="reentry-banner"]')).toHaveCount(0); + }); });