{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-hash",
  "title": "useHash",
  "description": "An SSR-safe hook that reads and writes the URL hash as shareable UI state, writing through pushState/replaceState and broadcasting to every instance on the page.",
  "files": [
    {
      "path": "src/registry/hooks/use-hash.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * 写入 hash 之后,用来通知本文档里所有订阅者的事件名。\n *\n * **为什么必须有它**:`history.pushState` / `replaceState` 规范上**不触发**\n * `hashchange`,也不触发 `popstate`——只有\"真的导航到一个新 fragment\"(点锚点、\n * 前进后退、给 `location.hash` 赋值)才触发。而本 hook 刻意用 pushState 写入\n * (见 `setHash` 的注释),所以写完必须自己广播一次,否则连自己都收不到通知。\n *\n * **为什么挂在 `window` 而不是模块级订阅者集合**:这个 hook 是 `shadcn add`\n * 复制进消费者项目的一份源码,同一个页面里完全可能同时存在**两份互不相识的\n * 拷贝**(应用装了一份、某个内部组件包里又打包了一份)。模块级集合只能通知\n * 自己那份拷贝里的订阅者,两份拷贝之间会静默失联;`window` 是它们唯一共享的\n * 东西,事件名又是同一个字符串常量,于是跨拷贝天然互通。\n *\n * 快照本身不需要任何模块级缓存:store 就是 `location.hash`(浏览器持有的唯一\n * 事实源),快照是**字符串这种原始值**,`useSyncExternalStore` 要求的\"没变化\n * 时两次调用要相等\"由值相等天然满足,不像 `JSON.parse` 那样每次产出新引用。\n */\nconst HASH_WRITE_EVENT = \"zyeon:hash-write\"\n\n/** Navigation API(Chromium 系有,Safari/Firefox 未必)。用它兜住\"第三方代码\n *  直接调 pushState 改了 hash\"——那种写入不发 `hashchange`,只有 Navigation\n *  API 的 `currententrychange` 能看见。拿不到就降级,不是必需路径。 */\nfunction getNavigation(): EventTarget | undefined {\n  return (window as unknown as { navigation?: EventTarget }).navigation\n}\n\nfunction decodeFragment(fragment: string): string {\n  try {\n    return decodeURIComponent(fragment)\n  } catch {\n    // 畸形百分号序列(\"#100%\"、\"#%zz\")会让 decodeURIComponent 抛 URIError。\n    // hash 是用户可以手改的,解码失败必须退回原始字符串,永不抛。\n    return fragment\n  }\n}\n\nfunction encodeFragment(value: string): string {\n  try {\n    return encodeURIComponent(value)\n  } catch {\n    // 落单代理项(半个 emoji,常见于被粗暴截断的字符串)会让 encodeURIComponent\n    // 抛 URIError。退回原值交给 URL 序列化器处理,同样永不抛。\n    return value\n  }\n}\n\nexport interface UseHashOptions {\n  /**\n   * `false`(默认):读到的是**解码后、不含前导 `#`** 的值,写入时自动\n   * `encodeURIComponent`(值被当作不透明字符串,中文/空格/`#` 都能安全往返)。\n   *\n   * `true`:读写都按浏览器里那份原样字符串走——读到的**含前导 `#`**(没有\n   * fragment 时是 `\"\"`),写入时不做 `encodeURIComponent`(只去掉你自己带的\n   * 前导 `#`),百分号编码由你自己负责。适合你要按 `#/a/b`、`#k=v&k2=v2` 这类\n   * 结构自己解析的场景。\n   */\n  raw?: boolean\n}\n\nexport interface SetHashOptions {\n  /**\n   * `true` 用 `history.replaceState` 覆盖当前历史条目:不产生新条目,按后退会\n   * 直接离开当前页。适合\"高频、不值得留在历史里\"的状态(输入框、滑块)。\n   * 默认 `false`,用 `pushState` 追加一条,后退回到上一个 hash。\n   */\n  replace?: boolean\n}\n\nexport type SetHash = (next: string, options?: SetHashOptions) => void\n\n/**\n * 把 URL 的 hash 当作**可分享的 UI 状态**来读写:当前打开的标签页、展开的详情、\n * 滚动锚点——把它放进 hash,用户复制地址栏就能把这份界面状态发给别人,刷新和\n * 前进后退也都还在。\n *\n * - **`useSyncExternalStore` 订阅,不是 `useEffect` + `setState`**:store 是\n *   `location.hash`,`subscribe` 挂 `hashchange` + `popstate`(+ 本 hook 自己的\n *   写入广播),`getServerSnapshot` 返回 `\"\"`。**渲染期不读 `location`**,所以\n *   服务端渲染和客户端首帧一致,不会水合失配。\n * - **写入走 `history.pushState` / `replaceState`,不是给 `location.hash` 赋值**:\n *   赋值会让浏览器把页面强行滚到同名锚点(hash 里放标签页 id 时这是纯干扰),\n *   而且根本没有\"替换当前条目\"这个选项。代价是 pushState 不发 `hashchange`,\n *   所以写完要自己广播——见 `HASH_WRITE_EVENT` 的注释。\n * - **同页多个实例天然同步**:所有实例读的都是同一份 `location.hash`,任何一次\n *   写入都会经 `window` 广播给全部订阅者(包括另一份独立安装的拷贝)。\n * - **`setHash(\"\")` 得到干净 URL**:结果是 `pathname + search`,地址栏里连那个\n *   孤零零的 `#` 都不会留下。\n * - **重复写入会被跳过**:新 URL 与当前 URL 完全相同时直接返回,不入栈、不广播\n *   ——否则连点同一个标签页五次,用户要按五次后退才能离开。\n *\n * **诚实边界**:别的代码**直接**调用 `history.pushState` 改了 hash 时,浏览器\n * 不发任何事件(规范如此)。支持 Navigation API 的浏览器(Chromium 系)由\n * `currententrychange` 兜住;不支持的浏览器上,这类\"绕过本 hook 的写入\"要等到\n * 下一次 `hashchange` / `popstate` 才会被看到。\n */\nexport function useHash(options: UseHashOptions = {}): [string, SetHash] {\n  const { raw = false } = options\n\n  const subscribe = React.useCallback((onStoreChange: () => void) => {\n    if (typeof window === \"undefined\") return () => {}\n    // hashchange:点锚点 / 给 location.hash 赋值。popstate:前进后退。\n    // HASH_WRITE_EVENT:本 hook(或同页另一份拷贝)用 pushState 写入。\n    window.addEventListener(\"hashchange\", onStoreChange)\n    window.addEventListener(\"popstate\", onStoreChange)\n    window.addEventListener(HASH_WRITE_EVENT, onStoreChange)\n    const navigation = getNavigation()\n    navigation?.addEventListener(\"currententrychange\", onStoreChange)\n    return () => {\n      window.removeEventListener(\"hashchange\", onStoreChange)\n      window.removeEventListener(\"popstate\", onStoreChange)\n      window.removeEventListener(HASH_WRITE_EVENT, onStoreChange)\n      navigation?.removeEventListener(\"currententrychange\", onStoreChange)\n    }\n  }, [])\n\n  const getSnapshot = React.useCallback(() => {\n    const hash = window.location.hash // \"\" | \"#foo\";URL 末尾光一个 \"#\" 时也是 \"\"\n    return raw ? hash : decodeFragment(hash.slice(1))\n  }, [raw])\n\n  const getServerSnapshot = React.useCallback(() => \"\", [])\n\n  const hash = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)\n\n  const setHash = React.useCallback<SetHash>(\n    (next, setOptions = {}) => {\n      if (typeof window === \"undefined\") return\n      const { replace = false } = setOptions\n\n      const url = new URL(window.location.href)\n      // 空串会让 URL 序列化器把 fragment 整个置空(连 \"#\" 都不留),正好就是\n      // \"清空 hash 要得到干净 URL\"想要的结果。\n      url.hash = raw ? next.replace(/^#/, \"\") : encodeFragment(next)\n\n      // 比较序列化后的完整 URL,而不是比较入参:浏览器会把空格 / 非 ASCII 归一\n      // 成百分号编码,只比入参的话,raw 模式下重复写同一个中文值会次次入栈。\n      if (url.href === window.location.href) return\n\n      // 关键:不写 `location.hash = x`。那样会让浏览器滚到同名锚点(把标签页 id\n      // 放进 hash 时纯属干扰),而且没有 replace 语义。\n      // 这里传 `null` 而不是当前的 `history.state`:Next.js App Router 给\n      // pushState 打过补丁,看到 state 里带它自己的内部标记就当成\"内部调用\"直接\n      // 放行、不再同步路由的 canonical URL(于是它下一次 replaceState 会拿旧\n      // URL 把我们写的 hash 抹掉);传 null 走的是它的\"外部写入\"分支,它会把内部\n      // state 补回来并同步 URL。不打这种补丁的框架里,null 就是一个干净空 state。\n      if (replace) window.history.replaceState(null, \"\", url.href)\n      else window.history.pushState(null, \"\", url.href)\n\n      // pushState / replaceState 不触发 hashchange,自己广播。\n      window.dispatchEvent(new Event(HASH_WRITE_EVENT))\n    },\n    [raw],\n  )\n\n  return [hash, setHash]\n}\n\nexport default useHash\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}