修复:点回到底部后平滑滚动不再被自身补偿打断
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m53s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m41s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Repository checks (pull_request) Failing after 27s
Project CI / Frontend tests (pull_request) Successful in 2m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m24s
Project CI / Native shell tests (pull_request) Successful in 5m42s

- 新增 programmaticScrollRef:动画在飞时滚动事件不翻转跟随、布局补偿整段跳过
- 只有真的还要走一段距离才接管滚动位置;滚到贴底或滚轮 / 触摸 / 键盘接手即交还
- 补齐 hook 用例:动画途中胶囊不闪回、不写 scrollTop、落位后与被打断后照常判定

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 18:18:34 +08:00
parent ecb84f1e70
commit 238c3c60d7
2 changed files with 187 additions and 2 deletions
@@ -16,15 +16,53 @@ const PATCHED_GEOMETRY = [
'scrollHeight',
'clientHeight',
'getBoundingClientRect',
'scrollTo',
] as const;
/**
* 假 ResizeObserver:记录回调,用例自己决定何时模拟一次布局变化。
*
* jsdom 没有 ResizeObserver,也不做布局,滚动补偿的唯一入口只能靠它驱动。
*/
class FakeResizeObserver {
private static callbacks: Array<() => void> = [];
private readonly callback: () => void;
constructor(callback: () => void) {
this.callback = callback;
FakeResizeObserver.callbacks.push(callback);
}
observe(): void {}
unobserve(): void {}
disconnect(): void {
FakeResizeObserver.callbacks = FakeResizeObserver.callbacks.filter(
(item) => item !== this.callback,
);
}
static reset(): void {
FakeResizeObserver.callbacks = [];
}
/** 模拟一次「列表子元素高度变了」:展开、图片撑高、历史前插都走这条路。 */
static triggerLayoutChange(): void {
act(() => {
for (const callback of [...FakeResizeObserver.callbacks]) callback();
});
}
}
/**
* jsdom 不做布局:几何量全部挂到 `HTMLDivElement.prototype` 上,用例自己给数字。
*
* 挂在原型而不是实例上,是因为列表元素由 React 在渲染时创建,挂载期读几何的效果必须
* 一开始就能读到这些值。`afterEach` 删掉这些自有属性即可落回 jsdom 的原实现。
*/
function stubListMetrics(metrics: ListMetrics): void {
function stubListMetrics(metrics: ListMetrics): { writes: number } {
const counter = { writes: 0 };
Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', {
configurable: true,
get: () => metrics.scrollHeight,
@@ -37,6 +75,7 @@ function stubListMetrics(metrics: ListMetrics): void {
configurable: true,
get: () => metrics.scrollTop,
set: (value: number) => {
counter.writes += 1;
metrics.scrollTop = value;
},
});
@@ -55,6 +94,26 @@ function stubListMetrics(metrics: ListMetrics): void {
toJSON: () => ({}),
}) as DOMRect,
});
return counter;
}
/**
* 平滑滚动在 jsdom 里没有实现:只记录目标位置。
*
* 真实浏览器里写一次 `scrollTop` 就会取消动画,而 jsdom 不会——所以「动画被打断」这件事
* 只能靠「有没有人写 `scrollTop`」来断言,不能靠位置收敛来断言。
*/
function stubSmoothScroll(): { targets: number[] } {
const calls = { targets: [] as number[] };
Object.defineProperty(HTMLDivElement.prototype, 'scrollTo', {
configurable: true,
value: (options?: ScrollToOptions | number) => {
calls.targets.push(
typeof options === 'number' ? options : (options?.top ?? 0),
);
},
});
return calls;
}
function Harness({
@@ -82,11 +141,19 @@ function Harness({
});
return (
<>
{/* 一个回合块:前插锚点与「往哪儿补偿」都要有真实可定位的元素。 */}
<div
ref={scroll.listRef}
data-testid="list"
onScroll={scroll.onScroll}
onClickCapture={scroll.onToggleCapture}
>
<div data-turn-key="t1" />
</div>
<button
type="button"
data-testid="to-bottom"
onClick={scroll.scrollToBottom}
/>
<span data-testid="capsule">
{scroll.showScrollToBottom ? scroll.scrollToBottomLabel : ''}
@@ -112,6 +179,8 @@ const turn = (
afterEach(() => {
cleanup();
FakeResizeObserver.reset();
vi.unstubAllGlobals();
for (const key of PATCHED_GEOMETRY) {
Reflect.deleteProperty(HTMLDivElement.prototype, key);
}
@@ -316,3 +385,78 @@ describe('加载行', () => {
}
});
});
describe('点「回到底部」后的程序化滚动', () => {
function renderLongList() {
const metrics: ListMetrics = {
scrollTop: 0,
scrollHeight: 1200,
clientHeight: 400,
};
const counter = stubListMetrics(metrics);
const smooth = stubSmoothScroll();
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
const view = render(
<Harness
turns={[turn('t1')]}
historyHasMore={false}
historyLoading={false}
historyError={null}
onLoadEarlierHistory={() => undefined}
/>,
);
const list = view.getByTestId('list');
// 用户先往上滚,离开底部:胶囊出现,之后才点它。
metrics.scrollTop = 0;
fireEvent.scroll(list);
return { metrics, counter, smooth, view, list };
}
it('动画途中收到滚动事件不算用户意图:胶囊不闪回', () => {
const { metrics, smooth, view } = renderLongList();
fireEvent.click(view.getByTestId('to-bottom'));
expect(smooth.targets).toEqual([1200]);
// 平滑动画刚走了一帧、还没到贴底阈值。
metrics.scrollTop = 420;
fireEvent.scroll(view.getByTestId('list'));
expect(view.getByTestId('capsule').textContent).toBe('');
});
it('动画途中不写 scrollTop:写一次就把动画取消在半路', () => {
const { metrics, counter, view, list } = renderLongList();
fireEvent.click(view.getByTestId('to-bottom'));
const writesBefore = counter.writes;
metrics.scrollTop = 420;
fireEvent.scroll(list);
FakeResizeObserver.triggerLayoutChange();
expect(counter.writes).toBe(writesBefore);
});
it('动画落到位后交还控制权:再往上滚照样显示胶囊', () => {
const { metrics, view, list } = renderLongList();
fireEvent.click(view.getByTestId('to-bottom'));
metrics.scrollTop = 1200 - 400;
fireEvent.scroll(list);
expect(view.getByTestId('capsule').textContent).toBe('');
metrics.scrollTop = 400;
fireEvent.scroll(list);
expect(view.getByTestId('capsule').textContent).toBe('回到底部');
});
it('用户中途用滚轮打断:立刻交还控制权,后续滚动照常判定', () => {
const { metrics, view, list } = renderLongList();
fireEvent.click(view.getByTestId('to-bottom'));
metrics.scrollTop = 420;
fireEvent.wheel(list);
fireEvent.scroll(list);
expect(view.getByTestId('capsule').textContent).toBe('回到底部');
});
});
@@ -86,6 +86,15 @@ export function useConversationScroll({
const listRef = useRef<HTMLDivElement | null>(null);
/** 跟随时任何高度变化都贴底;用户离开底部(或点胶囊)即翻转。 */
const followLatestRef = useRef(true);
/**
* 程序化平滑滚动在飞吗。
*
* 为真时滚动位置归这次动画所有:滚动事件不翻转跟随、布局补偿不写 `scrollTop`。
* 真实浏览器里只要写一次 `scrollTop`,正在跑的平滑动画就被取消——补偿与锚点还原都是写
* `scrollTop`,所以「点回到底部只下去一屏就停住」的直接原因就在这里。
* 滚到贴底阈值、或用户用滚轮 / 触摸 / 键盘接手时交还。
*/
const programmaticScrollRef = useRef(false);
const [atBottom, setAtBottom] = useState(true);
const [hasNewReply, setHasNewReply] = useState(false);
/** 前插时要保住的顶部可见回合;每次滚动与补偿后刷新。 */
@@ -130,6 +139,9 @@ export function useConversationScroll({
const list = listRef.current;
if (!list) return;
// 程序化滚动在飞:滚动位置归它所有,这里写一次就会把动画取消在半路。
if (programmaticScrollRef.current) return;
if (followLatestRef.current) {
scrollListToBottom(list, 'auto');
return;
@@ -194,11 +206,30 @@ export function useConversationScroll({
};
}, [compensateLayout]);
// 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。
// 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。
useEffect(() => {
const list = listRef.current;
if (!list) return undefined;
const release = () => {
programmaticScrollRef.current = false;
};
list.addEventListener('wheel', release, { passive: true });
list.addEventListener('touchstart', release, { passive: true });
list.addEventListener('keydown', release);
return () => {
list.removeEventListener('wheel', release);
list.removeEventListener('touchstart', release);
list.removeEventListener('keydown', release);
};
}, []);
// 新一回合开始(假 → 真)强制恢复跟随:用户上一轮跑完时停在中途,这一轮不该再让他手动滚。
useEffect(() => {
const started = turnInFlight && !turnInFlightRef.current;
turnInFlightRef.current = turnInFlight;
if (!started) return;
programmaticScrollRef.current = false;
followLatestRef.current = true;
setAtBottom(true);
setHasNewReply(false);
@@ -227,6 +258,11 @@ export function useConversationScroll({
const list = event.currentTarget;
const metrics = readConversationListMetrics(list);
const nearBottom = isNearBottom(metrics);
if (programmaticScrollRef.current) {
// 平滑滚动期间的中间帧不是用户意图:这里翻转跟随或换锚点,都会让补偿立刻把动画取消。
if (!nearBottom) return;
programmaticScrollRef.current = false;
}
followLatestRef.current = nearBottom;
setAtBottom(nearBottom);
if (nearBottom) setHasNewReply(false);
@@ -254,7 +290,12 @@ export function useConversationScroll({
followLatestRef.current = true;
setAtBottom(true);
setHasNewReply(false);
scrollListToBottom(listRef.current, 'smooth');
const list = listRef.current;
// 只有真的还要走一段距离才接管滚动位置:已经贴底的列表不会再有滚动事件,挂上标记就没人交还。
if (list && !isNearBottom(readConversationListMetrics(list))) {
programmaticScrollRef.current = true;
}
scrollListToBottom(list, 'smooth');
}, []);
return {