Skip to content

feat(#15): fieldsNeedingReentry — close the excludeFields UX gap (v0.3.0)#16

Merged
mayrang merged 2 commits into
mainfrom
feat/v0.3-fields-needing-reentry
May 28, 2026
Merged

feat(#15): fieldsNeedingReentry — close the excludeFields UX gap (v0.3.0)#16
mayrang merged 2 commits into
mainfrom
feat/v0.3-fields-needing-reentry

Conversation

@mayrang
Copy link
Copy Markdown
Owner

@mayrang mayrang commented May 28, 2026

Closes #15.

Problem

`excludeFields: ['password']` keeps sensitive values out of storage, which is correct from a security standpoint — but creates a silent UX failure for multi-step wizards. The user fills password in step 1, progresses to step 5, refreshes. `step` is persisted, password is not. They land back at step 5 with an empty password field and click Submit, and the server gets an empty password. Nothing surfaces the gap.

API

```tsx
const draft = useFormDraft({
defaultValues: { email: '', password: '', step: 1 },
excludeFields: ['password'],
// ...
});

return (
<>
{draft.fieldsNeedingReentry.includes('password') && (
보안을 위해 비밀번호를 다시 입력해주세요.
)}
{/* form */}
</>
);
```

Also exposed on:

  • `useFormDraftStatus(key).fieldsNeedingReentry` (sibling reader)
  • `getFormDraft(key).getFieldsNeedingReentry()` (imperative)
  • RHF / Formik / TanStack adapter returns

How it works

  • Stored record gains optional `__excludedHad?: string[]` — key names of `excludeFields` whose values were non-default at persist time. Values themselves are never persisted.
  • On restore, hint hydrates internal state. `fieldsNeedingReentry` derives per-render: keys that are (a) still in current `excludeFields`, AND (b) currently at default. As the user re-enters a value, it falls out automatically.
  • Discard / submit / conflict-resolve-to-remote clear the hint.
  • Pre-v0.3 records without `__excludedHad` restore cleanly with an empty list — fully backward compatible. v0.2 readers ignore the unknown field if a v0.3 client writes and the user downgrades.

Audit round (one pass, all findings addressed)

Code review + adversarial audit ran in parallel after the initial implementation. Findings:

ID Severity Fix
F1 HIGH Added notify-effect on `fieldsNeedingReentry` so sibling `useFormDraftStatus` re-renders without waiting for a save/status change
F2 Medium-High `resolveConflict('remote' | merged)` + LWW remote/merged paths now clear `excludedHadOnRestore`
F4 Medium Restore filters non-string entries from `__excludedHad` (defends against hand-edited storage / forward-compat issues)
F5 Medium Migrate path recomputes the hint against migrated values + current defaults; doesn't preserve stale field names after schema rename
F6 Medium useMemo intersects with current `excludeFields` so a shrunk set doesn't surface ghost entries

Each fix has a regression test.

Tests

  • Unit: 216 (was 202; +14 covering happy path, deletion-aware merge, defaults comparison, StrictMode double-mount, F1/F2/F4/F6 regressions)
  • e2e: 87 (was 84; new S8 reentry banner across Chromium / Firefox / WebKit)
  • Bundle: 5.8 KB brotli (was 5.42; 8 KB CI gate)

Test plan

  • `npm run typecheck` — clean
  • `npm run lint` — clean
  • `npm test` — 216 passing
  • `npx playwright test` — 87 passing
  • `npm run build` clean
  • `npm run size` — 5.8 KB / 8 KB

Migration

Drop-in for v0.2 consumers. No breaking changes. New API is opt-in via reading `draft.fieldsNeedingReentry`.

Bumps `package.json` to 0.3.0.

mayrang added 2 commits May 28, 2026 15:12
…try after restore

When `excludeFields: ['password']` is set in a multi-step wizard, the
user fills the password in step 1 and progresses to step 5. `step` is
persisted, password is not. On refresh, the wizard restores at step 5
with an empty password and the user clicks Submit — server gets an
empty password. Silent UX failure.

`fieldsNeedingReentry` solves this:

  const draft = useFormDraft({
    excludeFields: ['password'],
    // ...
  });

  return (
    <>
      {draft.fieldsNeedingReentry.includes('password') && (
        <Banner>보안을 위해 비밀번호를 다시 입력해주세요.</Banner>
      )}
      {/* form */}
    </>
  );

How it works:
- Stored record gains optional `__excludedHad?: string[]` — key names of
  excludeFields whose values were non-default at persist time. Values
  themselves are never persisted (that's the point of excludeFields).
- On restore, the hint hydrates internal state. The derived
  `fieldsNeedingReentry` per-render filters to keys that are STILL at
  default AND still in current `excludeFields`. As the user re-enters
  a value, it falls out of the list automatically. Discard / submit /
  conflict-resolve-remote clear the state.
- Pre-v0.3 records (without `__excludedHad`) restore cleanly with an
  empty list — fully backward compatible.

Exposed on:
- `FormDraftResult.fieldsNeedingReentry`
- `useFormDraftStatus(key)` (sibling reader)
- `getFormDraft(key).getFieldsNeedingReentry()` (imperative)
- RHF / Formik / TanStack adapter returns

Audit-driven hardening:
- Restore filters non-string entries from `__excludedHad` (F4)
- Migrate path recomputes the hint against migrated values + current
  defaults instead of carrying over stale field names (F5)
- useMemo intersects with current `excludeFields` so a shrinking set
  doesn't surface ghost entries (F6)
- `resolveConflict('remote' | merged)` + multiTab `last-writer-wins`
  remote/merged paths clear the hint (F2)
- Added a notify-effect on `fieldsNeedingReentry` so sibling
  `useFormDraftStatus` consumers re-render on transitions without
  waiting for a save/status change (F1)

Tests: 216 unit (was 202; +14) + 87 e2e (was 84; +3 cross-browser S8).
Bundle: 5.8 KB brotli / 8 KB CI gate.

Closes #15.
Extends the existing wizard demo (fill → refresh → values survive) with
the new v0.3 `fieldsNeedingReentry` flow at the end: refresh shows the
re-entry banner, going back to step 1 reveals the empty password field,
typing it clears the banner.

Recorded at 900x640, scaled to 800px wide, 14fps, brotli-friendly
palette quantization. Size 1.2 MB (was 0.8 MB; longer because the
reentry segment is appended).
@mayrang mayrang merged commit 88b9fd0 into main May 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.3: fieldsNeedingReentry — surface excludeFields after restore

1 participant