From 28112f1b1c4ffc96c97e2f496da2cc58435802d0 Mon Sep 17 00:00:00 2001 From: "Leonardo E. Dominguez" Date: Mon, 20 Jul 2026 15:05:54 -0400 Subject: [PATCH] fix: follow gradual item resizes when maintaining scroll at end --- __tests__/core/doMaintainScrollAtEnd.test.ts | 27 ++++++++++++ __tests__/core/updateItemSize.test.ts | 45 +++++++++++++++++++- src/core/doMaintainScrollAtEnd.ts | 26 +++++++---- src/core/updateItemSizes.ts | 9 +++- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/__tests__/core/doMaintainScrollAtEnd.test.ts b/__tests__/core/doMaintainScrollAtEnd.test.ts index 14f3ba15..6250de0c 100644 --- a/__tests__/core/doMaintainScrollAtEnd.test.ts +++ b/__tests__/core/doMaintainScrollAtEnd.test.ts @@ -123,6 +123,33 @@ describe("doMaintainScrollAtEnd", () => { } }); + it("should force non-animated scrolling when animated is false", () => { + mockState.props.maintainScrollAtEnd = { animated: true }; + + const result = doMaintainScrollAtEnd(mockCtx, { animated: false }); + + expect(result).toBe(true); + expect(globalThis.requestAnimationFrame).toHaveBeenCalledTimes(1); + + if (rafCallback) { + rafCallback(); + expect(mockScrollToEnd).toHaveBeenCalledWith({ animated: false }); + expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.any(Function), 0); + } + }); + + it("should maintain immediately without scheduling a frame", () => { + mockState.props.maintainScrollAtEnd = { animated: true }; + + const result = doMaintainScrollAtEnd(mockCtx, { immediate: true }); + + expect(result).toBe(true); + expect(globalThis.requestAnimationFrame).not.toHaveBeenCalled(); + expect(mockState.maintainingScrollAtEnd).toBe("animated"); + expect(mockScrollToEnd).toHaveBeenCalledWith({ animated: true }); + expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); + }); + it("should reset maintainingScrollAtEnd flag after timeout", () => { runMaintainScrollAtEnd(true); diff --git a/__tests__/core/updateItemSize.test.ts b/__tests__/core/updateItemSize.test.ts index da4c66b3..0326c63e 100644 --- a/__tests__/core/updateItemSize.test.ts +++ b/__tests__/core/updateItemSize.test.ts @@ -8,6 +8,7 @@ import { updateItemSizes, updateOneItemSize } from "../../src/core/updateItemSiz import type { StateContext } from "../../src/state/state"; import type { InternalState } from "../../src/types.internal"; import { getItemSize } from "../../src/utils/getItemSize"; +import { NATIVE_LAYOUT_MEASUREMENT_EPSILON } from "../../src/utils/layoutMeasurement"; import { normalizeMaintainVisibleContentPosition } from "../../src/utils/normalizeMaintainVisibleContentPosition"; import { createMockContext } from "../__mocks__/createMockContext"; @@ -249,7 +250,7 @@ describe("item size update functions", () => { updateItemAndFlush(mockCtx, "item_0", { height: 150, width: 400 }); - expect(doMaintainScrollAtEndSpy).toHaveBeenCalledWith(mockCtx); + expect(doMaintainScrollAtEndSpy).toHaveBeenCalledWith(mockCtx, { animated: false, immediate: true }); doMaintainScrollAtEndSpy.mockRestore(); }); @@ -267,7 +268,47 @@ describe("item size update functions", () => { updateItemAndFlush(mockCtx, "item_0", { height: 150, width: 400 }); - expect(doMaintainScrollAtEndSpy).toHaveBeenCalledWith(mockCtx); + expect(doMaintainScrollAtEndSpy).toHaveBeenCalledWith(mockCtx, { animated: false, immediate: true }); + doMaintainScrollAtEndSpy.mockRestore(); + }); + + it("maintains the end for a known-item resize above layout noise", () => { + const doMaintainScrollAtEndSpy = spyOn( + doMaintainScrollAtEndModule, + "doMaintainScrollAtEnd", + ).mockReturnValue(true); + mockState.props.maintainScrollAtEnd = { + animated: true, + on: { itemLayout: true }, + }; + mockState.sizesKnown.set("item_0", 100); + mockState.sizes.set("item_0", 100); + + updateItemAndFlush(mockCtx, "item_0", { height: 102, width: 400 }); + + expect(2).toBeGreaterThan(NATIVE_LAYOUT_MEASUREMENT_EPSILON); + expect(doMaintainScrollAtEndSpy).toHaveBeenCalledWith(mockCtx, { animated: false, immediate: true }); + doMaintainScrollAtEndSpy.mockRestore(); + }); + + it("does not maintain the end for a sub-epsilon known-item resize", () => { + const doMaintainScrollAtEndSpy = spyOn( + doMaintainScrollAtEndModule, + "doMaintainScrollAtEnd", + ).mockReturnValue(true); + mockState.props.maintainScrollAtEnd = { + animated: true, + on: { itemLayout: true }, + }; + mockState.sizesKnown.set("item_0", 100); + mockState.sizes.set("item_0", 100); + + updateItemAndFlush(mockCtx, "item_0", { + height: 100 + NATIVE_LAYOUT_MEASUREMENT_EPSILON / 2, + width: 400, + }); + + expect(doMaintainScrollAtEndSpy).not.toHaveBeenCalled(); doMaintainScrollAtEndSpy.mockRestore(); }); diff --git a/src/core/doMaintainScrollAtEnd.ts b/src/core/doMaintainScrollAtEnd.ts index c347cbe8..c11b8d3c 100644 --- a/src/core/doMaintainScrollAtEnd.ts +++ b/src/core/doMaintainScrollAtEnd.ts @@ -2,7 +2,7 @@ import { getContentSize } from "@/state/getContentSize"; import { peek$, type StateContext } from "@/state/state"; import { getLogicalHorizontalMaxOffset, isHorizontalRTL, toNativeHorizontalOffset } from "@/utils/rtl"; -export function doMaintainScrollAtEnd(ctx: StateContext) { +export function doMaintainScrollAtEnd(ctx: StateContext, options?: { animated?: boolean; immediate?: boolean }) { const state = ctx.state; const { didContainersLayout, @@ -10,6 +10,8 @@ export function doMaintainScrollAtEnd(ctx: StateContext) { refScroller, props: { maintainScrollAtEnd }, } = state; + const animated = options?.animated ?? maintainScrollAtEnd?.animated; + const immediate = options?.immediate === true; const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); const shouldMaintainScrollAtEnd = !!( isWithinMaintainScrollAtEndThreshold && @@ -35,11 +37,11 @@ export function doMaintainScrollAtEnd(ctx: StateContext) { } if (!state.maintainingScrollAtEnd) { - const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; - const activeState = maintainScrollAtEnd.animated ? "animated" : "instant"; + const pendingState = animated ? "pending-animated" : "pending-instant"; + const activeState = animated ? "animated" : "instant"; state.maintainingScrollAtEnd = pendingState; - requestAnimationFrame(() => { + const maintain = () => { // Make sure we're still at the end after the animation frame, before scrolling to the end if (peek$(ctx, "isWithinMaintainScrollAtEndThreshold")) { state.maintainingScrollAtEnd = activeState; @@ -50,13 +52,13 @@ export function doMaintainScrollAtEnd(ctx: StateContext) { const logicalEndOffset = getLogicalHorizontalMaxOffset(state, currentContentSize); const nativeOffset = toNativeHorizontalOffset(state, logicalEndOffset, currentContentSize); scroller?.scrollTo({ - animated: maintainScrollAtEnd.animated, + animated, x: nativeOffset, y: 0, }); } else { scroller?.scrollToEnd({ - animated: maintainScrollAtEnd.animated, + animated, }); } setTimeout( @@ -64,16 +66,22 @@ export function doMaintainScrollAtEnd(ctx: StateContext) { if (state.maintainingScrollAtEnd === activeState) { state.maintainingScrollAtEnd = undefined; if (state.pendingMaintainScrollAtEnd) { - doMaintainScrollAtEnd(ctx); + doMaintainScrollAtEnd(ctx, options); } } }, - maintainScrollAtEnd.animated ? 500 : 0, + animated ? 500 : 0, ); } else if (state.maintainingScrollAtEnd === pendingState) { state.maintainingScrollAtEnd = undefined; } - }); + }; + + if (immediate) { + maintain(); + } else { + requestAnimationFrame(maintain); + } } else { // Coalesce follow-up requests while the current maintain pass is still settling. state.pendingMaintainScrollAtEnd = true; diff --git a/src/core/updateItemSizes.ts b/src/core/updateItemSizes.ts index 62135f4a..ea017636 100644 --- a/src/core/updateItemSizes.ts +++ b/src/core/updateItemSizes.ts @@ -102,7 +102,10 @@ function flushItemSizeUpdates(ctx: StateContext, result: ItemSizeUpdateResult) { state.userScrollAnchorReset = undefined; } if (result.didChange && result.shouldMaintainScrollAtEnd) { - doMaintainScrollAtEnd(ctx); + // A measured resize is already the real layout for this pass. Follow it immediately and + // non-animated so end-pinned content does not spend frames below the viewport while a + // correction catches up. + doMaintainScrollAtEnd(ctx, { animated: false, immediate: true }); } } @@ -206,7 +209,9 @@ function applyItemSize( } // Check if we should maintain scroll at end - if (prevSizeKnown !== undefined && Math.abs(prevSizeKnown - size) > 5) { + // Gradual animated resizes advance only a few pixels per frame, so use the layout noise + // epsilon instead of a larger magic number and follow every real measurement. + if (prevSizeKnown !== undefined && !isNativeLayoutNoise(prevSizeKnown - size)) { shouldMaintainScrollAtEnd = true; }