{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "draft-restore",
  "title": "Draft Restore",
  "description": "The \"you have an unsent draft — restore it?\" notice that belongs above a form, and the localStorage bookkeeping behind it: it keeps what is being typed, offers it back on the next visit, and — the part that is actually hard — throws it away at the right moment. Reach for it wherever work exists only in the browser until somebody presses send: a comment, reply or review box, a contact or support form, a bug report, a job application, an article, post or newsletter draft, a profile or settings page, an invoice or expense claim, a multi-step checkout or onboarding form, a CMS entry, a message composer, a feedback or survey form, and any textarea somebody can close by accident. Common asks it answers: \"save form data to localStorage react\", \"restore unsaved form react\", \"draft autosave react\", \"recover draft after refresh\", \"persist form state on reload\", \"restore form after accidental close\", \"react-hook-form persist localStorage\", \"formik autosave localStorage\", \"save textarea to localStorage\", \"you have an unsaved draft\", \"restore draft prompt\", \"draft recovery react\", \"useLocalStorage form draft\", \"autosave draft before submit\", \"clear draft after submit\", \"stale draft keeps coming back\", \"draft reappears after submitting\", \"text disappears on refresh\", \"localStorage is not defined next.js\", \"next.js localStorage hydration mismatch\", \"hydration failed because the initial UI does not match\", \"debounce localStorage writes\", \"save draft when tab closes\", \"beforeunload does not fire on mobile\", \"visibilitychange save\", \"two tabs same form localStorage\", \"下書き 自動保存 react\", \"リロードで入力が消える\", \"下書きを復元しますか\", \"localStorage 下書き 保存\", \"送信後に下書きが残る\", \"ハイドレーションエラー localStorage\", \"タブを閉じても入力を残したい\", \"入力途中の復元\". Official shadcn/ui has nothing here, and not in the way a missing component is usually missing — it ships no persistence of any kind. Measured across the whole registry today (65 items listed, 62 fetchable, code-block, questionnaire and trace indexed and 404 on both style tracks, 223,287 bytes of files[].content concatenated): localStorage, sessionStorage, draft, restore, recover, autosave, beforeunload, discard and expire are every one of them a zero hit. Its form, field and textarea hold a value while the page is open and let go of it when the page does. The reason to install this rather than write the two obvious lines — setItem in an onChange, getItem in a useState initialiser — is that those two lines are wrong in four separate places, and three of them only surface after you ship. The first is when to throw the draft away, and it is the one that turns a helpful feature into a haunting. A draft that outlives a successful submit comes back as a restore prompt for work that was sent days ago, and the person reading it cannot tell that prompt from a real one. Clearing on submit is the obvious answer and it is not sufficient by itself, because the clear sits on the one path everybody forgets: the happy one. So most of it is derived here instead. A stored draft that matches what the form is currently showing is deleted rather than offered, on the grounds that a draft identical to reality is not a draft — which for an edit form is the entire problem solved, because you edit an article, save it, come back, and the server hands over the very text the draft holds, so it goes quietly without anybody having remembered anything. For a create form it is still clear() that does it, and that limit is stated rather than hidden: a comment box that resets to empty after sending looks exactly like a comment box somebody emptied by hand, and nothing in here can tell those two apart. maxAge bounds what a forgotten clear() can cost — a draft past its age is deleted on sight rather than offered, seven days by default — and version does the same for a form whose shape has changed, so an old draft is dropped instead of restored into fields that have since moved. The clear also cancels a save already on its way, which is the same bug in miniature: a submit that lands inside the debounce window would otherwise have the timer write the draft back a few hundred milliseconds after it was cleared. The second is hydration. getItem in a useState initialiser runs on the server too, where there is no localStorage, and the client's first render then disagrees with the HTML it is hydrating. Nothing is read during render here: pending is null until an effect has run, so the server and the first client render agree and the notice arrives a frame later rather than flashing for every visitor who has no draft at all. The third is the key, which is a privacy question wearing a naming question's clothes. A key of just \"draft\" is shared by every form on the origin; a key per form but not per account hands the next person to sign in on a shared machine the last one's unsent text. So the id is required and scope is there for the user id, and a draft stored under one scope is neither offered to nor deleted by another. The fourth is the write itself. A setItem per keystroke is a synchronous main-thread write per keystroke, so writes are debounced — and a debounce with nothing flushing it loses whatever was typed inside the last window, which is exactly the moment somebody closes the tab. Both pagehide and visibilitychange flush immediately, because a phone backgrounds a tab rather than unloading it and may never fire pagehide at all; beforeunload, which is what gets reached for first, is the one that does not help here. A fifth was not foreseen and came out of testing it, so it is written down rather than smoothed over: the debounce has to be keyed on the draft's serialised content, not on the value prop. value={{ title, body }} is a new object on every render and a controlled form re-renders on every keystroke, so keyed on identity the timer is restarted by the very typing it is waiting for, and a whole-form draft is never written once. Keyed on content, a re-render that changed nothing leaves the pending save alone. What you get: useDraft() for the persistence, returning pending, restore(), clear(), flush() and the storageKey, and DraftRestore for the notice, which takes the hook's result and renders nothing visible when there is no draft, so it can be left mounted above the form rather than conditionally rendered. It takes a notice rather than a dialog on purpose: arriving at a page with an old draft on it is not an emergency, and a modal would make somebody answer a question about work they may not even remember before they can read their own form. The live region is mounted whether or not there is a draft and only its text comes and goes, because a region that mounts with its message already inside it is registered too late to announce that message — and this notice always appears after mount, so conditional rendering would mean it is never announced at all. The timestamp and the buttons sit outside that region: inside it, the ticking relative time would have the page announce \"saved 3 minutes ago\" every minute for as long as the tab is open. It composes time-ago for that timestamp rather than freezing a string that goes stale while somebody reads it, every label is overridable for i18n, and all of it is shadcn tokens so it follows light and dark. Honest limits: two tabs on one form share a key and the last write wins, which is not reconciled here — what is handled is the case that misleads, the other tab submitting, since a storage event removing the key takes this tab's offer down rather than leaving it asking about work already sent. localStorage holds strings, so the value must survive JSON — a File, a Map or a Date comes back as something else, and a value JSON cannot carry at all is skipped rather than throwing. It is also not encrypted and not a backup: a draft lives in one browser profile on one device, and a private window throws it away on close. Anything that must not be lost belongs on a server, with this as the thing that covers the gap between keystrokes and saves. Distinct from its neighbours here: unsaved-changes-guard interrupts the departure and stores nothing, so it protects work on the way out while this one is what makes it survive having left — the two are complementary and good together on the same form; save-status reports the fate of a background write to a server and owns no copy of the data; autosize-textarea and char-counter are about the field rather than its contents outliving the page; announcement-bar also persists to localStorage but only remembers a dismissal, which is a preference rather than somebody's writing; and idle-timeout coordinates tabs over a session rather than over a draft.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://pulld.pages.dev/r/time-ago.json"
  ],
  "files": [
    {
      "path": "registry/ui/draft-restore.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { FileClock } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { TimeAgo } from \"@/registry/ui/time-ago\"\n\nconst STORAGE_PREFIX = \"pulld-draft:\"\nconst DEFAULT_MAX_AGE = 7 * 24 * 60 * 60 * 1000\nconst DEFAULT_DELAY = 500\n\n/** A draft that was found in storage and is worth offering back. */\nexport interface DraftRecord<T> {\n  /** The value that was saved. */\n  value: T\n  /** When it was saved, as epoch milliseconds. */\n  savedAt: number\n}\n\nexport interface UseDraftOptions<T> {\n  /**\n   * Stable id for this form. Part of the storage key, so two forms on the same\n   * origin must not share one — `\"issue-comment\"`, `\"profile\"`, `\"invoice-new\"`.\n   */\n  id: string\n  /** The form's current value: a string for one field, an object for a whole form. */\n  value: T\n  /**\n   * What the form arrived showing — an edit form's saved copy, or `\"\"` for a new\n   * one. Defaults to the first `value` seen, which is already right for both\n   * unless the form's data arrives from a fetch after mount; pass it then.\n   *\n   * A stored draft equal to this is deleted rather than offered, which is what\n   * makes a forgotten `clear()` after a successful save self-correcting.\n   */\n  baseline?: T\n  /**\n   * Bump when the form's shape changes so old drafts are dropped instead of\n   * restored into fields that moved or no longer exist.\n   */\n  version?: string | number\n  /**\n   * Added to the storage key. Pass the signed-in user's id on anything that can\n   * be used by more than one account: without it the next person to sign in on\n   * the same browser is offered the previous one's unsent text.\n   */\n  scope?: string\n  /** Drafts older than this are deleted rather than offered. Default 7 days. */\n  maxAge?: number\n  /** How long typing has to settle before a write. Default 500ms. */\n  delay?: number\n  /** Set false to stop saving entirely (an unauthenticated or read-only view). */\n  enabled?: boolean\n  /**\n   * Whether a value is worth keeping. The default treats `null`, `undefined`,\n   * a blank or whitespace-only string and an empty array as nothing to save.\n   */\n  isEmpty?: (value: T) => boolean\n  /** Called by `restore()` with the saved value, for you to put back in the form. */\n  onRestore?: (value: T) => void\n}\n\nexport interface Draft<T> {\n  /** The draft worth offering, or null when there is none. Always null until mounted. */\n  pending: DraftRecord<T> | null\n  /** Hands `pending.value` to `onRestore` and stands the offer down. */\n  restore: () => void\n  /**\n   * Forgets the draft: deletes it from storage and stands the offer down.\n   *\n   * Call it when a submit succeeds — this is the one thing the caller has to do,\n   * because a form that resets to empty after sending looks exactly like a form\n   * somebody emptied by hand, and nothing in here can tell those apart. It is\n   * also what the Discard button calls.\n   */\n  clear: () => void\n  /** Writes the current value now rather than after `delay`. */\n  flush: () => void\n  /** The localStorage key in use, exposed for tests and for clearing it yourself. */\n  storageKey: string\n}\n\ninterface StoredDraft {\n  v: unknown\n  at: number\n  ver: string\n}\n\nfunction defaultIsEmpty(value: unknown): boolean {\n  if (value === null || value === undefined) return true\n  if (typeof value === \"string\") return value.trim() === \"\"\n  if (Array.isArray(value)) return value.length === 0\n  return false\n}\n\n/**\n * JSON, or undefined for a value JSON cannot carry.\n *\n * `JSON.stringify` answers undefined for `undefined`, a function and a symbol,\n * and throws on a cycle or a bigint. Each of those means \"there is nothing to\n * store\", and they are all answered the same way here so the write path has one\n * guard instead of a try/catch at every call.\n */\nfunction toJson(value: unknown): string | undefined {\n  try {\n    const json = JSON.stringify(value)\n    return json === undefined ? undefined : json\n  } catch {\n    return undefined\n  }\n}\n\nfunction getStorage(): Storage | null {\n  try {\n    return typeof window === \"undefined\" ? null : window.localStorage\n  } catch {\n    // Blocked storage and some private modes throw on the property itself.\n    return null\n  }\n}\n\nfunction removeItem(store: Storage, key: string): void {\n  try {\n    store.removeItem(key)\n  } catch {\n    // Nothing to do about a storage that will not take a write.\n  }\n}\n\n/**\n * Keeps a form's work in localStorage while it is being typed, and offers it back\n * on the next visit.\n *\n * Nothing is read during render, so the server and the first client render agree\n * and `pending` is null until an effect has run — the offer appears a frame after\n * mount rather than flashing for somebody who has no draft.\n */\nexport function useDraft<T>({\n  id,\n  value,\n  baseline,\n  version,\n  scope,\n  maxAge = DEFAULT_MAX_AGE,\n  delay = DEFAULT_DELAY,\n  enabled = true,\n  isEmpty = defaultIsEmpty,\n  onRestore,\n}: UseDraftOptions<T>): Draft<T> {\n  const storageKey = STORAGE_PREFIX + (scope ? `${scope}:` : \"\") + id\n  const token = version === null || version === undefined ? \"1\" : String(version)\n\n  // The value the form arrived with, for callers that do not pass a baseline.\n  const mountValue = React.useRef(value)\n  const base = baseline === undefined ? mountValue.current : baseline\n  const baseJson = toJson(base)\n  // The value is compared and stored as JSON, so it is also what the debounce is\n  // keyed on. Keying it on the value itself works for a single field and fails for\n  // a whole form: `value={{ title, body }}` is a new object on every render, which\n  // restarts the timer on every render, so a form that re-renders while being typed\n  // into — which is every controlled form — would never reach the end of one delay\n  // and would never save anything at all.\n  const valueJson = toJson(value)\n\n  const [pending, setPending] = React.useState<DraftRecord<T> | null>(null)\n  const pendingRef = React.useRef(pending)\n  pendingRef.current = pending\n\n  // Whether the value has ever differed from the baseline during this mount. A\n  // draft is only pruned for \"the form is back to how it started\" once this is\n  // true, because on the very first commit the form is always back to how it\n  // started — pruning then would delete the draft that is being offered.\n  const dirtied = React.useRef(false)\n  // Whether the offer has been answered. Once it has, it is not raised again for\n  // the life of the mount, so a late-arriving baseline cannot resurrect it.\n  const answered = React.useRef(false)\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  // The latest value and the decision about it, for handlers that fire outside\n  // render — the pagehide flush reads these rather than a closed-over value.\n  const latest = React.useRef({ value, baseJson, isEmpty, enabled, storageKey, token })\n  latest.current = { value, baseJson, isEmpty, enabled, storageKey, token }\n\n  const write = React.useCallback(() => {\n    const { value: v, baseJson: bj, isEmpty: empty, enabled: on, storageKey: key, token: ver } =\n      latest.current\n    if (!on) return\n    const store = getStorage()\n    if (!store) return\n    const json = toJson(v)\n    if (json === undefined) return\n    if (empty(v) || json === bj) {\n      if (dirtied.current) removeItem(store, key)\n      return\n    }\n    try {\n      store.setItem(key, JSON.stringify({ v, at: Date.now(), ver } satisfies StoredDraft))\n    } catch {\n      // A full or blocked storage loses the draft and nothing else.\n    }\n  }, [])\n\n  const clear = React.useCallback(() => {\n    answered.current = true\n    dirtied.current = false\n    if (timer.current !== null) {\n      clearTimeout(timer.current)\n      timer.current = null\n    }\n    const store = getStorage()\n    if (store) removeItem(store, storageKey)\n    setPending(null)\n  }, [storageKey])\n\n  const restore = React.useCallback(() => {\n    answered.current = true\n    // Read through the ref rather than handing `setPending` an updater that calls\n    // `onRestore`: an updater has to be pure, and React is free to run it twice.\n    const current = pendingRef.current\n    if (current) onRestore?.(current.value)\n    setPending(null)\n  }, [onRestore])\n\n  // Look for a draft. Anything unreadable, from another version, past its age or\n  // equal to what the form already shows is deleted instead of offered.\n  React.useEffect(() => {\n    if (!enabled || answered.current) return\n    const store = getStorage()\n    if (!store) return\n    let raw: string | null = null\n    try {\n      raw = store.getItem(storageKey)\n    } catch {\n      return\n    }\n    if (raw === null) return\n\n    let record: StoredDraft | null = null\n    try {\n      const parsed: unknown = JSON.parse(raw)\n      if (parsed && typeof parsed === \"object\") record = parsed as StoredDraft\n    } catch {\n      record = null\n    }\n    if (\n      !record ||\n      typeof record.at !== \"number\" ||\n      record.ver !== token ||\n      Date.now() - record.at > maxAge ||\n      toJson(record.v) === baseJson\n    ) {\n      removeItem(store, storageKey)\n      // An offer already standing has to come down with it. A baseline that\n      // arrives from a fetch after mount reaches this branch for a draft that was\n      // being offered a moment ago, and leaving it up would ask about work that\n      // has just been deleted.\n      setPending(null)\n      return\n    }\n    // Answered with the same record rather than a fresh object, so an effect that\n    // re-runs — React double-invoking it, or a dependency that changed and landed\n    // on the same draft — does not re-render the form for nothing.\n    setPending((current) =>\n      current && current.savedAt === record.at && toJson(current.value) === toJson(record.v)\n        ? current\n        : { value: record.v as T, savedAt: record.at }\n    )\n  }, [storageKey, token, maxAge, baseJson, enabled])\n\n  // Save after typing settles.\n  //\n  // `isEmpty` is read through the ref rather than listed as a dependency: it is\n  // ordinarily written inline at the call site, so a new function arrives every\n  // render, and depending on it would restart the timer on every render instead\n  // of on every change — a form that re-renders for any other reason would then\n  // never reach the end of a single delay, and nothing would ever be saved.\n  React.useEffect(() => {\n    if (!enabled) return\n    if (valueJson !== undefined && !latest.current.isEmpty(value) && valueJson !== baseJson) {\n      dirtied.current = true\n    }\n    timer.current = setTimeout(write, delay)\n    return () => {\n      if (timer.current !== null) clearTimeout(timer.current)\n      timer.current = null\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- `value` through `valueJson`\n  }, [valueJson, baseJson, delay, enabled, write])\n\n  // A tab being closed or backgrounded mid-sentence would otherwise lose whatever\n  // was typed inside the last `delay`. `visibilitychange` is the one that fires on\n  // mobile, where a tab is backgrounded rather than unloaded and `pagehide` may\n  // never come.\n  React.useEffect(() => {\n    if (!enabled) return\n    const onPageHide = () => write()\n    const onVisibility = () => {\n      if (document.visibilityState === \"hidden\") write()\n    }\n    window.addEventListener(\"pagehide\", onPageHide)\n    document.addEventListener(\"visibilitychange\", onVisibility)\n    return () => {\n      window.removeEventListener(\"pagehide\", onPageHide)\n      document.removeEventListener(\"visibilitychange\", onVisibility)\n    }\n  }, [enabled, write])\n\n  // Two tabs on one form share a key and the last write wins. The case worth\n  // catching is the other tab submitting: it clears the draft, and an offer still\n  // standing here is for work that has already been sent.\n  React.useEffect(() => {\n    if (!enabled) return\n    const onStorage = (event: StorageEvent) => {\n      if (event.key !== null && event.key !== storageKey) return\n      if (event.newValue === null) setPending(null)\n    }\n    window.addEventListener(\"storage\", onStorage)\n    return () => window.removeEventListener(\"storage\", onStorage)\n  }, [storageKey, enabled])\n\n  return { pending, restore, clear, flush: write, storageKey }\n}\n\nexport interface DraftRestoreProps<T>\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The value returned by `useDraft`. */\n  draft: Draft<T>\n  /** Leading text before the timestamp. */\n  label?: string\n  restoreLabel?: string\n  discardLabel?: string\n  /** Called after the user restores, in addition to `useDraft`'s `onRestore`. */\n  onRestore?: () => void\n  /** Called after the user discards. */\n  onDiscard?: () => void\n}\n\n/**\n * The inline \"you have an unsent draft — restore it?\" notice for a form.\n *\n * A notice rather than a dialog: coming back to a page with an old draft on it is\n * not an emergency, and a modal would make somebody answer a question about work\n * they may not even remember before they can read their own form.\n *\n * The live region is mounted whether or not there is a draft, and only its text\n * comes and goes. A region that mounts with its message already inside it is\n * registered too late to announce that message, which is what rendering the whole\n * notice conditionally produces — and this notice always appears after mount, so\n * that would be every time. The buttons sit outside the region: their labels are\n * not news, and a screen reader is about to reach them anyway.\n */\nexport function DraftRestore<T>({\n  draft,\n  label = \"You have an unsent draft\",\n  restoreLabel = \"Restore\",\n  discardLabel = \"Discard\",\n  onRestore,\n  onDiscard,\n  className,\n  ...props\n}: DraftRestoreProps<T>) {\n  const { pending } = draft\n\n  function handleRestore() {\n    draft.restore()\n    onRestore?.()\n  }\n\n  function handleDiscard() {\n    draft.clear()\n    onDiscard?.()\n  }\n\n  return (\n    <div\n      className={cn(\n        // The caller's classes go on with the chrome, not instead of it. Spacing is\n        // the usual thing to pass here, and a `mb-4` left on while there is nothing\n        // to show would hold a gap open above every form that has no draft.\n        pending &&\n          \"flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-md border bg-muted/50 px-3 py-2 text-sm\",\n        pending && className\n      )}\n      {...props}\n    >\n      {pending ? (\n        <FileClock className=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n      ) : null}\n      <div\n        role=\"status\"\n        aria-live=\"polite\"\n        className={cn(\"min-w-0 flex-1 text-muted-foreground\", !pending && \"sr-only\")}\n      >\n        {pending ? (\n          <>\n            <span className=\"text-foreground\">{label}</span>{\" \"}\n            <TimeAgo date={pending.savedAt} className=\"text-inherit\" />\n          </>\n        ) : null}\n      </div>\n      {pending ? (\n        <span className=\"flex shrink-0 items-center gap-1\">\n          <button\n            type=\"button\"\n            onClick={handleRestore}\n            className=\"rounded-md px-2 py-1 font-medium text-foreground underline underline-offset-2 transition-colors hover:bg-foreground/5 hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            {restoreLabel}\n          </button>\n          <button\n            type=\"button\"\n            onClick={handleDiscard}\n            className=\"rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            {discardLabel}\n          </button>\n        </span>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
