Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions hooks/use-form-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,51 @@

import { useEffect, useRef, useCallback } from 'react'

/** Drafts older than this (24h) are treated as expired and removed on read. */
const DRAFT_TTL_MS = 24 * 60 * 60 * 1000

/** Shape stored in localStorage under `flowstar_draft_<key>`. */
interface DraftEntry<T> {
data: T
/** `Date.now()` at save time, used for TTL expiry. */
savedAt: number
}

/**
* Auto-saves a form's value to localStorage and lets the caller restore it
* later.
*
* Draft-save contract:
* - While `enabled`, every change to `value` schedules a save 500ms later.
* Further changes within that window reset the timer (debounce).
* - Drafts are stored under `flowstar_draft_<key>` with a `savedAt`
* timestamp and expire after 24 hours.
* - Saving is best-effort. If `localStorage.setItem` throws (quota exceeded,
* private browsing, storage disabled), that draft is dropped and the hook
* keeps working. `onSaveError` is called once per failure streak, not on
* every failed save, and is re-armed after the next successful save
* (#676). If `onSaveError` is omitted, failures stay silent.
* - `restore()` passes the stored draft to `onChange` and skips the
* auto-save that the resulting `value` change would otherwise trigger.
* - `enabled = false` only turns off auto-save. `loadDraft`, `restore`, and
* `discard` still work.
*
* Pass a stable (memoized) `onSaveError`. A new function on every render
* re-runs the save effect on every render.
*
* @param key - Unique draft name for this form; prefixed with `flowstar_draft_`.
* @param value - Current form value to auto-save. Must be JSON-serializable.
* @param onChange - Called with the draft data by `restore()`.
* @param enabled - Whether auto-save is active. Defaults to `true`.
* @param onSaveError - Optional callback for when saving starts failing.
* @returns
* - `loadDraft()`: the stored `{ data, savedAt }`, or `null` if there is no
* draft or it is expired or unreadable. Expired drafts are removed.
* - `restore()`: applies the stored draft via `onChange`; does nothing if
* there is none.
* - `discard()`: deletes the stored draft. Unlike the other methods it does
* not catch storage errors.
*/
export function useFormDraft<T>(
key: string,
value: T,
Expand Down Expand Up @@ -88,6 +126,14 @@ export function useFormDraft<T>(
return { loadDraft, restore, discard }
}

/**
* Removes every draft under `prefix` that is past the 24-hour TTL or can't be
* parsed. Safe to call when localStorage is unavailable, because all errors
* are swallowed. Intended to run once on mount of a form that uses
* {@link useFormDraft}.
*
* @param prefix - Key prefix to scan. Defaults to the prefix `useFormDraft` uses.
*/
export function clearExpiredDrafts(prefix = 'flowstar_draft_') {
try {
const keysToRemove: string[] = []
Expand Down