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.
[](https://www.npmjs.com/package/formdraft)
-[](#zero-runtime-dependencies)
+[](#zero-runtime-dependencies)
[](LICENSE)
[](#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.

@@ -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(',')}
+ d.set('sensitive', 'placeholder')} />
+ d.set('sensitive', 'real')} />
+
+ );
+ }
+ 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 (
+ d.set('password', 'real')}>
+ set
+
+ );
+ }
+ 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(',')}
+ d.resolveConflict('remote')}
+ />
+
+ );
+ }
+ 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);
+ });
});