修复:动画期间内容变高时把回到底部动画重新对准新底部

- 程序化平滑滚动期间布局补偿不再直接跳过:内容变高时以新的 scrollHeight 重发平滑滚动
- 避免动画停在旧目标后 programmaticScrollRef 等不到贴底事件、跟随与补偿整段挂起
- hook 用例断言动画目标从 [1200] 变为 [1200,1500] 且仍能交还控制权
- ADR 第 3 条补一条口径,决策记录补齐本条与错误行结构两条

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 22:25:47 +08:00
parent 8f91227d17
commit 2b01476891
4 changed files with 34 additions and 2 deletions
@@ -439,6 +439,27 @@ describe('点「回到底部」后的程序化滚动', () => {
expect(counter.writes).toBe(writesBefore);
});
it('动画途中内容变高:把动画目标重新对准新的底部', () => {
const { metrics, smooth, view } = renderLongList();
fireEvent.click(view.getByTestId('to-bottom'));
expect(smooth.targets).toEqual([1200]);
// 点下去之后内容又长高了(图片撑开 / 流式正文):点击瞬间记下的 1200 已经不是底部。
metrics.scrollHeight = 1500;
FakeResizeObserver.triggerLayoutChange();
// 修正前:程序化滚动期间补偿直接跳过,动画停在旧目标 1200 上;`programmaticScrollRef` 只在
// 「贴底」那次滚动事件里交还,于是跟随与补偿永久挂起,胶囊又已按「已贴底」隐掉。
expect(smooth.targets).toEqual([1200, 1500]);
// 动画落到新底部后照常交还控制权:再往上滚仍然能出现胶囊。
metrics.scrollTop = 1500 - 400;
fireEvent.scroll(view.getByTestId('list'));
metrics.scrollTop = 400;
fireEvent.scroll(view.getByTestId('list'));
expect(view.getByTestId('capsule').textContent).toBe('回到底部');
});
it('动画落到位后交还控制权:再往上滚照样显示胶囊', () => {
const { metrics, view, list } = renderLongList();
fireEvent.click(view.getByTestId('to-bottom'));
@@ -151,8 +151,14 @@ export function useConversationScroll({
const list = listRef.current;
if (!list) return;
// 程序化滚动在飞:滚动位置归它所有,这里写一次就会把动画取消在半路。
if (programmaticScrollRef.current) return;
// 程序化滚动在飞:滚动位置归这次动画所有,直接写 `scrollTop` 会把动画取消在半路。
// 但内容在这期间变高(流式正文、图片撑开)时,点击瞬间记下的目标已经不是底部了——动画会
// 停在旧目标上,而 `programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是跟随与补偿
// 一直挂着、胶囊又已按「已贴底」隐掉。重新对准新的底部,让动画继续跑到真正的底。
if (programmaticScrollRef.current) {
if (followLatestRef.current) scrollListToBottom(list, 'smooth');
return;
}
if (followLatestRef.current) {
scrollListToBottom(list, 'auto');