做 #4682(给 client-react 建测试基座)时,用刚建好的基座实测发现,unassigned。 ## 现象 `useAutoRefresh(object, refetch)` 在 `refetch` 的函数身份每渲染变化时,**每渲染一次就把两条订阅各拆一次、重开一次**。 实测(fake client 记录每次 `subscribeData` / `subscribeBulkData`): | `refetch` 身份 | 3 次渲染后的订阅数(逐记录 / 批量) | |:---|---:| | 内联箭头函数 `() => {}` | **3 / 3** | | 稳定引用(`vi.fn()` 提到外面) | 1 / 1 | ## 成因 `packages/client-react/src/realtime-hooks.tsx`: ```ts const handleEvent = useCallback((_e: DataEvent) => { refetch(); }, [refetch]); const handleBulkEvent = useCallback((_e: BulkDataEvent) => { refetch(); }, [refetch]); useDataSubscriptionCallback(object, handleEvent, options); useBulkDataSubscriptionCallback(object, handleBulkEvent); ``` 而 `useDataSubscriptionCallback` / `useBulkDataSubscriptionCallback` 的 effect 依赖数组里都含 `callback`。`refetch` 身份一变 → `handleEvent` 身份变 → effect 重跑 → 退订 + 重订。 ## 为什么这不只是性能问题 1. **退订与重订之间有窗口**,该窗口内到达的事件直接丢失 —— 而 `useAutoRefresh` 的全部职责就是不漏掉数据变更; 2. 真实 `RealtimeAPI` 的 `subscribeData` 会 `startPolling()`,unsubscribe 在订阅数归零时 `stopPolling()`,所以这是**轮询定时器的反复起停**,不是往 Map 里塞一项那么轻; 3. 与 #4690 叠加时更糟:`useQuery` 的 `refetch` 本身就是每渲染换身份的(它 memo 在同样不稳定的 `fetchData` 上),而 `useAutoRefresh` 的 TSDoc 示例正是 `const { refetch } = useQuery(...)` 配 `useAutoRefresh(...)`。**两个缺陷是同一条调用链上的**。 ## 处置方向 把 callback 存进 ref,effect 只依赖 `[client, object, options?.recordId]`,回调从 ref 里读。这是这类「订阅 + 回调」hook 的标准做法,也让 `useDataSubscriptionCallback` / `useBulkDataSubscriptionCallback` 对调用方是否记得 `useCallback` 免疫。 注意别顺手把 `useDataSubscription` / `useBulkDataSubscription`(返回 state 的那两个)的依赖也改了——它们不接收外部回调,当前依赖是正确的,#4682 已有测试钉住(改成依赖 options 对象身份会变红)。 修复须带回归测试:基座已就位(#4682 / PR #4692),断言形状即上表——内联回调渲染 3 次后订阅数仍应为 1。 ## 优先级 比 #4690 低:本缺陷是 churn 与丢事件窗口,#4690 是无界请求循环。但两者同源(依赖数组用身份而非值),建议一并修。
Activity
os-zhuang commentedon Aug 2, 2026
认领开工,与 #4693 同一分支处理(同源:依赖数组按身份而非按值,且在同一条调用链上)。
session_01LYnZrTwXbrctB8E8HpJAPTclaude/issue-4693-hook-dep-identityGenerated by Claude Code
fix(client-react): stop five hooks from looping on dependency identity (