{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-mutation-observer",
  "title": "useMutationObserver",
  "description": "A callback-ref hook that watches DOM changes with MutationObserver — filtered attributes, rAF-batched records, and a disconnect/write/observe guard against self-feeding loops.",
  "files": [
    {
      "path": "src/registry/hooks/use-mutation-observer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * 观测控制器。存在的唯一理由是那条最危险的用法:**写被观测的 DOM**。不先关掉观测\n * 就写,自己的写会再投递一次回调,回调再写……成为一个自己喂自己的循环。配方永远是\n * `disconnect() → 写 → observe()`,回调里和事件处理器里都一样(hook 返回值和\n * `onMutate` 的第二个参数是同一组函数)。\n */\nexport interface MutationObserverControls {\n  /**\n   * 停止观测(幂等)。和原生 `disconnect()` 一样会**丢弃**还没交到你手上的记录\n   * (包括排在下一帧提交的那批)—— 要留就先 `takeRecords()`。\n   *\n   * 它是**命令式的临时开合**,配 `observe()` 成对使用;要长期停用请用 `disabled`\n   * 选项 —— 选项变化会重建观测,把手动 disconnect 的效果抹掉。\n   */\n  disconnect: () => void\n  /** 用同一份 init 重新观测当前节点(幂等;节点已被换走 / 卸载时是 no-op)。 */\n  observe: () => void\n  /** 抽干「已排队但还没交到你手上」的记录并返回它们,同时取消挂起的那次提交。 */\n  takeRecords: () => MutationRecord[]\n}\n\nexport interface UseMutationObserverOptions {\n  /** 观测直接子节点的增删。默认 false。 */\n  childList?: boolean\n  /**\n   * 观测属性变化。默认 false —— 但按 DOM 规范的做法,只给了 `attributeFilter` 或\n   * `attributeOldValue` 时它自动视为 true(你显式写 `attributes: false` 则以你为准,\n   * 此时 filter / oldValue 会被丢掉而不是让 `observe()` 抛 TypeError)。\n   */\n  attributes?: boolean\n  /**\n   * 观测文本节点的内容变化。默认 false;给了 `characterDataOldValue` 时自动视为 true。\n   * 注意记录的 `target` 是**文本节点本身**,所以观测一个元素时几乎总要配 `subtree: true`。\n   */\n  characterData?: boolean\n  /** 把上面三项的观测范围扩到整棵子树。默认 false(只看被观测节点这一层)。 */\n  subtree?: boolean\n  /**\n   * 只观测这些属性名。**内容相同即视为没变**(内部按内容序列化成 key),所以直接写\n   * 内联数组字面量 `[\"class\"]` 是安全的,不会每次渲染都 disconnect + 重新 observe。\n   */\n  attributeFilter?: readonly string[]\n  /** 在记录里带上属性的旧值(`record.oldValue`)。默认 false。 */\n  attributeOldValue?: boolean\n  /** 在记录里带上文本的旧值(`record.oldValue`)。默认 false。 */\n  characterDataOldValue?: boolean\n  /**\n   * 每批变更提交时调用,带这一帧累计到的**全部**记录 + 一个控制器。用 latest-ref\n   * 保存,换新函数身份不会重建 observer。\n   */\n  onMutate?: (records: MutationRecord[], controls: MutationObserverControls) => void\n  /** true 时完全不观测(`lastMutation` 冻结在最后一次,不清空)。默认 false。 */\n  disabled?: boolean\n}\n\nexport interface UseMutationObserverResult<T extends Element> extends MutationObserverControls {\n  /** callback ref —— 挂到要观测的元素上:`<div ref={ref} />`。 */\n  ref: (node: T | null) => void\n  /**\n   * 最近一批变更里的**最后一条**记录;还没有任何变更时是 null。要逐条处理请用\n   * `onMutate`(它拿到整批),这个字段是给「渲染最新一次变更」用的。\n   */\n  lastMutation: MutationRecord | null\n}\n\n/**\n * 用 **callback ref** 观测一个元素的 **DOM 变化** —— 第三方脚本往你的容器里塞了\n * 节点、别人改了你元素的 class/属性、contenteditable 里的文字被编辑了。它回答的\n * 是「这块 DOM 被谁改成什么样了」,不是「它多大」(`useResizeObserver`)也不是\n * 「它可见吗」(`useIntersectionObserver`)。\n *\n * **和同族两个 observer hook 最不一样的一点:`observe()` 之后不会立刻回调。**\n * ResizeObserver / IntersectionObserver 在 observe 之后会立刻投递一次当前状态,\n * MutationObserver **不会** —— 它只报「变化」,不报「现状」。所以「首次状态」必须\n * 消费者自己读一遍(`node.children.length`、`node.className`、`node.textContent`),\n * 而且**恢复观测(从 disabled 回来、换了节点)也不会补报**观测中断期间发生的事。\n *\n * 为什么是 callback ref 而不是 `useRef` + `useEffect`:`ref` 对象只在 effect 跑的\n * 那一刻被读一次,元素若是条件渲染出来的(先 `null` 后挂载、或换 key 换分支被替换\n * 成另一个 DOM 节点),effect 不会重跑,hook 就永远盯着一个 detached 节点或干脆什么\n * 都没盯上。callback ref 由 React 在挂载 / 替换 / 卸载时逐次调用,\"现在到底该观测\n * 哪个节点\"是天然正确的。\n *\n * - **防自喂循环**:提交一律推到下一帧(`requestAnimationFrame`),一帧内的多次投递\n *   合并成一次 setState + 一次 `onMutate`。这挡住的是「一条记录一次渲染」的风暴;\n *   真正的死循环(回调里写被观测的 DOM)必须靠 `disconnect() → 写 → observe()`\n *   这个配方,hook 帮你把两个动作做成幂等的。回调里用 `onMutate` 的第二个参数,\n *   事件处理器里用 hook 返回的同名函数 —— 两者是同一组函数。\n * - **至少要打开一种观测**:`childList` / `attributes` / `characterData` 全 false 时\n *   原生 `observe()` 会抛 `TypeError`。这里拦在调用之前,`console.error` 一条能照做的\n *   信息然后空转,而不是把消费者的渲染打断。\n * - **`attributeFilter` 稳定化**:按内容序列化成 key 参与依赖比较,内联数组字面量\n *   不会造成反复 disconnect/observe。\n * - **SSR 安全**:能力探测只发生在 callback ref 里(服务端永远不会调它),渲染期不碰\n *   任何浏览器 API。\n * - **清理**:节点替换 / 卸载 / 选项变化时 `disconnect()`,并取消挂起的 rAF、丢掉\n *   还没提交的记录。\n */\nexport function useMutationObserver<T extends Element = HTMLElement>(\n  options: UseMutationObserverOptions = {},\n): UseMutationObserverResult<T> {\n  const {\n    childList = false,\n    attributes: attributesOption,\n    characterData: characterDataOption,\n    subtree = false,\n    attributeFilter,\n    attributeOldValue = false,\n    characterDataOldValue = false,\n    onMutate,\n    disabled = false,\n  } = options\n\n  // DOM 规范里 `observe()` 自己做的两件事,这里提前做掉,免得默认值把它们吃掉:\n  // 只写 `{ attributeFilter: [\"class\"] }` 或 `{ attributeOldValue: true }` 时,\n  // `attributes` 自动为 true(否则我们显式传的 `attributes: false` 会让它什么都观测\n  // 不到,还会因为 \"filter 存在但 attributes 为 false\" 直接抛 TypeError)。\n  const attributes = attributesOption ?? (attributeFilter !== undefined || attributeOldValue)\n  const characterData = characterDataOption ?? characterDataOldValue\n  const observesSomething = childList || attributes || characterData\n\n  // `attributeFilter` 几乎总是调用方现写的数组字面量(`[\"class\"]`),按引用比较会让\n  // 每次渲染都判定\"变了\",于是每渲染一次就 disconnect + 重新 observe 一次 —— 在这\n  // 两个动作之间发生的变更**永远丢失**。序列化成字符串当依赖后,内容不变就是同一个\n  // init 身份,已挂载的节点不会被重新观测。\n  const attributeFilterKey = JSON.stringify(attributeFilter ?? null)\n\n  const init = React.useMemo<MutationObserverInit>(() => {\n    const parsedFilter = JSON.parse(attributeFilterKey) as string[] | null\n    const next: MutationObserverInit = { childList, subtree, attributes, characterData }\n    // oldValue / filter 只在对应的观测类型真的开着时才带上:规范规定\n    // `{ attributes: false, attributeOldValue: true }` 是 TypeError。\n    if (attributes) {\n      if (parsedFilter) next.attributeFilter = parsedFilter\n      if (attributeOldValue) next.attributeOldValue = true\n    }\n    if (characterData && characterDataOldValue) next.characterDataOldValue = true\n    return next\n  }, [\n    childList,\n    subtree,\n    attributes,\n    characterData,\n    attributeFilterKey,\n    attributeOldValue,\n    characterDataOldValue,\n  ])\n\n  const [lastMutation, setLastMutation] = React.useState<MutationRecord | null>(null)\n\n  // latest-ref:`onMutate` 通常是消费者内联写的箭头函数,每次渲染都是新身份。放进\n  // ref 按渲染刷新,callback ref 的依赖数组里就不必出现它 —— 否则每渲染一次就会\n  // disconnect + 重新 observe 一次。\n  const onMutateRef = React.useRef(onMutate)\n  React.useEffect(() => {\n    onMutateRef.current = onMutate\n  })\n\n  const nodeRef = React.useRef<T | null>(null)\n  const observerRef = React.useRef<MutationObserver | null>(null)\n  const initRef = React.useRef<MutationObserverInit>(init)\n  const pendingRef = React.useRef<MutationRecord[]>([])\n  const frameRef = React.useRef<number | null>(null)\n\n  const cancelPending = React.useCallback(() => {\n    if (frameRef.current !== null) {\n      cancelAnimationFrame(frameRef.current)\n      frameRef.current = null\n    }\n  }, [])\n\n  const disconnect = React.useCallback(() => {\n    observerRef.current?.disconnect()\n    // 和原生语义对齐:disconnect 丢弃未处理的记录。我们自己的两级缓冲(已投递但\n    // 还排着下一帧提交的 pending)也一起丢,否则「先 disconnect 再写 DOM」的配方\n    // 会在写完之后被上一帧的残留记录再叫一次回调,计数与状态就对不上了。\n    cancelPending()\n    pendingRef.current = []\n  }, [cancelPending])\n\n  // 同一个 observer 实例 disconnect 之后可以重新 observe;对同一节点重复 observe 会\n  // 替换掉旧登记而不是叠加,所以这两个动作都是幂等的 —— 回调里的\n  // \"disconnect → 写 → observe\" 配方可以放心地写在任何分支里。\n  const observe = React.useCallback(() => {\n    const observer = observerRef.current\n    const node = nodeRef.current\n    if (!observer || !node) return\n    observer.observe(node, initRef.current)\n  }, [])\n\n  const takeRecords = React.useCallback(() => {\n    cancelPending()\n    const pending = pendingRef.current\n    pendingRef.current = []\n    // 两个来源都要抽:已经投递到我们手上、还等着下一帧提交的(pending),以及浏览器\n    // 里排着队还没投递的(observer 自己的队列)。\n    return pending.concat(observerRef.current?.takeRecords() ?? [])\n  }, [cancelPending])\n\n  const controls = React.useMemo<MutationObserverControls>(\n    () => ({ disconnect, observe, takeRecords }),\n    [disconnect, observe, takeRecords],\n  )\n\n  const flush = React.useCallback(() => {\n    frameRef.current = null\n    const records = pendingRef.current\n    pendingRef.current = []\n    const last = records[records.length - 1]\n    if (!last) return\n    setLastMutation(last)\n    onMutateRef.current?.(records, controls)\n  }, [controls])\n\n  const ref = React.useCallback(\n    (node: T | null) => {\n      // 先无条件拆掉上一轮:observer + 挂起的提交 + 还没提交的记录。少清一样就是泄漏\n      // 或串场(上一个节点的记录被算到新节点头上)。\n      observerRef.current?.disconnect()\n      observerRef.current = null\n      cancelPending()\n      pendingRef.current = []\n      nodeRef.current = node\n      initRef.current = init\n\n      if (!node || disabled || typeof MutationObserver === \"undefined\") return\n\n      if (!observesSomething) {\n        console.error(\n          \"useMutationObserver: at least one of `childList`, `attributes` or \" +\n            \"`characterData` must be true — MutationObserver.observe() throws a \" +\n            \"TypeError otherwise. Nothing is being observed.\",\n        )\n        return\n      }\n\n      const observer = new MutationObserver(records => {\n        // 攒到 ref 里而不是直接提交:① 一帧内的多次投递合成一次渲染;② 把\n        // \"回调 → setState → 重排 → 又回调\" 的环拉开到下一帧,消费者至少还有一帧\n        // 可以喘息和排查,而不是同一个任务里原地打转。\n        for (const record of records) pendingRef.current.push(record)\n        if (frameRef.current !== null) return\n        frameRef.current = requestAnimationFrame(flush)\n      })\n\n      observer.observe(node, init)\n      observerRef.current = observer\n    },\n    [init, disabled, observesSomething, flush, cancelPending],\n  )\n\n  return { ref, lastMutation, disconnect, observe, takeRecords }\n}\n\nexport default useMutationObserver\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}