use-form-draft 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +437 -0
- package/dist/chunk-HAW5XIQM.js +159 -0
- package/dist/chunk-HAW5XIQM.js.map +1 -0
- package/dist/index.cjs +313 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +78 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.js +156 -0
- package/dist/index.js.map +1 -0
- package/dist/rhf.cjs +178 -0
- package/dist/rhf.cjs.map +1 -0
- package/dist/rhf.d.cts +23 -0
- package/dist/rhf.d.ts +23 -0
- package/dist/rhf.js +22 -0
- package/dist/rhf.js.map +1 -0
- package/dist/useFormDraft-BFyNp_2I.d.cts +83 -0
- package/dist/useFormDraft-BFyNp_2I.d.ts +83 -0
- package/package.json +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maaz046
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# use-form-draft
|
|
2
|
+
|
|
3
|
+
> Auto-save React form drafts to `localStorage` and restore them on mount — with an optional recovery banner. Works with plain `useState`, React Hook Form, Formik, or anything that has a value and a setter.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
Long forms get abandoned mid-fill — a switched tab, a browser crash, an accidental refresh. `use-form-draft` is the small, tested helper that quietly persists what the user has typed and offers it back when they return.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
> [!IMPORTANT]
|
|
15
|
+
> **Status — pre-release (`v0.1.0`).** The library is feature-complete and covered by a substantial test suite, but it is **not yet published to npm**. Until the first release is cut, install it [from source](#local-development). The `npm install` line below is how you'll add it once it's published. There are no other distribution channels yet — if you find it on a registry under this name before an official release, it isn't from this project.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Contents
|
|
20
|
+
|
|
21
|
+
- [Why it exists](#why-it-exists)
|
|
22
|
+
- [Features](#features)
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Quick start](#quick-start-plain-usestate)
|
|
25
|
+
- [How it works](#how-it-works)
|
|
26
|
+
- [Recipes](#recipes)
|
|
27
|
+
- [React Hook Form](#react-hook-form)
|
|
28
|
+
- [Formik](#formik)
|
|
29
|
+
- [Headless banner](#headless-banner)
|
|
30
|
+
- [Excluding sensitive fields](#excluding-sensitive-fields)
|
|
31
|
+
- [Schema versioning](#schema-versioning)
|
|
32
|
+
- [Expiry (TTL)](#expiry-ttl)
|
|
33
|
+
- [Theming the banner](#theming-the-banner)
|
|
34
|
+
- [Internationalisation](#internationalisation)
|
|
35
|
+
- [API reference](#api-reference)
|
|
36
|
+
- [Behaviour & guarantees](#behaviour--guarantees)
|
|
37
|
+
- [Limitations (the v0.1 contract)](#limitations-the-v01-contract)
|
|
38
|
+
- [Roadmap](#roadmap)
|
|
39
|
+
- [How it compares](#how-it-compares)
|
|
40
|
+
- [FAQ](#faq)
|
|
41
|
+
- [Local development](#local-development)
|
|
42
|
+
- [Contributing](#contributing)
|
|
43
|
+
- [License](#license)
|
|
44
|
+
|
|
45
|
+
## Why it exists
|
|
46
|
+
|
|
47
|
+
Every team eventually writes its own "debounce the form state into `localStorage` and read it back" helper. It looks trivial, then the edge cases arrive:
|
|
48
|
+
|
|
49
|
+
- It writes the **initial empty state** over a good saved draft on first paint.
|
|
50
|
+
- It double-writes under React 18 **StrictMode**.
|
|
51
|
+
- It crashes the whole form when `localStorage` is **full or disabled** (private browsing).
|
|
52
|
+
- It re-persists on **identity-only re-renders** (the React Hook Form `watch()` pattern), thrashing storage.
|
|
53
|
+
- It hydrates a **stale draft into a changed schema** and throws.
|
|
54
|
+
- It keeps a **poisoned draft** that crash-loops on every remount.
|
|
55
|
+
|
|
56
|
+
`use-form-draft` is that helper with all of those handled and tested, behind a small API. No global store, no provider, no opinion about your form library.
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
- **Library-agnostic.** Anything with a value and a setter: `useState`, React Hook Form, Formik, or your own reducer.
|
|
61
|
+
- **Tiny.** ~2 kB min+gzip core, **zero runtime dependencies** beyond React. The React Hook Form adapter ships as a separate `use-form-draft/rhf` entry, so you only pay for it if you import it.
|
|
62
|
+
- **Debounced writes**, with a no-op when the persisted JSON hasn't actually changed.
|
|
63
|
+
- **Restore on mount** with a `savedAt` timestamp for "restored 3 minutes ago" messaging.
|
|
64
|
+
- **TTL expiry** — drafts older than N days are silently discarded on read.
|
|
65
|
+
- **Schema versioning** — bump a number to invalidate incompatible old drafts instead of crashing on them.
|
|
66
|
+
- **Sensitive-field exclusion** — strip passwords / CVVs before anything touches storage.
|
|
67
|
+
- **Recovery UI, your choice** — a themeable `<DraftBanner>`, a headless `useDraftBanner` hook, or nothing at all.
|
|
68
|
+
- **SSR- and StrictMode-safe** — verified by `react-dom/server` and double-mount tests, not just guards.
|
|
69
|
+
- **Fully typed**, ESM + CJS, with bundled `.d.ts`.
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
> Not on npm yet — see [Status](#use-form-draft) above. Once published:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install use-form-draft
|
|
77
|
+
# or
|
|
78
|
+
pnpm add use-form-draft
|
|
79
|
+
# or
|
|
80
|
+
yarn add use-form-draft
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`react >= 17` is a peer dependency. `react-hook-form >= 7` is an **optional** peer — only needed if you import the `use-form-draft/rhf` entry.
|
|
84
|
+
|
|
85
|
+
Until the first release, install [from source](#local-development).
|
|
86
|
+
|
|
87
|
+
## Quick start (plain `useState`)
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { useState } from 'react';
|
|
91
|
+
import { useFormDraft, DraftBanner } from 'use-form-draft';
|
|
92
|
+
|
|
93
|
+
interface TenderInput {
|
|
94
|
+
title: string;
|
|
95
|
+
qty: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function NewTenderForm() {
|
|
99
|
+
const [form, setForm] = useState<TenderInput>({ title: '', qty: 0 });
|
|
100
|
+
const [saving, setSaving] = useState(false);
|
|
101
|
+
|
|
102
|
+
const draft = useFormDraft<TenderInput>(
|
|
103
|
+
'draft:tender:create', // stable storage key
|
|
104
|
+
form, // state that drives the write
|
|
105
|
+
setForm, // hydrate: called once if a draft is found
|
|
106
|
+
{ disabled: saving }, // pause writes during submit
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
async function submit() {
|
|
110
|
+
setSaving(true);
|
|
111
|
+
await api.createTender(form);
|
|
112
|
+
draft.clear(); // remove the stored draft on success
|
|
113
|
+
setSaving(false);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<form onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
|
118
|
+
<DraftBanner savedAt={draft.savedAt} onDiscard={draft.clear} />
|
|
119
|
+
|
|
120
|
+
<input
|
|
121
|
+
value={form.title}
|
|
122
|
+
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
|
123
|
+
/>
|
|
124
|
+
<input
|
|
125
|
+
type="number"
|
|
126
|
+
value={form.qty}
|
|
127
|
+
onChange={(e) => setForm({ ...form, qty: Number(e.target.value) })}
|
|
128
|
+
/>
|
|
129
|
+
<button disabled={saving}>Submit</button>
|
|
130
|
+
</form>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Close the tab, come back, and the form is restored with the banner reading *"Draft restored 3 minutes ago"*. On a successful submit, `draft.clear()` removes the stored draft so the next visit starts clean.
|
|
136
|
+
|
|
137
|
+
## How it works
|
|
138
|
+
|
|
139
|
+
The hook does three things, and nothing else:
|
|
140
|
+
|
|
141
|
+
1. **On mount** it reads the key once. If a draft exists, is the right schema `version`, and is younger than `ttlDays`, it calls your `hydrate` callback with the stored state and exposes `savedAt` / `hadFile`. If anything is wrong (missing, corrupt, expired, wrong version), it stays quiet.
|
|
142
|
+
2. **While mounted** it watches `state`. When the *persistable* JSON changes, it schedules a debounced write (default 400 ms). If the JSON is identical to what was last written — including the initial render and identity-only re-renders — it writes nothing.
|
|
143
|
+
3. **On `clear()`** it deletes the key, cancels any pending write, and resets its internal "last written" marker so a follow-up state reset (e.g. `setForm(empty)` after submit) doesn't immediately re-persist.
|
|
144
|
+
|
|
145
|
+
Everything is keyed off the *content* of `state`, serialised with `JSON.stringify`. If a value can't be serialised (a `BigInt`, a circular reference, a throwing `toJSON`), that write is skipped silently rather than throwing into your form.
|
|
146
|
+
|
|
147
|
+
## Recipes
|
|
148
|
+
|
|
149
|
+
### React Hook Form
|
|
150
|
+
|
|
151
|
+
Use the dedicated adapter from `use-form-draft/rhf`. It wires `form.watch()` for change tracking and `form.reset()` for hydration, and automatically pauses writes while `formState.isSubmitting` is true.
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
import { useForm } from 'react-hook-form';
|
|
155
|
+
import { DraftBanner } from 'use-form-draft';
|
|
156
|
+
import { useFormDraftRHF } from 'use-form-draft/rhf';
|
|
157
|
+
|
|
158
|
+
function NewTenderForm() {
|
|
159
|
+
const form = useForm<TenderInput>({ defaultValues: { title: '', qty: 0 } });
|
|
160
|
+
const draft = useFormDraftRHF(form, { key: 'draft:tender:create' });
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<form onSubmit={form.handleSubmit(submit)}>
|
|
164
|
+
<DraftBanner savedAt={draft.savedAt} onDiscard={draft.clear} />
|
|
165
|
+
<input {...form.register('title')} />
|
|
166
|
+
<input type="number" {...form.register('qty', { valueAsNumber: true })} />
|
|
167
|
+
<button>Submit</button>
|
|
168
|
+
</form>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`useFieldArray` (dynamic rows) round-trips correctly across reloads — it's covered by a test.
|
|
174
|
+
|
|
175
|
+
### Formik
|
|
176
|
+
|
|
177
|
+
There's no Formik-specific adapter — you don't need one. Feed Formik's `values` as the state and its `setValues` as the hydrate:
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
import { useFormik } from 'formik';
|
|
181
|
+
import { useFormDraft, DraftBanner } from 'use-form-draft';
|
|
182
|
+
|
|
183
|
+
function NewTenderForm() {
|
|
184
|
+
const formik = useFormik<TenderInput>({
|
|
185
|
+
initialValues: { title: '', qty: 0 },
|
|
186
|
+
onSubmit: async (values, helpers) => {
|
|
187
|
+
await api.createTender(values);
|
|
188
|
+
draft.clear();
|
|
189
|
+
helpers.setSubmitting(false);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const draft = useFormDraft<TenderInput>(
|
|
194
|
+
'draft:tender:create',
|
|
195
|
+
formik.values,
|
|
196
|
+
(saved) => formik.setValues(saved),
|
|
197
|
+
{ disabled: formik.isSubmitting },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<form onSubmit={formik.handleSubmit}>
|
|
202
|
+
<DraftBanner savedAt={draft.savedAt} onDiscard={draft.clear} />
|
|
203
|
+
<input name="title" value={formik.values.title} onChange={formik.handleChange} />
|
|
204
|
+
<input name="qty" type="number" value={formik.values.qty} onChange={formik.handleChange} />
|
|
205
|
+
<button type="submit">Submit</button>
|
|
206
|
+
</form>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Headless banner
|
|
212
|
+
|
|
213
|
+
If the bundled `<DraftBanner>` doesn't fit your design system, drive your own UI with `useDraftBanner`. It owns the visibility lifecycle (auto-hide, re-show on a fresh restore) and formats the relative time for you:
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
import { useDraftBanner } from 'use-form-draft';
|
|
217
|
+
|
|
218
|
+
const banner = useDraftBanner({ savedAt: draft.savedAt, autoHideMs: 8000 });
|
|
219
|
+
|
|
220
|
+
return banner.visible ? (
|
|
221
|
+
<MyToast onDismiss={banner.dismiss}>
|
|
222
|
+
Draft restored {banner.relativeTime}
|
|
223
|
+
</MyToast>
|
|
224
|
+
) : null;
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Excluding sensitive fields
|
|
228
|
+
|
|
229
|
+
Strip secrets before anything is written. Listed keys never touch `localStorage`:
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
useFormDraft('draft:payment', state, hydrate, {
|
|
233
|
+
exclude: ['cvv', 'cardNumber'],
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
> [!NOTE]
|
|
238
|
+
> **Type caveat (v0.1).** The `hydrate` callback's parameter is still typed as the full `T`, but at runtime the excluded keys arrive as `undefined`. Treat `draft.cvv` as `string | undefined` inside your hydrate. A precisely-typed signature (`hydrate: (draft: Omit<T, ExcludedKeys>) => void`) is planned for v0.2.
|
|
239
|
+
|
|
240
|
+
### Schema versioning
|
|
241
|
+
|
|
242
|
+
When your form's shape changes in a way old drafts can't satisfy, bump `version`. Stale drafts are discarded instead of hydrating into the new shape and crashing:
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
// v1: { title: string }
|
|
246
|
+
// v2: { title: { en: string; ar: string } }
|
|
247
|
+
useFormDraft('draft:tender', state, hydrate, { version: 2 });
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Backwards-compatible additions (a new optional field) don't need a bump.
|
|
251
|
+
|
|
252
|
+
### Expiry (TTL)
|
|
253
|
+
|
|
254
|
+
Drafts older than `ttlDays` (default **30**) are treated as absent on read and cleaned up:
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
useFormDraft('draft:tender', state, hydrate, { ttlDays: 7 });
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Theming the banner
|
|
261
|
+
|
|
262
|
+
`<DraftBanner>` is styled with inline defaults that read from CSS custom properties. Set them anywhere in your CSS to retheme every banner at once:
|
|
263
|
+
|
|
264
|
+
```css
|
|
265
|
+
:root {
|
|
266
|
+
--ufd-banner-bg: #fffbeb; /* background (default) */
|
|
267
|
+
--ufd-banner-border: #f59e0b; /* left accent bar (default) */
|
|
268
|
+
--ufd-banner-text: #374151; /* message text (default) */
|
|
269
|
+
--ufd-banner-muted: #9ca3af; /* buttons / hints (default) */
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
For one-off overrides, pass `className` or `style` — both are merged onto the outer container.
|
|
274
|
+
|
|
275
|
+
### Internationalisation
|
|
276
|
+
|
|
277
|
+
Pass `locale` (drives `Intl.RelativeTimeFormat`) and `messages` to fully localise the banner:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
<DraftBanner
|
|
281
|
+
savedAt={draft.savedAt}
|
|
282
|
+
onDiscard={draft.clear}
|
|
283
|
+
locale="ar"
|
|
284
|
+
messages={{ restored: 'تم استرجاع المسودة', discard: 'تجاهل', dismiss: 'إغلاق' }}
|
|
285
|
+
/>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## API reference
|
|
289
|
+
|
|
290
|
+
### `useFormDraft<T>(key, state, hydrate, options?)`
|
|
291
|
+
|
|
292
|
+
| Param | Type | Notes |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| `key` | `string` | Stable storage key. Convention: `draft:<scope>:<qualifier>`. Must be stable for the component's lifetime — see [Limitations](#limitations-the-v01-contract). |
|
|
295
|
+
| `state` | `T` | Your form state. Drives the debounced write. |
|
|
296
|
+
| `hydrate` | `(draft: T) => void` | Called **once** on mount if a valid draft is found. Wire it to your setter. |
|
|
297
|
+
| `options.disabled` | `boolean` | Pause writes (use during submit). Default `false`. |
|
|
298
|
+
| `options.skipRestore` | `boolean` | Skip the restore-on-mount step. Default `false`. |
|
|
299
|
+
| `options.ttlDays` | `number` | Drafts older than this are discarded on read. Default `30`. |
|
|
300
|
+
| `options.version` | `number` | Schema version. Bump to invalidate old drafts. Default `1`. |
|
|
301
|
+
| `options.exclude` | `ReadonlyArray<keyof T>` | Keys stripped before persisting (passwords, CVVs). |
|
|
302
|
+
| `options.hasFile` | `boolean` | Stored as a flag so the banner can prompt re-attach. File *content* is never persisted. Default `false`. |
|
|
303
|
+
| `options.debounceMs` | `number` | Write debounce window. Default `400`. |
|
|
304
|
+
|
|
305
|
+
**Returns** `UseFormDraftReturn`:
|
|
306
|
+
|
|
307
|
+
| Field | Type | Notes |
|
|
308
|
+
|---|---|---|
|
|
309
|
+
| `restored` | `boolean` | True if a draft was found and hydrated on mount. |
|
|
310
|
+
| `savedAt` | `Date \| null` | When the restored draft was last saved, else `null`. |
|
|
311
|
+
| `hadFile` | `boolean` | Whether the restored draft had a file flagged. |
|
|
312
|
+
| `clear` | `() => void` | Delete the persisted draft and reset hook state. Call on successful submit. |
|
|
313
|
+
|
|
314
|
+
### `<DraftBanner />`
|
|
315
|
+
|
|
316
|
+
| Prop | Type | Default | Notes |
|
|
317
|
+
|---|---|---|---|
|
|
318
|
+
| `savedAt` | `Date \| null` | — | From `useFormDraft().savedAt`. Renders nothing while `null`. |
|
|
319
|
+
| `onDiscard` | `() => void` | — | Wire to `useFormDraft().clear`. |
|
|
320
|
+
| `hadFile` | `boolean` | `false` | Appends a "re-attach file" hint when true. |
|
|
321
|
+
| `autoHideMs` | `number` | `10000` | ms before auto-hide. `0` disables it. The 10 s default follows WAI-ARIA live-region guidance — shorter is risky for screen-reader users. |
|
|
322
|
+
| `escDismiss` | `boolean` | `false` | Adds a **document-level** Escape listener. Leave off if your app has modals that handle Esc themselves, or the banner will be dismissed alongside them. |
|
|
323
|
+
| `locale` | `string` | `'en'` | For `Intl.RelativeTimeFormat`. |
|
|
324
|
+
| `messages` | `object` | — | `{ restored, reattach, discard, dismiss }` string overrides. |
|
|
325
|
+
| `closeIcon` | `ReactNode` | `'×'` | Override the close glyph. |
|
|
326
|
+
| `className` | `string` | — | Applied to the outer container. |
|
|
327
|
+
| `style` | `CSSProperties` | — | Merged onto the outer container. |
|
|
328
|
+
|
|
329
|
+
The banner is a `role="status"` live region. It is intentionally a status message, not a full alert dialog (see [Roadmap](#roadmap)).
|
|
330
|
+
|
|
331
|
+
### `useDraftBanner(options)`
|
|
332
|
+
|
|
333
|
+
Headless banner state. `options`: `{ savedAt: Date | null; autoHideMs?: number; locale?: string }`.
|
|
334
|
+
|
|
335
|
+
**Returns** `{ visible: boolean; dismiss: () => void; relativeTime: string | null }`. `relativeTime` is `null` until after mount (SSR-safe) and whenever `savedAt` is `null`.
|
|
336
|
+
|
|
337
|
+
### `useFormDraftRHF(form, options)` — `use-form-draft/rhf`
|
|
338
|
+
|
|
339
|
+
React Hook Form adapter. `form` is a `UseFormReturn<T>`. `options` takes every `useFormDraft` option **except** `disabled`, plus a **required** `key`. It supplies `state` and `hydrate` automatically and disables writes while `formState.isSubmitting` is true (pass `disabled` to override). Returns the same `UseFormDraftReturn`.
|
|
340
|
+
|
|
341
|
+
### `DraftPayload<T>`
|
|
342
|
+
|
|
343
|
+
The exported shape of what's stored in `localStorage`: `{ version: number; savedAt: string; hadFile: boolean; state: T }` (`savedAt` is an ISO string on disk; the hook hands you a `Date`).
|
|
344
|
+
|
|
345
|
+
## Behaviour & guarantees
|
|
346
|
+
|
|
347
|
+
These are all covered by tests, not just intentions:
|
|
348
|
+
|
|
349
|
+
- **`localStorage` unavailable** (private browsing, quota exceeded, disabled): every read/write is a silent no-op. The host form never crashes.
|
|
350
|
+
- **Corrupted JSON** in storage: discarded as if absent.
|
|
351
|
+
- **Hydrate throws:** the draft is deleted so a remount doesn't keep crashing on it.
|
|
352
|
+
- **React 18 StrictMode:** the intentional double-mount does **not** write the initial state over a good draft.
|
|
353
|
+
- **SSR (Next.js, Remix):** `useFormDraft` renders without touching `window`; `<DraftBanner>` returns `null` on the server; `useDraftBanner` returns `relativeTime: null` until after mount — no hydration mismatch.
|
|
354
|
+
- **Rapid input:** writes are debounced.
|
|
355
|
+
- **Identity-only re-renders:** if `state` is a new object each render but its persistable JSON is unchanged (the RHF `watch()` pattern), nothing is written. Writes fire only when the JSON actually differs.
|
|
356
|
+
- **Non-serialisable state** (`BigInt`, circular refs, throwing `toJSON`): that write is skipped silently.
|
|
357
|
+
|
|
358
|
+
## Limitations (the v0.1 contract)
|
|
359
|
+
|
|
360
|
+
Known and deliberate for this version — call them out so you don't get surprised:
|
|
361
|
+
|
|
362
|
+
- **The `key` must be stable for the component's lifetime.** Changing it while mounted has two failure modes: (1) the new key's existing draft is **not** restored (the restore effect runs once, on mount); (2) a pending debounced write for the old key still lands on the old key. If your key depends on a route param or entity id, unmount + remount the component instead — e.g. `<Form key={id} />` so React swaps the instance. Native key-change handling is planned for v0.2.
|
|
363
|
+
- **Same-key concurrency is last-write-wins.** Two components mounted with the same `key` at once will race. The typical pattern — one form open per key — is safe.
|
|
364
|
+
- **No cross-tab / cross-instance coordination** yet. Editing the same draft in two tabs is last-write-wins.
|
|
365
|
+
- **`localStorage` only.** No `sessionStorage` or IndexedDB adapter yet.
|
|
366
|
+
- **The `exclude` type caveat** described [above](#excluding-sensitive-fields).
|
|
367
|
+
|
|
368
|
+
## Roadmap
|
|
369
|
+
|
|
370
|
+
Not in v0.1, planned:
|
|
371
|
+
|
|
372
|
+
- Cross-tab / cross-instance coordination via `BroadcastChannel` or the `storage` event.
|
|
373
|
+
- Native `key`-change handling.
|
|
374
|
+
- `sessionStorage` and IndexedDB storage adapters (large drafts, file metadata, rich text).
|
|
375
|
+
- Precisely-typed `exclude` (`Omit<T, ExcludedKeys>` in the hydrate signature).
|
|
376
|
+
- Banner: focus management and ARIA alert escalation.
|
|
377
|
+
- Optional encryption at rest.
|
|
378
|
+
|
|
379
|
+
## How it compares
|
|
380
|
+
|
|
381
|
+
An honest sketch — feature columns and last-release dates were checked against `npm view <pkg>` and each package's README at the time of writing. Pick the one that fits; none is universally right.
|
|
382
|
+
|
|
383
|
+
| Package | Form-lib-agnostic | Bundled recovery UI | Server autosave | Storage |
|
|
384
|
+
|---|:-:|:-:|:-:|---|
|
|
385
|
+
| **use-form-draft** | ✅ | ✅ banner + headless hook | ❌ | localStorage |
|
|
386
|
+
| `react-hook-form-persist` | ❌ RHF only | ❌ | ❌ | local / session |
|
|
387
|
+
| `react-hook-form-autosave` | ❌ RHF only | ❌ | ✅ | server |
|
|
388
|
+
| `@ryanflorence/persist-form` | ✅ vanilla HTML form | ❌ | ❌ | sessionStorage |
|
|
389
|
+
| `@zippers/savior` | ✅ framework-free | ❌ | ❌ | local / session |
|
|
390
|
+
| `form-snapshots` | ✅ | ❌ snapshot history | ❌ | IndexedDB (Dexie) |
|
|
391
|
+
|
|
392
|
+
**When to pick something else:** if you only use React Hook Form and don't need a banner, `react-hook-form-persist` does the persistence job in fewer bytes. If you want autosave to a *server* (not a local draft), use `react-hook-form-autosave`. If you need snapshot history with undo, look at `form-snapshots`. If you're working with vanilla DOM forms (no framework), `@zippers/savior` is purpose-built for that.
|
|
393
|
+
|
|
394
|
+
`use-form-draft` targets the *closed-the-tab-and-came-back* recovery flow specifically, with a React-idiomatic API, a bundled banner UX, and support for any form library.
|
|
395
|
+
|
|
396
|
+
## FAQ
|
|
397
|
+
|
|
398
|
+
**Where is the draft stored?** In `window.localStorage`, under the `key` you pass, as a JSON [`DraftPayload`](#draftpayloadt). Nothing leaves the browser.
|
|
399
|
+
|
|
400
|
+
**Is it safe for passwords / card numbers?** Use [`exclude`](#excluding-sensitive-fields) to strip them before they're written. There's no encryption at rest yet (it's on the [roadmap](#roadmap)) — don't rely on `localStorage` for secrets you wouldn't want readable by other scripts on the origin.
|
|
401
|
+
|
|
402
|
+
**Does it autosave to my backend?** No — it's a *local* draft, not server autosave. For server autosave, see [`react-hook-form-autosave`](#how-it-compares).
|
|
403
|
+
|
|
404
|
+
**Does it work outside React?** No. The core is a React hook.
|
|
405
|
+
|
|
406
|
+
**Does it support uncontrolled inputs?** It persists whatever state you hand it. For React Hook Form (largely uncontrolled), use the [adapter](#react-hook-form), which reads via `watch()`.
|
|
407
|
+
|
|
408
|
+
**Will it write my empty initial form over a saved draft?** No — that's one of the specific cases it's built and tested to avoid.
|
|
409
|
+
|
|
410
|
+
## Local development
|
|
411
|
+
|
|
412
|
+
This is the supported way to run it until the first npm release.
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
git clone https://github.com/Maaz046/use-form-draft.git
|
|
416
|
+
cd use-form-draft
|
|
417
|
+
npm install
|
|
418
|
+
|
|
419
|
+
npm test # run the test suite (vitest)
|
|
420
|
+
npm run typecheck # tsc --noEmit
|
|
421
|
+
npm run build # bundle ESM + CJS + .d.ts with tsup
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
To use it in another local project before it's published, build it and install the folder (or `npm pack` it and install the resulting tarball):
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
npm run build
|
|
428
|
+
npm install /absolute/path/to/use-form-draft
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## Contributing
|
|
432
|
+
|
|
433
|
+
Issues and PRs are welcome. Please run `npm run typecheck && npm test && npm run build` before opening a PR — CI runs all three across Node 18, 20, and 22.
|
|
434
|
+
|
|
435
|
+
## License
|
|
436
|
+
|
|
437
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
// src/useFormDraft.ts
|
|
4
|
+
function resolveStorage(custom) {
|
|
5
|
+
if (custom) return custom;
|
|
6
|
+
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
|
7
|
+
return window.localStorage;
|
|
8
|
+
}
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
function safeGet(storage, key, ttlDays, version) {
|
|
12
|
+
if (!storage) return null;
|
|
13
|
+
try {
|
|
14
|
+
const raw = storage.getItem(key);
|
|
15
|
+
if (!raw) return null;
|
|
16
|
+
const p = JSON.parse(raw);
|
|
17
|
+
if (p.version !== version) return null;
|
|
18
|
+
const ageMs = Date.now() - new Date(p.savedAt).getTime();
|
|
19
|
+
if (ageMs > ttlDays * 864e5) return null;
|
|
20
|
+
return p;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function safeSet(storage, key, state, hadFile, version) {
|
|
26
|
+
if (!storage) return;
|
|
27
|
+
try {
|
|
28
|
+
const p = {
|
|
29
|
+
version,
|
|
30
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31
|
+
hadFile,
|
|
32
|
+
state
|
|
33
|
+
};
|
|
34
|
+
storage.setItem(key, JSON.stringify(p));
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function safeDel(storage, key) {
|
|
39
|
+
if (!storage) return;
|
|
40
|
+
try {
|
|
41
|
+
storage.removeItem(key);
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function stripExcluded(state, exclude) {
|
|
46
|
+
if (!exclude || exclude.length === 0) return state;
|
|
47
|
+
if (state === null || typeof state !== "object") return state;
|
|
48
|
+
const copy = { ...state };
|
|
49
|
+
for (const key of exclude) {
|
|
50
|
+
delete copy[key];
|
|
51
|
+
}
|
|
52
|
+
return copy;
|
|
53
|
+
}
|
|
54
|
+
function safeStringify(payload) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.stringify(payload);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function useFormDraft(key, state, hydrate, options) {
|
|
62
|
+
const ttlDays = options?.ttlDays ?? 30;
|
|
63
|
+
const disabled = options?.disabled ?? false;
|
|
64
|
+
const hasFile = options?.hasFile ?? false;
|
|
65
|
+
const skipRestore = options?.skipRestore ?? false;
|
|
66
|
+
const version = options?.version ?? 1;
|
|
67
|
+
const exclude = options?.exclude;
|
|
68
|
+
const debounceMs = options?.debounceMs ?? 400;
|
|
69
|
+
const crossTab = options?.crossTab ?? false;
|
|
70
|
+
const storage = resolveStorage(options?.storage);
|
|
71
|
+
const [restored, setRestored] = useState(false);
|
|
72
|
+
const [savedAt, setSavedAt] = useState(null);
|
|
73
|
+
const [hadFile, setHadFile] = useState(false);
|
|
74
|
+
const hydrateRef = useRef(hydrate);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
hydrateRef.current = hydrate;
|
|
77
|
+
});
|
|
78
|
+
const storageRef = useRef(storage);
|
|
79
|
+
storageRef.current = storage;
|
|
80
|
+
const readOptsRef = useRef({ ttlDays, version, exclude });
|
|
81
|
+
readOptsRef.current = { ttlDays, version, exclude };
|
|
82
|
+
const lastWrittenJsonRef = useRef(null);
|
|
83
|
+
if (lastWrittenJsonRef.current === null) {
|
|
84
|
+
lastWrittenJsonRef.current = safeStringify(stripExcluded(state, exclude));
|
|
85
|
+
}
|
|
86
|
+
const applyDraft = useCallback((draft) => {
|
|
87
|
+
hydrateRef.current(draft.state);
|
|
88
|
+
setRestored(true);
|
|
89
|
+
setSavedAt(new Date(draft.savedAt));
|
|
90
|
+
setHadFile(draft.hadFile);
|
|
91
|
+
lastWrittenJsonRef.current = safeStringify(
|
|
92
|
+
stripExcluded(draft.state, readOptsRef.current.exclude)
|
|
93
|
+
);
|
|
94
|
+
}, []);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (skipRestore) return;
|
|
97
|
+
const draft = safeGet(storage, key, ttlDays, version);
|
|
98
|
+
if (!draft) return;
|
|
99
|
+
try {
|
|
100
|
+
applyDraft(draft);
|
|
101
|
+
} catch {
|
|
102
|
+
safeDel(storage, key);
|
|
103
|
+
}
|
|
104
|
+
}, []);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!crossTab || typeof window === "undefined") return;
|
|
107
|
+
const onStorage = (e) => {
|
|
108
|
+
if (e.key !== key) return;
|
|
109
|
+
const { ttlDays: ttl, version: ver } = readOptsRef.current;
|
|
110
|
+
if (e.newValue === null) {
|
|
111
|
+
setRestored(false);
|
|
112
|
+
setSavedAt(null);
|
|
113
|
+
setHadFile(false);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const draft = safeGet(storageRef.current, key, ttl, ver);
|
|
117
|
+
if (!draft) return;
|
|
118
|
+
try {
|
|
119
|
+
applyDraft(draft);
|
|
120
|
+
} catch {
|
|
121
|
+
safeDel(storageRef.current, key);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
window.addEventListener("storage", onStorage);
|
|
125
|
+
return () => window.removeEventListener("storage", onStorage);
|
|
126
|
+
}, [crossTab, key]);
|
|
127
|
+
const timerRef = useRef(null);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (disabled) return;
|
|
130
|
+
const payload = stripExcluded(state, exclude);
|
|
131
|
+
const json = safeStringify(payload);
|
|
132
|
+
if (json === null) return;
|
|
133
|
+
if (json === lastWrittenJsonRef.current) return;
|
|
134
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
135
|
+
timerRef.current = setTimeout(() => {
|
|
136
|
+
safeSet(storage, key, payload, hasFile, version);
|
|
137
|
+
lastWrittenJsonRef.current = json;
|
|
138
|
+
}, debounceMs);
|
|
139
|
+
return () => {
|
|
140
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
141
|
+
};
|
|
142
|
+
}, [state, disabled]);
|
|
143
|
+
const clear = useCallback(() => {
|
|
144
|
+
safeDel(storageRef.current, key);
|
|
145
|
+
setRestored(false);
|
|
146
|
+
setSavedAt(null);
|
|
147
|
+
setHadFile(false);
|
|
148
|
+
if (timerRef.current) {
|
|
149
|
+
clearTimeout(timerRef.current);
|
|
150
|
+
timerRef.current = null;
|
|
151
|
+
}
|
|
152
|
+
lastWrittenJsonRef.current = null;
|
|
153
|
+
}, [key]);
|
|
154
|
+
return { restored, savedAt, hadFile, clear };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { useFormDraft };
|
|
158
|
+
//# sourceMappingURL=chunk-HAW5XIQM.js.map
|
|
159
|
+
//# sourceMappingURL=chunk-HAW5XIQM.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/useFormDraft.ts"],"names":[],"mappings":"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","file":"chunk-HAW5XIQM.js","sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\n/**\n * Minimal synchronous storage interface. The Web Storage API\n * (`window.localStorage` / `window.sessionStorage`) satisfies it as-is, and\n * you can supply your own adapter (in-memory, encrypted, namespaced, …) as\n * long as it stays synchronous.\n */\nexport interface DraftStorage {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n removeItem(key: string): void;\n}\n\n/** Resolve the storage backend: an explicit adapter, else localStorage, else null (SSR). */\nfunction resolveStorage(custom?: DraftStorage): DraftStorage | null {\n if (custom) return custom;\n if (typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') {\n return window.localStorage;\n }\n return null;\n}\n\nexport interface DraftPayload<T> {\n version: number;\n savedAt: string;\n hadFile: boolean;\n state: T;\n}\n\nexport interface UseFormDraftOptions<T> {\n /** Disables writes while true. Set to true during submit so the in-flight payload isn't persisted. */\n disabled?: boolean;\n /** Skip the restore-on-mount step. Useful for one-shot dismissals. */\n skipRestore?: boolean;\n /** Drafts older than this are discarded silently on read. Default 30. */\n ttlDays?: number;\n /** Whether the form currently has a file attached. Stored as a flag so the banner can prompt re-attach. */\n hasFile?: boolean;\n /**\n * Schema version. Bump this when your state shape changes incompatibly — old drafts will be discarded\n * instead of hydrating into the new shape and crashing. Default 1.\n */\n version?: number;\n /** Keys to strip from state before persisting (passwords, CVVs, one-time tokens). */\n exclude?: ReadonlyArray<keyof T>;\n /** Debounce window in ms for writes. Default 400. */\n debounceMs?: number;\n /**\n * Where to persist. Defaults to `window.localStorage`. Pass `window.sessionStorage` for\n * tab-scoped drafts, or any object implementing {@link DraftStorage}. Must be synchronous —\n * async stores (IndexedDB) aren't supported by this interface yet.\n */\n storage?: DraftStorage;\n /**\n * Keep this instance in sync with edits made in other tabs of the same origin. When true, a\n * draft saved in another tab is restored into this one via the `storage` event. Default false.\n *\n * Only meaningful with `localStorage` (the default) — the `storage` event does not fire for\n * `sessionStorage` (tab-scoped) or for custom adapters. Syncing is last-write-wins, so a remote\n * save can overwrite what the user is currently editing here; opt in deliberately.\n */\n crossTab?: boolean;\n}\n\nexport interface UseFormDraftReturn {\n /** True if a draft was found and successfully hydrated on mount. */\n restored: boolean;\n /** When the restored draft was last saved, or null if no draft was restored. */\n savedAt: Date | null;\n /** Whether the restored draft had a file attached (file content itself is never persisted). */\n hadFile: boolean;\n /** Remove the persisted draft and reset hook state. Call on successful submit. */\n clear: () => void;\n}\n\nfunction safeGet<T>(\n storage: DraftStorage | null,\n key: string,\n ttlDays: number,\n version: number,\n): DraftPayload<T> | null {\n if (!storage) return null;\n try {\n const raw = storage.getItem(key);\n if (!raw) return null;\n const p = JSON.parse(raw) as DraftPayload<T>;\n if (p.version !== version) return null;\n const ageMs = Date.now() - new Date(p.savedAt).getTime();\n if (ageMs > ttlDays * 86_400_000) return null;\n return p;\n } catch {\n return null;\n }\n}\n\nfunction safeSet<T>(\n storage: DraftStorage | null,\n key: string,\n state: T,\n hadFile: boolean,\n version: number,\n): void {\n if (!storage) return;\n try {\n const p: DraftPayload<T> = {\n version,\n savedAt: new Date().toISOString(),\n hadFile,\n state,\n };\n storage.setItem(key, JSON.stringify(p));\n } catch {\n /* quota exceeded / private browsing / disabled */\n }\n}\n\nfunction safeDel(storage: DraftStorage | null, key: string): void {\n if (!storage) return;\n try {\n storage.removeItem(key);\n } catch {\n /* ignore */\n }\n}\n\ntype Persistable<T, E extends ReadonlyArray<keyof T>> = E extends ReadonlyArray<never>\n ? T\n : Omit<T, E[number]>;\n\nfunction stripExcluded<T, E extends ReadonlyArray<keyof T>>(\n state: T,\n exclude: E | undefined,\n): Persistable<T, E> {\n if (!exclude || exclude.length === 0) return state as unknown as Persistable<T, E>;\n if (state === null || typeof state !== 'object') return state as unknown as Persistable<T, E>;\n const copy = { ...(state as Record<string, unknown>) };\n for (const key of exclude) {\n delete copy[key as string];\n }\n return copy as unknown as Persistable<T, E>;\n}\n\n/**\n * Stringify safely. Returns null if the payload contains a non-serializable\n * value (BigInt, circular reference, throwing toJSON, Symbol). Callers treat\n * null as \"skip this write\" — we'd rather silently no-op than crash the form.\n */\nfunction safeStringify(payload: unknown): string | null {\n try {\n return JSON.stringify(payload);\n } catch {\n return null;\n }\n}\n\n/**\n * Auto-saves form state to a synchronous store (localStorage by default) with a debounced write,\n * and restores it on mount.\n *\n * @param key Storage key. **Must be stable for the component's lifetime in v0.1.** Changing\n * the key while mounted has two failure modes: (1) the new key's existing draft\n * is NOT restored (the restore effect runs once on mount); (2) any pending\n * debounced write for the old key still writes to the old key. If you need a key\n * that depends on a route param or entity id, unmount + remount the component\n * with the new key. Key-change handling is planned for a later release.\n * Two components mounting with the same key concurrently will race; last write\n * wins. For cross-tab coordination, see the `crossTab` option.\n * @param state The form state to persist. Re-runs the write check whenever this changes.\n * Writes only fire when the persisted JSON actually differs from the last write —\n * parent re-renders with unchanged values are no-ops.\n * @param hydrate Called once on mount if a valid draft is found (and again on cross-tab updates\n * when `crossTab` is enabled). Wire it to your form's setter.\n * @param options See {@link UseFormDraftOptions}.\n */\nexport function useFormDraft<T>(\n key: string,\n state: T,\n hydrate: (draft: T) => void,\n options?: UseFormDraftOptions<T>,\n): UseFormDraftReturn {\n const ttlDays = options?.ttlDays ?? 30;\n const disabled = options?.disabled ?? false;\n const hasFile = options?.hasFile ?? false;\n const skipRestore = options?.skipRestore ?? false;\n const version = options?.version ?? 1;\n const exclude = options?.exclude;\n const debounceMs = options?.debounceMs ?? 400;\n const crossTab = options?.crossTab ?? false;\n\n const storage = resolveStorage(options?.storage);\n\n const [restored, setRestored] = useState(false);\n const [savedAt, setSavedAt] = useState<Date | null>(null);\n const [hadFile, setHadFile] = useState(false);\n\n const hydrateRef = useRef(hydrate);\n useEffect(() => {\n hydrateRef.current = hydrate;\n });\n\n // Keep the resolved storage and read-time options reachable from event-listener\n // closures (the cross-tab effect) without re-subscribing on every render.\n const storageRef = useRef(storage);\n storageRef.current = storage;\n const readOptsRef = useRef({ ttlDays, version, exclude });\n readOptsRef.current = { ttlDays, version, exclude };\n\n // Seed once with the initial persistable JSON — so the very first effect run\n // (and StrictMode's double-mount) sees \"no change vs initial\" and writes nothing.\n // Also prevents writes on parent re-renders where `state` is a new reference\n // but its JSON is identical (e.g. RHF's form.watch() snapshot).\n // safeStringify returns null on non-serializable input; we leave the ref null\n // in that case and the write effect will also no-op (also returning null).\n const lastWrittenJsonRef = useRef<string | null>(null);\n if (lastWrittenJsonRef.current === null) {\n lastWrittenJsonRef.current = safeStringify(stripExcluded(state, exclude));\n }\n\n // Apply a freshly-read payload into hook state + the host form. Shared by the\n // mount-restore effect and the cross-tab listener.\n const applyDraft = useCallback((draft: DraftPayload<T>) => {\n hydrateRef.current(draft.state);\n setRestored(true);\n setSavedAt(new Date(draft.savedAt));\n setHadFile(draft.hadFile);\n // Seed lastWritten so the post-hydrate render doesn't re-persist what we just restored.\n lastWrittenJsonRef.current = safeStringify(\n stripExcluded(draft.state, readOptsRef.current.exclude),\n );\n }, []);\n\n // Restore on mount\n useEffect(() => {\n if (skipRestore) return;\n const draft = safeGet<T>(storage, key, ttlDays, version);\n if (!draft) return;\n try {\n applyDraft(draft);\n } catch {\n safeDel(storage, key);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Cross-tab sync: another tab saving (or clearing) this key fires a `storage`\n // event here. We re-read through safeGet so version/ttl validation still applies.\n useEffect(() => {\n if (!crossTab || typeof window === 'undefined') return;\n const onStorage = (e: StorageEvent) => {\n if (e.key !== key) return;\n const { ttlDays: ttl, version: ver } = readOptsRef.current;\n if (e.newValue === null) {\n // Another tab cleared the draft. Don't clobber what the user is typing here;\n // just drop our restored badge so stale \"restored N ago\" UI goes away.\n setRestored(false);\n setSavedAt(null);\n setHadFile(false);\n return;\n }\n const draft = safeGet<T>(storageRef.current, key, ttl, ver);\n if (!draft) return;\n try {\n applyDraft(draft);\n } catch {\n safeDel(storageRef.current, key);\n }\n };\n window.addEventListener('storage', onStorage);\n return () => window.removeEventListener('storage', onStorage);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [crossTab, key]);\n\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n // Debounced write — only when the persistable JSON actually changes.\n useEffect(() => {\n if (disabled) return;\n const payload = stripExcluded(state, exclude);\n const json = safeStringify(payload);\n // Non-serializable payload (BigInt, circular ref, throwing toJSON): skip silently.\n // We never want a write to crash the form.\n if (json === null) return;\n if (json === lastWrittenJsonRef.current) return;\n\n if (timerRef.current) clearTimeout(timerRef.current);\n timerRef.current = setTimeout(() => {\n safeSet(storage, key, payload, hasFile, version);\n lastWrittenJsonRef.current = json;\n }, debounceMs);\n\n return () => {\n if (timerRef.current) clearTimeout(timerRef.current);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [state, disabled]);\n\n const clear = useCallback(() => {\n safeDel(storageRef.current, key);\n setRestored(false);\n setSavedAt(null);\n setHadFile(false);\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n // Re-seed on next render so a subsequent state reset (e.g. setForm(empty) on submit)\n // doesn't compare against a stale pre-clear payload and re-persist the reset state.\n lastWrittenJsonRef.current = null;\n }, [key]);\n\n return { restored, savedAt, hadFile, clear };\n}\n"]}
|