{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-cookie",
  "title": "useCookie",
  "description": "A cookie-as-state hook with typed attributes, cross-instance sync, an SSR-safe default snapshot, and writes that refuse instead of silently vanishing over the 4 KB cap.",
  "files": [
    {
      "path": "src/registry/hooks/use-cookie.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * Custom event used to sync instances within one tab. Cookies have **no** native\n * change event (Chromium's CookieStore is the one exception, subscribed to below),\n * so every `useCookie` on the same key has to tell the others: whoever writes\n * broadcasts once and every subscriber re-reads the jar. Without it two instances\n * each remember only their own write and disagree on screen.\n */\nconst SYNC_EVENT = \"zyeon:cookie-sync\"\n\n/**\n * The real ceiling for one cookie: every browser sits around 4096 bytes, and going\n * over **throws nothing** — `document.cookie = ...` drops it silently and you read\n * back the old value. So this hook turns it into an explicit refusal instead of\n * letting the caller believe the write landed.\n */\nconst DEFAULT_MAX_BYTES = 4096\n\n/** The past instant used for deletion. A module constant, not a clock read during render — always \"Thu, 01 Jan 1970 00:00:00 GMT\". */\nconst EPOCH = new Date(0)\n\nexport type CookieSameSite = \"strict\" | \"lax\" | \"none\"\n\nexport interface CookieAttributes {\n  /** `Path`. Default `\"/\"` — without it the browser scopes the cookie to the current directory. */\n  path?: string\n  /** `Domain`, e.g. `\".example.com\"` to share it with subdomains. Omitted by default (host-only). */\n  domain?: string\n  /** `Expires`. A `Date`, or a number of **days** resolved against `Date.now()` at write time. */\n  expires?: Date | number\n  /** `Max-Age` in seconds. Takes precedence over `expires` per RFC 6265 when both are present. */\n  maxAge?: number\n  /** `SameSite`. `\"none\"` implies `Secure` — it is added for you, or the browser drops the write. */\n  sameSite?: CookieSameSite\n  /** `Secure`: only sent over HTTPS. Ignored by browsers on plain-http origins other than localhost. */\n  secure?: boolean\n}\n\nexport interface UseCookieOptions extends CookieAttributes {\n  /**\n   * Value reported while there is no cookie — and the snapshot used for server\n   * rendering and hydration, where `document.cookie` does not exist. Captured\n   * once on mount, like `useState`'s initial argument. Default `null`.\n   *\n   * In an SSR framework, read the cookie on the server and pass it here: the\n   * first client paint then matches the server HTML instead of flashing.\n   */\n  defaultValue?: string | null\n  /** Byte budget for the whole serialized cookie. Default 4096. */\n  maxBytes?: number\n}\n\n/** Why a write did not happen (or did not survive). */\nexport type CookieRefusal = \"cookies-disabled\" | \"too-large\" | \"rejected\"\n\nexport type CookieWriteResult =\n  | { ok: true; bytes: number }\n  | { ok: false; reason: CookieRefusal; bytes: number; limit: number }\n\nexport interface UseCookieResult {\n  /** Decoded cookie value, or `defaultValue` (default `null`) when the cookie is absent. */\n  value: string | null\n  /** Write the cookie. Returns whether it actually landed — never throws. */\n  set: (\n    next: string | ((prev: string | null) => string),\n    attributes?: CookieAttributes,\n  ) => CookieWriteResult\n  /** Delete the cookie. `path`/`domain` must match the ones it was written with. */\n  remove: (attributes?: Pick<CookieAttributes, \"path\" | \"domain\">) => void\n  /** Re-read the jar and notify every instance of this key — after a response set the cookie, say. */\n  refresh: () => void\n}\n\ninterface CookieSyncDetail {\n  key: string\n}\n\nconst SAME_SITE_LABEL: Record<CookieSameSite, string> = {\n  strict: \"Strict\",\n  lax: \"Lax\",\n  none: \"None\",\n}\n\n/**\n * Chromium's CookieStore: the only API that dispatches an event when a cookie\n * changes, including changes from **another tab or a server `Set-Cookie`**. It is\n * not reliably typed in lib.dom, so narrow it to the two methods used here and\n * feature-detect at runtime — elsewhere this degrades to \"only this page's writes\n * sync\", which is exactly why `refresh()` exists.\n */\ninterface CookieChangeTarget {\n  addEventListener: (type: \"change\", listener: () => void) => void\n  removeEventListener: (type: \"change\", listener: () => void) => void\n}\n\nfunction getCookieStore(): CookieChangeTarget | null {\n  const candidate = (globalThis as unknown as { cookieStore?: CookieChangeTarget }).cookieStore\n  if (!candidate || typeof candidate.addEventListener !== \"function\") return null\n  return candidate\n}\n\n/** `decodeURIComponent` throws URIError on a half-written percent sequence (`\"%\"`, `\"%zz\"`) — common in hand-edited cookies. */\nfunction safeDecode(raw: string): string {\n  try {\n    return decodeURIComponent(raw)\n  } catch {\n    return raw\n  }\n}\n\nfunction readCookie(key: string): string | null {\n  if (typeof document === \"undefined\") return null\n  const jar = document.cookie\n  if (!jar) return null\n  const encodedKey = encodeURIComponent(key)\n  for (const entry of jar.split(\";\")) {\n    const pair = entry.trim()\n    // \"=\" is legal inside a value (base64 padding is exactly that), so split on the first one only.\n    const separator = pair.indexOf(\"=\")\n    if (separator === -1) continue\n    if (pair.slice(0, separator) !== encodedKey) continue\n    return safeDecode(pair.slice(separator + 1))\n  }\n  return null\n}\n\n/** The budget is in **bytes**, not characters: one CJK character costs 9 bytes once percent-encoded. */\nfunction byteLength(input: string): number {\n  if (typeof TextEncoder === \"undefined\") return input.length\n  return new TextEncoder().encode(input).length\n}\n\nfunction serializeCookie(key: string, value: string, attributes: CookieAttributes): string {\n  const { path = \"/\", domain, expires, maxAge, sameSite, secure } = attributes\n  const parts = [`${encodeURIComponent(key)}=${encodeURIComponent(value)}`, `Path=${path}`]\n  if (domain) parts.push(`Domain=${domain}`)\n  if (maxAge !== undefined) parts.push(`Max-Age=${Math.floor(maxAge)}`)\n  if (expires !== undefined) {\n    // a number means days, resolved at the moment **this write** happens (inside the handler), never from a clock read during render.\n    const at = typeof expires === \"number\" ? new Date(Date.now() + expires * 86_400_000) : expires\n    parts.push(`Expires=${at.toUTCString()}`)\n  }\n  if (sameSite) parts.push(`SameSite=${SAME_SITE_LABEL[sameSite]}`)\n  // SameSite=None without Secure gets the whole cookie rejected — add it rather than leave a silent failure.\n  if (secure || sameSite === \"none\") parts.push(\"Secure\")\n  return parts.join(\"; \")\n}\n\n/**\n * Would the write be readable back **at the current address**? Only then is the\n * read-back check meaningful: a cookie written to `/admin` or another Domain is\n * invisible here by design, so treating \"can't read it\" as failure is a false alarm.\n */\nfunction isVisibleHere(attributes: CookieAttributes): boolean {\n  if (attributes.domain) return false\n  const path = attributes.path ?? \"/\"\n  const here = window.location.pathname\n  return here === path || here.startsWith(path.endsWith(\"/\") ? path : `${path}/`)\n}\n\nfunction broadcast(key: string) {\n  window.dispatchEvent(new CustomEvent<CookieSyncDetail>(SYNC_EVENT, { detail: { key } }))\n}\n\nfunction warn(message: string) {\n  if (process.env.NODE_ENV === \"production\") return\n  console.warn(`useCookie: ${message}`)\n}\n\n/**\n * Read and write one cookie as state: `value` is the decoded string (or\n * `defaultValue` when absent), `set` writes with attributes, `remove` deletes,\n * `refresh` re-reads by hand, and every instance of the key stays in agreement.\n *\n * - **SSR-safe**: the server and the hydrating first frame both render\n *   `defaultValue`, and `useSyncExternalStore` only switches to the real jar after\n *   mount — no extra `useEffect`, so no flash from a setState inside one. In\n *   Next.js, pass what the server's `cookies()` read and the first frame is right.\n * - **Snapshots are stable for free**: the value is always a string or `null`, and\n *   `Object.is` compares by value, so unlike a JSON-backed store `getSnapshot`\n *   needs no parse cache — re-parsing on every read cannot drag\n *   `useSyncExternalStore` into an infinite re-render.\n * - **Cross-instance sync**: every write broadcasts a custom event, and on Chromium\n *   a `cookieStore` `change` listener stacks on top, catching changes from other\n *   tabs and from a `Set-Cookie` response header. Elsewhere that ability does not\n *   exist — call `refresh()` yourself at those moments.\n * - **Writes can be refused**: over the byte budget (4096 by default), cookies\n *   disabled in the browser, or not readable back at the current address (typically\n *   an `HttpOnly` cookie of the same name, or `Secure` on an http origin) all give\n *   `{ ok: false, reason }` from `set`, with the **old value untouched**.\n * - **`HttpOnly` is invisible**: that is the definition of this API, not a gap in\n *   the hook — `document.cookie` never lists them, and a same-name write is ignored\n *   rather than overwriting. Keep session credentials server-side; this hook only\n *   covers the half the client can read and write.\n */\nexport function useCookie(key: string, options: UseCookieOptions = {}): UseCookieResult {\n  const { defaultValue = null, maxBytes = DEFAULT_MAX_BYTES, ...attributes } = options\n\n  // `defaultValue` behaves like `useState`'s initial argument: captured once on mount.\n  // It is also what `getServerSnapshot` returns, and React needs that referentially\n  // stable across calls — a ref gives that for free.\n  const defaultRef = React.useRef(defaultValue)\n\n  // latest-ref: the attributes object is usually a literal written at the call site,\n  // so it is a new identity every render. In a dependency array it would re-create\n  // `set`/`remove` each render; reading it from the ref at call time keeps `set`'s\n  // identity tied to `key` alone, safe for dependency arrays and memoized children.\n  const latest = React.useRef({ attributes, maxBytes })\n  // an insertion effect is React's earliest commit-phase hook (before layout effects,\n  // before paint), so any handler firing after this commit sees the new attributes;\n  // writing a ref during render is not allowed.\n  React.useInsertionEffect(() => {\n    latest.current = { attributes, maxBytes }\n  })\n\n  const subscribe = React.useCallback(\n    (onStoreChange: () => void) => {\n      const handleSync = (event: Event) => {\n        if ((event as CustomEvent<CookieSyncDetail>).detail?.key === key) onStoreChange()\n      }\n      window.addEventListener(SYNC_EVENT, handleSync)\n      // CookieStore's change event does not name the key, so just re-read; if the value\n      // is unchanged React's `Object.is` on the strings costs no extra render.\n      const store = getCookieStore()\n      store?.addEventListener(\"change\", onStoreChange)\n      return () => {\n        window.removeEventListener(SYNC_EVENT, handleSync)\n        store?.removeEventListener(\"change\", onStoreChange)\n      }\n    },\n    [key],\n  )\n\n  // note the `??`: an empty string is a legal cookie value and must not be replaced by `defaultValue`.\n  const getSnapshot = React.useCallback(() => readCookie(key) ?? defaultRef.current, [key])\n  const getServerSnapshot = React.useCallback(() => defaultRef.current, [])\n\n  const value = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)\n\n  const set = React.useCallback(\n    (\n      next: string | ((prev: string | null) => string),\n      overrides?: CookieAttributes,\n    ): CookieWriteResult => {\n      const { attributes: base, maxBytes: limit } = latest.current\n      // the updater gets **the jar's value right now**, not the frame captured in the\n      // closure — two writes in one tick, or a write another instance just made, both\n      // build on the latest value.\n      const resolved =\n        typeof next === \"function\" ? next(readCookie(key) ?? defaultRef.current) : next\n      const merged = { ...base, ...overrides }\n      const serialized = serializeCookie(key, resolved, merged)\n      const bytes = byteLength(serialized)\n\n      if (typeof document === \"undefined\" || !navigator.cookieEnabled) {\n        warn(`cookies are unavailable in this context; \"${key}\" was not written.`)\n        return { ok: false, reason: \"cookies-disabled\", bytes, limit }\n      }\n      if (bytes > limit) {\n        warn(\n          `refusing to write \"${key}\": ${bytes} bytes exceeds the ${limit}-byte budget. ` +\n            \"Browsers drop an oversized cookie silently, so the previous value is kept instead.\",\n        )\n        return { ok: false, reason: \"too-large\", bytes, limit }\n      }\n\n      document.cookie = serialized\n      broadcast(key)\n\n      // read-back check, only where the cookie should be visible here — anywhere else it\n      // false-alarms. Not readable back = the browser refused it (an HttpOnly cookie of the\n      // same name, Secure on an http origin, or shadowed by a same-name cookie on a deeper Path).\n      if (isVisibleHere(merged) && readCookie(key) !== resolved) {\n        warn(\n          `wrote \"${key}\" but the browser did not keep it. Common causes: an HttpOnly cookie of ` +\n            \"the same name, `secure: true` on a non-HTTPS origin, or a same-name cookie on a deeper Path.\",\n        )\n        return { ok: false, reason: \"rejected\", bytes, limit }\n      }\n      return { ok: true, bytes }\n    },\n    [key],\n  )\n\n  const remove = React.useCallback(\n    (overrides?: Pick<CookieAttributes, \"path\" | \"domain\">) => {\n      if (typeof document === \"undefined\") return\n      // deleting is just a write that is already expired, and Path/Domain must match the\n      // ones it was written with or the browser treats it as a different cookie and the\n      // delete silently does nothing. Send both Max-Age and Expires to cover old browsers\n      // that honour only one.\n      document.cookie = serializeCookie(key, \"\", {\n        ...latest.current.attributes,\n        ...overrides,\n        maxAge: 0,\n        expires: EPOCH,\n      })\n      broadcast(key)\n    },\n    [key],\n  )\n\n  const refresh = React.useCallback(() => {\n    if (typeof window === \"undefined\") return\n    broadcast(key)\n  }, [key])\n\n  return { value, set, remove, refresh }\n}\n\nexport default useCookie\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}