fix: resolve intermittent scroll stuck in Tabs container#9975
fix: resolve intermittent scroll stuck in Tabs container#9975
Conversation
Eliminate ResizeObserver disconnect/reconnect gap that caused maxHeight to become stale when tab content height changed during the 100ms window. Use element identity check to only recreate observer when the target element actually changes, and use callback ref in ScrollView to keep element references up to date on re-mount.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
| if (element) { | ||
| scrollTabElementsRef.current[currentTabName].element = | ||
| element as HTMLElement; | ||
| } |
There was a problem hiding this comment.
🟡 Element reference not cleared on unmount in callback ref, causing stale references
The updateElementRef function in ScrollView.tsx only updates scrollTabElementsRef.current[currentTabName].element when element is truthy, but does not clear it when element is null (on unmount).
Click to expand
How the bug occurs
When the ScrollView component unmounts, React calls the callback ref with null. The callbackRef function then calls updateElementRef(null). However, updateElementRef has this logic:
if (element) {
scrollTabElementsRef.current[currentTabName].element =
element as HTMLElement;
}When element is null, this block is skipped, leaving the old (now stale) element reference in scrollTabElementsRef.current[currentTabName].element.
Impact
This could cause the Container's updateListContainerHeight function at Container.tsx:198-200 to use a stale element reference that points to a removed DOM element. While the code has retry logic that would eventually recover (since clientHeight would be 0 for removed elements), this could cause temporary scroll height calculation issues - potentially the exact "intermittent scroll stuck" issue this PR aims to fix.
Expected vs Actual
- Expected: When ScrollView unmounts,
scrollTabElementsRef.current[currentTabName].elementshould be set tonull - Actual: The stale element reference is retained
Recommendation: Clear the element reference when element is null:
if (element) {
scrollTabElementsRef.current[currentTabName].element =
element as HTMLElement;
} else if (scrollTabElementsRef?.current?.[currentTabName]) {
scrollTabElementsRef.current[currentTabName].element = null;
}Was this helpful? React with 👍 or 👎 to provide feedback.
huhuanming
left a comment
There was a problem hiding this comment.
PR #9975 代码审查建议
变更文件: Container.tsx (+14/-13), ScrollView.tsx (+24/-7)
总体评价
核心修复思路是正确的:移除 setTimeout(100ms) 消除了 ResizeObserver 断开/重连之间的观察盲区,并使用元素身份检查避免不必要的 observer 重建。ScrollView 中引入 callback ref 模式确保 DOM 元素引用在 re-mount 时及时更新。
问题 1: 切换 Tab 时旧 ResizeObserver 可能残留
文件: packages/components/src/composite/Tabs/Container.tsx (diff 中 updateListContainerHeight 区域)
问题描述:
旧代码在 updateListContainerHeight 开头 总是 调用 resizeObserverRef.current.disconnect(),确保切换 tab 后旧 observer 立即失效。
新代码仅在 element 有 clientHeight 且 元素身份发生变化时才 disconnect。当新 tab 的 element 尚未渲染(height 为 falsy)时,进入 retryNext() 分支,但 旧 observer 仍然活跃。
若此时旧 tab element 的高度发生变化(例如异步内容加载),ResizeObserver 回调会把 listContainerRef 的 maxHeight 错误地更新为旧 tab 的高度。
切换 Tab → updateListContainerHeight()
→ 新 tab element 无 height
→ 进入 retryNext()
→ 旧 observer 仍在监听旧 element ← 风险窗口(每次 retry 250ms)
→ 旧 element 高度变化 → maxHeight 被错误更新
修复方案:
在进入 if (height) 检查之前,当 element 发生变化时先 disconnect 旧 observer:
if (listContainerRef.current) {
const element =
scrollTabElementsRef.current?.[focusedTab.value]?.element;
const height = element?.clientHeight;
// Disconnect stale observer if element changed (even if new element has no height yet)
if (element !== observedElementRef.current) {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
observedElementRef.current = null;
}
if (height) {
(listContainerRef.current as HTMLElement).style.maxHeight =
`${height}px`;
if (observedElementRef.current !== element) {
resizeObserverRef.current = new ResizeObserver((entries) => {
// ... existing callback
});
resizeObserverRef.current.observe(element);
observedElementRef.current = element;
}
} else {
// ... existing retry logic
}
}优先级: 中(实际触发需要特定时序,但修复简单且能消除潜在的高度闪烁)
问题 2: ResizeObserver 回调中缺少 null 检查
文件: Container.tsx (observer 回调内部)
问题描述:
ResizeObserver 回调中直接将 listContainerRef.current 断言为 HTMLElement:
resizeObserverRef.current = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.height) {
(listContainerRef.current as HTMLElement).style.maxHeight = // ← 若已卸载则 null
`${entry.contentRect.height}px`;
}
});虽然 useLayoutEffect cleanup 中会 disconnect observer,但 ResizeObserver 回调是异步的,在 disconnect 和回调执行之间存在微小的竞态窗口。这在旧代码中已存在,但此 PR 变更了 observer 生命周期逻辑,值得一并修复。
修复方案:
if (entry && entry.contentRect.height && listContainerRef.current) {
(listContainerRef.current as HTMLElement).style.maxHeight =
`${entry.contentRect.height}px`;
}优先级: 低(崩溃概率极低,但防御性编程成本也低)
问题 3: ScrollView callbackRef 的 null 处理
文件: ScrollView.tsx (callbackRef 部分)
问题描述:
callbackRef 在组件卸载时会被 React 调用传入 null。此时 updateElementRef(null) 因 if (element) 守卫不会更新 scrollTabElementsRef,导致 scrollTabElementsRef.current[currentTabName].element 仍指向已卸载的 DOM 元素(stale reference)。
分析: 由于每个 ScrollView 通过 TabNameContext.Provider 获得稳定的 currentTabName,且 Tab 组件卸载后通常不会再被访问,实际影响有限。但如果 Container 在 ScrollView 卸载后尝试通过 scrollTabElementsRef 获取该 tab 的 element 并计算 clientHeight,会拿到已分离的 DOM 节点。
修复方案(可选):
const updateElementRef = useCallback(
(element: Element | null) => {
if (
scrollTabElementsRef?.current &&
!scrollTabElementsRef?.current[currentTabName]
) {
scrollTabElementsRef.current[currentTabName] = {} as any;
}
scrollTabElementsRef.current[currentTabName].element =
(element as HTMLElement) ?? null;
},
[currentTabName, scrollTabElementsRef],
);优先级: 低
修改清单总结
| 优先级 | 文件 | 修改类型 | 描述 |
|---|---|---|---|
| 中 | Container.tsx | 修改 | 切换 tab 时应立即 disconnect 旧 observer,不论新 element 是否有 height |
| 低 | Container.tsx | 修改 | ResizeObserver 回调中增加 listContainerRef.current null 检查 |
| 低 | ScrollView.tsx | 修改 | callbackRef null 时清理 scrollTabElementsRef 中的旧引用 |
测试验证
修复完成后,建议进行以下测试:
- 快速切换 Tab — 在 Tab 内容含异步加载的页面快速来回切换,观察是否出现高度闪烁或滚动卡死
- 慢速网络下切换 Tab — 模拟 Slow 3G,新 Tab 内容延迟渲染时确认高度正确更新
- Tab 内容高度动态变化 — 在某个 Tab 内触发内容展开/折叠,验证 maxHeight 实时跟随
- 窗口 resize — 调整浏览器窗口大小后切换 Tab,确认布局正确
- 组件挂载/卸载 — 从包含 Tabs 的页面导航离开再返回,确认无内存泄漏或报错
Eliminate ResizeObserver disconnect/reconnect gap that caused maxHeight to become stale when tab content height changed during the 100ms window. Use element identity check to only recreate observer when the target element actually changes, and use callback ref in ScrollView to keep element references up to date on re-mount.