Skip to content

fix(client-react): stop five hooks from looping on dependency identity (#4693, #4694) - #4701

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-4693-hook-dep-identity
Aug 2, 2026
Merged

fix(client-react): stop five hooks from looping on dependency identity (#4693, #4694)#4701
os-zhuang merged 4 commits into
mainfrom
claude/issue-4693-hook-dep-identity

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #4693
Fixes #4694

ℹ️ 曾 stacked 在 #4692(测试基座)上。#4692 已合并(bbb1192),base 已 retarget 到 main,并已并入一次 main 让 diff 收敛——净差异就是下面这 6 个文件。

问题

五个 hook 把 useCallback / useEffect 的依赖挂在调用方内联传入的值上——where / fields / orderBy 对象、onSuccess / onError 处理器,以及 useMetadata 作为必传位置参数接收的 fetcher。内联意味着每次渲染都是新身份,于是 effect 每渲染重跑;而取数 hook 会 setState,那次渲染又触发下一次。

在这些 hook 自己文档示例的写法下,这就是一个无界请求循环。

单个已挂载组件在 250ms 内发出的请求数:

hook 修复前 修复后
useQuery(内联 where) 4691 1
useInfiniteQuery(内联 where) 6611 1
useObject(完全不传 options) 4306 1
useView(内联 onSuccess) 8197 1
useMetadata(内联 fetcher) 7654 1

useObjectuseMetadata 不需要任何特定用法就会循环:前者的依赖数组里有它自己的 dataetag state,而函数体又写这两个;后者的 fetcher 是位置参数,根本没有非内联的调用方式。

useMutation 不受影响——没有 effect 驱动它。这是实测确认的(0 次调用),不是假设。

同一根因也在 churn 实时订阅(#4694):useAutoRefresh 拿到未 memo 的 refetch(而 #4693 修复前 useQuery 每渲染返回的正是这种),会每渲染在两条流上各退订重订一次,退订与重订之间到达的事件直接丢失。

改动

两个内部原语(不从 index 导出),分别解决两半:

  • stableKey(value) —— 把结构化值变成「值变了才变」的依赖。键排序({a,b}{b,a} 等价),数组顺序保留(orderBy: ['-created_at','name'] 的顺序是语义)。跟随仓库既有先例(service-settings / service-automation 各有一份本地实现)。
  • useEventCallback(fn) —— 给处理器一个恒定身份,同时始终调用最新版本。ref 在 effect 里同步而非渲染期写入:并发渲染下被丢弃的那次渲染不应该把一个从未提交的处理器发布出去。

没有选择「让调用方自己 useMemo:TSDoc 示例本身传的就是对象字面量,把正确性押在每个调用点记得 memo 上等于没修。

测试:13 条,每条都用还原修复的方式验过

还原 结果
useQuery 依赖改回按身份 3 条红
useObject 自身 state 放回依赖 3 条红
useMetadatafetcher 放回依赖 2 条红
订阅 hook 依赖改回原始 callback 2 条红
全部还原 31/31 绿

请求数断言用的是精确值而不是 < 10 之类的上界——后者在「循环只是变慢了」的情况下依然会通过,而那正是这套测试要防的失败。

覆盖也包含反方向,免得修复把循环换成更糟的东西:

  • 值真的变了仍要重取 —— where{status:'open'} 变成 {status:'done'} 必须发起第二次请求;同值重建的对象则必须是 no-op。
  • 稳定化的处理器不能陈旧 —— ref 间接层最容易在这里出错。每个被稳定化的处理器都断言运行的是当前渲染那一版:换掉 onSuccess 不触发重取,但下一次取数必须打到的那个;订阅回调换掉后,送达的事件必须进新回调而不是订阅时捕获的旧闭包。

验证

按真实退出码(cmd | tail 的退出码是 tail 的,不作为证据),并在并入 main 之后重跑过一遍:

typecheck exit=0   build exit=0   test exit=0 (31/31)   eslint exit=0
check:type-check-coverage exit=0

顺带一提

仓库没有配 react-hooks eslint 插件,而 exhaustive-deps 正是专门抓这类缺陷的规则。这解释了它们为何能长期存活。是否引入该插件是个独立的取舍(它会在既有代码上产生大量告警),没有夹带进本 PR。

🤖 Generated with Claude Code

https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT

claude added 3 commits August 2, 2026 18:47
…hook behavior (#4682)

`packages/client-react` shipped 8 public hooks with `build` and `typecheck` as
its only scripts and zero test files. `tsc --noEmit` is structurally blind to
what actually breaks in a hook: a dependency array is a value, not a type, so a
missing entry, a missing cleanup and a callback that never fires all typecheck
perfectly. #4678 was exactly that shape — `useAutoRefresh` ignored predicate
writes, the case that dirties a list hardest, and no type noticed.

Adds the workspace's first DOM test environment (jsdom + @testing-library/react,
`environment: 'jsdom'`; every other package runs `node`) and 17 tests over the
realtime hooks covering the three things the type checker cannot see:

- re-subscription driven by the dependency array — changing `object` opens a
  subscription on the new name and releases the old one, an unrelated re-render
  churns nothing, and the hooks key on the primitive `options?.recordId` /
  `options?.packageId` rather than the options object's identity, so an
  equal-but-new object stays a no-op;
- release on unmount, including `useAutoRefresh`, which holds two subscriptions;
- delivery — events reach state and callbacks, and `useAutoRefresh` refetches on
  the per-record *and* the bulk stream (the #4678 regression pin).

Every assertion was verified by sabotage rather than assumed: dropping the
`object` dep (1 failure), deleting a cleanup (3) and reverting `useAutoRefresh`
to the single-stream version (3) each turn the suite red; reverting turns it
green again.

CI needs no wiring — `Test Core` partitions by package off `turbo ls`, and the
new `test` script puts client-react on shard 2 (verified with
scripts/partition-test-shards.mjs).

No runtime code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
…ding them

`check:type-check-coverage` failed on the first push, and it was right. The
initial commit added `**/*.test.tsx` to tsconfig's `exclude` to match the
`**/*.test.ts` entry every sibling package carries — but that entry is frozen
DEBT the repo is migrating away from (#4311), not a convention to copy. The
gate's own summary makes it explicit: 21 packages still exclude their tests,
carrying 2243 frozen raw errors in TEST_DEBT. Excluding one more would have
reported green over source `tsc` never read.

Drops the test exclusion entirely, so client-react typechecks its own tests and
stays out of that ledger.

Doing so immediately surfaced a real gap: the `METADATA_EVENT` fixture was
declared and never used, because `useMetadataSubscription` was covered for
re-subscription and unmount but not for delivery. Adds that assertion rather
than deleting the fixture — 18 tests now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
#4693, #4694)

Five hooks keyed a `useCallback`/`useEffect` on values the caller supplies
inline — `where`/`fields`/`orderBy` objects, `onSuccess`/`onError` handlers, and
the `fetcher` `useMetadata` takes as a required positional argument. Inline
means a fresh identity every render, so the effect re-ran every render; because
the fetch hooks call `setState`, that render caused another. Under the hooks'
own documented usage this was an unbounded request loop.

Requests issued in 250ms by one mounted component, before → after:

  useQuery (inline where)          4691 → 1
  useInfiniteQuery (inline where)  6611 → 1
  useObject (NO options at all)    4306 → 1
  useView (inline onSuccess)       8197 → 1
  useMetadata (inline fetcher)     7654 → 1

useObject and useMetadata needed no particular usage to loop: the former
depended on its own `data`/`etag` state while writing both, the latter takes its
fetcher positionally so there is no non-inline way to call it. useMutation was
never affected — no effect drives it.

The same root cause churned the realtime subscriptions (#4694): useAutoRefresh
with an unmemoized `refetch` — which is exactly what useQuery returned every
render — resubscribed on both streams every render, losing any event delivered
in the unsubscribe/resubscribe gap.

Adds two internal primitives (not exported): `stableKey` derives a dependency
from a structural VALUE (sorted keys, array order preserved, since `orderBy` is
positional) so a rebuilt-but-equal object is a no-op; `useEventCallback` gives a
handler a fixed identity while always invoking its latest version, synced in an
effect rather than during render so a discarded concurrent render cannot publish
a handler that never committed.

Fixing this by asking callers to memoize was rejected: the TSDoc examples
themselves pass object literals, and correctness must not rest on every call
site remembering `useMemo`.

13 tests, each verified by reverting the fix it guards — restoring identity deps
in useQuery (3 red), useObject's self-referential state (3), useMetadata's
fetcher dep (2) and the subscription callback dep (2). Counts are asserted
exactly rather than as an upper bound, which would pass on a loop that merely
got slower. Coverage includes the inverse direction: a changed value still
refetches, and every stabilized handler runs its newest version, so the ref
indirection cannot silently trade a loop for a stale closure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 2, 2026 7:25pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling size/l and removed documentation Improvements or additions to documentation tooling labels Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client-react.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/client-react)
  • content/docs/api/client-sdk.mdx (via @objectstack/client-react)
  • content/docs/plugins/packages.mdx (via @objectstack/client-react)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Base automatically changed from claude/issue-4682-client-react-test-harness to main August 2, 2026 19:21
@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Aug 2, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 19:42
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit 21855f8 Aug 2, 2026
22 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4693-hook-dep-identity branch August 2, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tooling

Projects

None yet

2 participants