Skip to content

test/use resize observer func target closure#226

Merged
zombieJ merged 2 commits intomasterfrom
test/use-resize-observer-func-target-closure
Mar 24, 2026
Merged

test/use resize observer func target closure#226
zombieJ merged 2 commits intomasterfrom
test/use-resize-observer-func-target-closure

Conversation

@zombieJ
Copy link
Member

@zombieJ zombieJ commented Mar 24, 2026

fix ant-design/ant-design#57292

这是一个复合问题,原因在于 rc-motion 的时序变化会导致 ResizeObserver 在 ref 监听的时候拿到的是一个 null 对象,导致监听尺寸变化的时候元素没有 ready。让 AI 总结了一下:

Tooltip unique smooth-transition 问题排查总结

现象

components/tooltip/demo/smooth-transition.tsx 中,鼠标首次移入 Tooltip 时,背景框尺寸不正确。

表现特征:

  • 刷新页面后的第一次 hover 最明显
  • Tooltip 文本已经出现,但背景框宽高不对
  • 后续再次 hover 往往恢复正常

涉及链路

本次问题涉及的主要文件:

  • components/tooltip/UniqueProvider/MotionContent.tsx
  • node_modules/@rc-component/trigger/es/Popup/index.js
  • node_modules/@rc-component/trigger/es/UniqueProvider/index.js
  • node_modules/@rc-component/resize-observer/es/useResizeObserver.js
  • node_modules/@rc-component/motion/*

链路大致如下:

  1. Tooltip unique 模式使用 rc-trigger 的 UniqueProvider
  2. UniqueProvider 依赖 PopuponResize 获取 popup 尺寸
  3. Popup 内部通过 ResizeObserver 监听外层 popup DOM
  4. Tooltip 内容又被 antd 的 MotionContent 包了一层 CSSMotion

关键发现

1. onPrepare 能执行,但拿到的 rect 是空的

rc-triggerUniqueProvider 里打印 onPrepare 时,可以看到:

  • onPrepare 的确会执行
  • 外层 popup DOM 也能在某个时刻取到
  • getBoundingClientRect() 的结果类似:
DOMRect { x: -11950, y: -9580, width: 0, height: 0, ... }

这里的 x/y 基本对应 -1000vw / -1000vh,说明量到的是 rc-trigger prepare 阶段临时停在屏幕外的 popup 壳子。

结论:

  • 这里是“有外层 DOM”
  • 但不是“有可布局内容的 DOM”

2. MotionContent 在首次 appear 时没有及时产出可布局内容

MotionContent 是 antd 自己封装的一层内容动画壳:

  • 文件:components/tooltip/UniqueProvider/MotionContent.tsx
  • 作用:给 unique Tooltip 的内容切换增加 fade motion

排查结论是:

  • 在首次 appear 的那一拍,里面的 CSSMotion 没有及时产出可布局的内容
  • 于是外层 popup 容器虽然存在,但内部没有子内容撑开尺寸
  • 因此第一次测量得到的是 0 x 0 或空尺寸

3. 真正导致背景框错误的直接原因是 onResize 没有补回来

继续往下看发现,更直接的问题是:

  • Popup/index.js 里的 ResizeObserver 本来应该在内容真正渲染出来后触发 onResize
  • 但首次这条链路里,onResize 没有再触发
  • 结果 UniqueProvider 拿不到正确的 popupSize
  • 最终背景框沿用了空尺寸或错误尺寸

为什么 onResize 没有触发

排查 @rc-component/resize-observer 后,确认了一个关键点:

  • useResizeObserver 里是通过 getTarget() 拿目标 DOM 的
  • getTarget 是函数时,effect 不会随着 ref.current 变化自动重新执行
  • 如果 effect 第一次执行时 target 还是 null,就不会调用 observe(target, ...)
  • 后续即使新的 render 把 DOM 渲染出来,也不会自动重新检测一次 target

也就是说,问题不是单纯“尺寸变了但 observer 没回调”,而是:

observer 很可能在第一次就错过了订阅时机。

临时实验

为了验证这个判断,临时修改了:

  • node_modules/@rc-component/resize-observer/es/useResizeObserver.js
  • node_modules/@rc-component/resize-observer/lib/useResizeObserver.js

实验内容:

  • 当第一次 getTarget() 返回 null
  • 在后续新的 render 中再重试几次 getTarget()
  • 一旦拿到 target,再执行 observe

实验结果:

  • 问题消失
  • Tooltip 首次 hover 时背景框尺寸恢复正确

这说明:

  1. 首次链路里 observer 确实可能没挂上
  2. 一旦 observer 能在后续 render 补挂成功,onResize 就能把正确尺寸补回来

最终结论

这次问题其实是两段因果链叠在一起:

根因

@rc-component/motion 在首次 appear 阶段的渲染语义变了,导致 popup 相关 DOM 没有在第一时间以“可观测/可布局”的方式挂出。

直接触发错误展示的原因

@rc-component/resize-observer 对“第一次 getTarget() 为 null,后续 DOM 才出现”的场景不够健壮,导致 observer 可能错过订阅,onResize 没有补回来。

修复建议

最佳正式修复

优先修 @rc-component/motion

原因:

  • 根因在 motion
  • 如果 motion 在 prepare / appear 阶段不返回 null,上层 target 就能按预期存在
  • ResizeObserver 也就不会错过首次订阅时机

推荐修法:

  1. 缩小 styleReady === 'NONE' 的触发范围
  2. 在需要 prepare 的分支里,不返回 null,而是至少保留可挂载节点

可选的健壮性增强

@rc-component/resize-observer 可以考虑补一个兜底:

  • 当第一次 getTarget()null
  • 后续新的 render 再重新检测一次 target

这不是本次问题的最优主修,但它能提升对“late mount target”场景的健壮性。

一句话总结

这次 Tooltip 背景框尺寸错误,不是单纯的尺寸算法问题,而是:

首次 appear 时 DOM 挂载时机变化,导致 ResizeObserver 错过订阅,最终 popup 尺寸没有被正确补回。

@vercel
Copy link

vercel bot commented Mar 24, 2026

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

Project Deployment Actions Updated (UTC)
resize-observer Ready Ready Preview, Comment Mar 24, 2026 9:23am

@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a subtle but important edge case in the useResizeObserver hook. It ensures that the observer reliably tracks changes to an element's dimensions even when the element is dynamically provided through a stable function reference that encapsulates a stateful React ref. This enhancement improves the hook's robustness and predictability in complex React component lifecycles.

Highlights

  • Improved useResizeObserver functionality: The useResizeObserver hook was updated to correctly handle scenarios where the target element is provided via a stable function (e.g., using useEvent) that closes over a stateful ref value. Previously, the observer might not re-evaluate when the underlying element referenced by such a function changed.
  • Enhanced dependency array logic: A funcTargetIdRef was introduced to the useEffect dependency array. This ref is incremented when a function target is enabled but no element is immediately found, forcing the effect to re-run on subsequent renders if the element eventually becomes available.
  • New test case added: A new test file tests/useResizeObserver.spec.tsx was added to specifically validate that the useResizeObserver correctly observes the latest element when the target getter closes over a stateful ref value.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@codecov
Copy link

codecov bot commented Mar 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.28%. Comparing base (7ae38d2) to head (c8166b0).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #226      +/-   ##
==========================================
+ Coverage   94.16%   94.28%   +0.12%     
==========================================
  Files           6        6              
  Lines         137      140       +3     
  Branches       40       41       +1     
==========================================
+ Hits          129      132       +3     
  Misses          7        7              
  Partials        1        1              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The pull request modifies the useResizeObserver hook to correctly handle scenarios where the target element is provided by a function (getTarget) that might initially return null but later resolves to an actual DOM element. This is achieved by introducing a funcTargetIdRef that, when incremented, forces the useEffect to re-run and re-evaluate the getTarget function, ensuring the latest element is observed. A new test case has been added to validate this dynamic behavior, particularly when useEvent is used to stabilize the getTarget function's identity.

@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

概览

修改 useResizeObserver 钩子的实现,添加 funcTargetIdRef 来追踪基于函数的目标。当目标缺失时递增引用计数器,将依赖数组从常量改为引用计数,确保效果在目标更新时重新运行。

变更

内聚体 / 文件 总结
核心实现修改
src/useResizeObserver.ts
添加了 funcTargetIdRef 以追踪函数式目标的更新。当 getTarget 为函数且目标缺失但已启用时,递增引用计数器。依赖数组从常量 0 改为 funcTargetIdRef.current,使效果在后续渲染中重新运行。
测试覆盖
tests/useResizeObserver.spec.tsx
新增测试套件验证钩子功能。定义 Demo 组件通过 ref 回调存储 DOM 元素,使用 useEvent 创建稳定的目标获取器,验证即使 getter 函数身份不变,钩子也能观察到更新的元素。

预估代码审查工作量

🎯 3 (Moderate) | ⏱️ ~20 分钟

可能相关的 PR

🐰 函数目标追踪来,
引用计数再闪耀。
依赖更新效果现,
观察者心甘乐。✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题清晰描述了主要变化:为useResizeObserver的函数目标闭包功能添加测试,与代码改动一致。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/use-resize-observer-func-target-closure

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/useResizeObserver.ts (1)

74-87: ⚠️ Potential issue | 🟠 Major

缺少对"非空元素切换"场景的处理。

现有的 funcTargetIdRef 只在 targetnull 时递增(第 74-75 行),这样当稳定的 getTarget 函数从返回元素 A 变为返回元素 B 时,依赖数组 [enabled, funcTargetIdRef.current] 不会变化,effect 不会重新运行,observer 会继续挂在元素 A 上。像条件渲染或动态替换元素这样的场景就会踩到这个问题。

建议的解决方案是在每次 render 时都重新求值并比较节点是否变化,或者单独缓存上一次解析出的节点并按节点变化做 observe/unobserve

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/useResizeObserver.ts` around lines 74 - 87, The effect doesn't handle the
case where a stable getTarget function begins returning a different non-null
element, so the observer can stay attached to the old node; update the logic in
the effect that uses funcTargetIdRef and getTarget so you resolve the current
target each render (call getTarget or read target), keep a
prevResolvedTargetRef, and when the resolved node changes call
unobserve(prevResolvedTargetRef.current, onInternalResize) then
observe(newTarget, onInternalResize) (or increment funcTargetIdRef when the
resolved target changes) and update prevResolvedTargetRef; ensure
onInternalResize and unobserve/observe calls are used as in the existing effect
so the effect reacts to node swaps even when getTarget identity doesn't change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/useResizeObserver.spec.tsx`:
- Around line 11-16: The test uses a getter getTarget and currently asserts it
returns HTMLElement, but the runtime and useResizeObserver(handle in function
useResizeObserver) already accept null targets; update the public type
signatures so the target and getter types allow null (e.g., accept HTMLElement |
null and () => HTMLElement | null) and change the test to remove the unsafe as
HTMLElement cast by using the nullable getter type; ensure symbols referenced
are getTarget (test getter) and useResizeObserver (hook) so callers and tests
align with the runtime null-handling branches (check the conditional handling
around target && enabled and the special function-target null handling).

---

Outside diff comments:
In `@src/useResizeObserver.ts`:
- Around line 74-87: The effect doesn't handle the case where a stable getTarget
function begins returning a different non-null element, so the observer can stay
attached to the old node; update the logic in the effect that uses
funcTargetIdRef and getTarget so you resolve the current target each render
(call getTarget or read target), keep a prevResolvedTargetRef, and when the
resolved node changes call unobserve(prevResolvedTargetRef.current,
onInternalResize) then observe(newTarget, onInternalResize) (or increment
funcTargetIdRef when the resolved target changes) and update
prevResolvedTargetRef; ensure onInternalResize and unobserve/observe calls are
used as in the existing effect so the effect reacts to node swaps even when
getTarget identity doesn't change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3f76fc4-ebdb-42d9-bede-c035a48239f4

📥 Commits

Reviewing files that changed from the base of the PR and between 7ae38d2 and c8166b0.

📒 Files selected for processing (2)
  • src/useResizeObserver.ts
  • tests/useResizeObserver.spec.tsx

Comment on lines +11 to +16
// `useEvent` keeps the getter identity stable while the closed-over DOM node
// comes from state. If the hook only checks the function reference, it misses
// the later state update from `null` to the actual element and never observes it.
const getTarget = useEvent(() => element as HTMLElement);

useResizeObserver(true, getTarget);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "\.(tsx?|jsx?)$" | head -20

Repository: react-component/resize-observer

Length of output: 479


🏁 Script executed:

fd -e tsx -e ts -e jsx -e js | grep -i "use.*resize" | head -20

Repository: react-component/resize-observer

Length of output: 132


🏁 Script executed:

fd "useResizeObserver" --type f

Repository: react-component/resize-observer

Length of output: 132


🏁 Script executed:

find . -name "*.tsx" -o -name "*.ts" | xargs grep -l "export.*useResizeObserver" | head -10

Repository: react-component/resize-observer

Length of output: 117


🏁 Script executed:

cat -n tests/useResizeObserver.spec.tsx | head -40

Repository: react-component/resize-observer

Length of output: 1558


🏁 Script executed:

cat -n src/useResizeObserver.ts

Repository: react-component/resize-observer

Length of output: 3508


🏁 Script executed:

cat -n src/index.tsx

Repository: react-component/resize-observer

Length of output: 2831


类型签名与运行时实现不匹配,导致需要不安全的类型断言。

测试中 getter 函数初始返回 null(当 element 状态为 null),后来返回真实元素。但当前签名只允许 () => HTMLElement,强制测试使用 as HTMLElement 绕过类型检查。

实现已在第 72 行明确处理了 null 目标(if (target && enabled)),包括第 74-75 行对函数目标 null 情况的特殊处理。既然运行时支持这个场景,公开类型应同步允许 HTMLElement | null() => HTMLElement | null

建议的修改
 export default function useResizeObserver(
   enabled: boolean,
-  getTarget: HTMLElement | (() => HTMLElement),
+  getTarget: HTMLElement | null | (() => HTMLElement | null),
   onDelayResize?: OnResize,
   onSyncResize?: OnResize,
 ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/useResizeObserver.spec.tsx` around lines 11 - 16, The test uses a
getter getTarget and currently asserts it returns HTMLElement, but the runtime
and useResizeObserver(handle in function useResizeObserver) already accept null
targets; update the public type signatures so the target and getter types allow
null (e.g., accept HTMLElement | null and () => HTMLElement | null) and change
the test to remove the unsafe as HTMLElement cast by using the nullable getter
type; ensure symbols referenced are getTarget (test getter) and
useResizeObserver (hook) so callers and tests align with the runtime
null-handling branches (check the conditional handling around target && enabled
and the special function-target null handling).

@zombieJ zombieJ merged commit 7722b14 into master Mar 24, 2026
10 checks passed
@zombieJ zombieJ deleted the test/use-resize-observer-func-target-closure branch March 24, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tooltip 平滑过渡 demo 显示异常

1 participant