From 709d860de5b525fe3308d2c781e6d431b5467be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Thu, 2 Jul 2026 18:09:50 +0200 Subject: [PATCH 1/8] refactor: extract end-aligned scroll target helpers --- bun.lock | 6 ++++++ src/core/checkFinishedScroll.ts | 31 +++++------------------------- src/core/endAlignedScrollTarget.ts | 30 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 26 deletions(-) create mode 100644 src/core/endAlignedScrollTarget.ts diff --git a/bun.lock b/bun.lock index bbf70be02..52e947bfd 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,13 @@ }, "peerDependencies": { "react": "*", + "react-dom": "*", + "react-native": "*", }, + "optionalPeers": [ + "react-dom", + "react-native", + ], }, }, "packages": { diff --git a/src/core/checkFinishedScroll.ts b/src/core/checkFinishedScroll.ts index b3f745353..0db7f9b73 100644 --- a/src/core/checkFinishedScroll.ts +++ b/src/core/checkFinishedScroll.ts @@ -1,6 +1,9 @@ -import { calculateOffsetForIndex } from "@/core/calculateOffsetForIndex"; -import { calculateOffsetWithOffsetPosition } from "@/core/calculateOffsetWithOffsetPosition"; import { clampScrollOffset } from "@/core/clampScrollOffset"; +import { + getCurrentTargetOffset, + isEndAlignedLastItemTarget, + scrollToFallbackOffset, +} from "@/core/endAlignedScrollTarget"; import { finishScrollTo } from "@/core/finishScrollTo"; import { initialScrollCompletion, initialScrollWatchdog } from "@/core/initialScrollSession"; import { Platform } from "@/platform/Platform"; @@ -97,22 +100,6 @@ function shouldFinishInitialZeroTargetScroll(ctx: StateContext) { ); } -function isEndAlignedLastItemTarget(ctx: StateContext, scrollingTo: ActiveScrollTarget) { - return scrollingTo.index === ctx.state.props.data.length - 1 && scrollingTo.viewPosition === 1; -} - -function getCurrentTargetOffset(ctx: StateContext, scrollingTo: ActiveScrollTarget) { - const index = scrollingTo.index; - const shouldRecomputeEndTarget = isEndAlignedLastItemTarget(ctx, scrollingTo); - const requestedTargetOffset = - shouldRecomputeEndTarget && index !== undefined - ? calculateOffsetWithOffsetPosition(ctx, calculateOffsetForIndex(ctx, index), scrollingTo) - : (scrollingTo.targetOffset ?? - clampScrollOffset(ctx, scrollingTo.offset - (scrollingTo.viewOffset || 0), scrollingTo)); - - return clampScrollOffset(ctx, requestedTargetOffset, scrollingTo); -} - function getResolvedScrollCompletionState(ctx: StateContext, scrollingTo: ActiveScrollTarget) { const { state } = ctx; const scroll = state.scrollPending; @@ -150,14 +137,6 @@ function checkFinishedScrollFrame(ctx: StateContext) { } } -function scrollToFallbackOffset(ctx: StateContext, offset: number) { - ctx.state.refScroller.current?.scrollTo({ - animated: false, - x: ctx.state.props.horizontal ? offset : 0, - y: ctx.state.props.horizontal ? 0 : offset, - }); -} - // In case checkFinishedScroll does not work correctly, set a maximum timeout // to make sure it does eventually get cleared, just waiting for scroll to end export function checkFinishedScrollFallback(ctx: StateContext) { diff --git a/src/core/endAlignedScrollTarget.ts b/src/core/endAlignedScrollTarget.ts new file mode 100644 index 000000000..3c427ec3d --- /dev/null +++ b/src/core/endAlignedScrollTarget.ts @@ -0,0 +1,30 @@ +import { calculateOffsetForIndex } from "@/core/calculateOffsetForIndex"; +import { calculateOffsetWithOffsetPosition } from "@/core/calculateOffsetWithOffsetPosition"; +import { clampScrollOffset } from "@/core/clampScrollOffset"; +import type { StateContext } from "@/state/state"; + +type ActiveScrollTarget = NonNullable; + +export function isEndAlignedLastItemTarget(ctx: StateContext, scrollingTo: ActiveScrollTarget) { + return scrollingTo.index === ctx.state.props.data.length - 1 && scrollingTo.viewPosition === 1; +} + +export function getCurrentTargetOffset(ctx: StateContext, scrollingTo: ActiveScrollTarget) { + const index = scrollingTo.index; + const shouldRecomputeEndTarget = isEndAlignedLastItemTarget(ctx, scrollingTo); + const requestedTargetOffset = + shouldRecomputeEndTarget && index !== undefined + ? calculateOffsetWithOffsetPosition(ctx, calculateOffsetForIndex(ctx, index), scrollingTo) + : (scrollingTo.targetOffset ?? + clampScrollOffset(ctx, scrollingTo.offset - (scrollingTo.viewOffset || 0), scrollingTo)); + + return clampScrollOffset(ctx, requestedTargetOffset, scrollingTo); +} + +export function scrollToFallbackOffset(ctx: StateContext, offset: number) { + ctx.state.refScroller.current?.scrollTo({ + animated: false, + x: ctx.state.props.horizontal ? offset : 0, + y: ctx.state.props.horizontal ? 0 : offset, + }); +} From 96286bf1fc343e67f7986a0f97811bfd3836fccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Thu, 2 Jul 2026 18:10:24 +0200 Subject: [PATCH 2/8] fix: correct end-aligned scroll position after pendingTotalSize commits getContentSize prefers state.pendingTotalSize when computing scroll targets, but the pending size is only committed to the rendered container in finishScrollTo. During a programmatic scrollToEnd this deadlocks: the dispatched target needs the container at the pending size, the container keeps the previous committed size until the scroll finishes, and the native scroll view clamps every dispatch (including fallback retries) to the smaller reachable range. The scroll settles short by exactly the uncommitted delta, and once finishScrollTo commits the pending size the missed distance becomes permanent because anchoredEndSpace-style end insets keep contentSize (and therefore maxScroll) constant. Observed in a streaming chat: target 2095 vs reachable 2071, five futile fallback retries, then finish 24 short; with chunks arriving during an animated scroll the pending delta grows to hundreds of pixels. After finishScrollTo commits the pending size for a non-initial end-aligned last-item target, wait two frames for the container to take the committed size and re-dispatch one unanimated scroll to the freshly recomputed end target. The correction is one-shot, bails if a new scroll session started, and only ever scrolls toward the end, so it cannot loop or fight user scrolling. --- src/core/endAlignedScrollTarget.ts | 26 ++++++++++++++++++++++++++ src/core/finishScrollTo.ts | 2 ++ 2 files changed, 28 insertions(+) diff --git a/src/core/endAlignedScrollTarget.ts b/src/core/endAlignedScrollTarget.ts index 3c427ec3d..59dd8774b 100644 --- a/src/core/endAlignedScrollTarget.ts +++ b/src/core/endAlignedScrollTarget.ts @@ -28,3 +28,29 @@ export function scrollToFallbackOffset(ctx: StateContext, offset: number) { y: ctx.state.props.horizontal ? 0 : offset, }); } + +// Committing pendingTotalSize in finishScrollTo can grow the content after an +// end-aligned scroll already settled: while the scroll was active the native +// container still had the previous committed size, so the dispatched target sat +// beyond the reachable range and retries could not move past it. Once the +// committed size lands natively (two frames), re-dispatch a single unanimated +// correction toward the end. One-shot and end-directed, so it cannot loop or +// fight the user. +export function maybeCorrectEndAlignedScrollAfterCommit(ctx: StateContext, scrollingTo: ActiveScrollTarget) { + const state = ctx.state; + if (!isEndAlignedLastItemTarget(ctx, scrollingTo)) { + return; + } + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (state.scrollingTo) { + return; + } + const correctedTarget = getCurrentTargetOffset(ctx, scrollingTo); + if (correctedTarget > state.scroll + 1) { + scrollToFallbackOffset(ctx, correctedTarget); + } + }); + }); +} diff --git a/src/core/finishScrollTo.ts b/src/core/finishScrollTo.ts index ff49bfba9..02399e8f0 100644 --- a/src/core/finishScrollTo.ts +++ b/src/core/finishScrollTo.ts @@ -1,4 +1,5 @@ import { addTotalSize } from "@/core/addTotalSize"; +import { maybeCorrectEndAlignedScrollAfterCommit } from "@/core/endAlignedScrollTarget"; import { finishInitialScroll } from "@/core/finishInitialScroll"; import { recalculateSettledScroll } from "@/core/recalculateSettledScroll"; import { PlatformAdjustBreaksScroll } from "@/platform/Platform"; @@ -45,6 +46,7 @@ export function finishScrollTo(ctx: StateContext) { } recalculateSettledScroll(ctx); + maybeCorrectEndAlignedScrollAfterCommit(ctx, scrollingTo); resolvePendingScroll?.(); } } From b2c8748e6f4ea283ad7fee625fc59b3fadde8270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Thu, 2 Jul 2026 18:11:00 +0200 Subject: [PATCH 3/8] fix: bound the scroll-completion fallback watchdog per session checkFinishedScrollFallback armed a new watchdog closure without clearing the pending one, so every re-arm (each dispatch while a scroll session stayed unresolved) leaked a concurrent retry loop. With an unreachable target the loops multiplied into thousands of 100ms timers, each issuing futile native scroll dispatches. Clearing on re-arm alone is not enough: the retry cap (numChecks) is closure-local, so continuous re-dispatches would reset it forever and the finish escape could never fire. Track the check count on state, keyed by the active scrollingTo session, and force-finish once a single session exceeds MAX_FALLBACK_CHECKS_PER_SESSION regardless of how many times the watchdog was re-armed. New sessions reset the counter, so normal scrolls are unaffected. --- src/core/checkFinishedScroll.ts | 20 ++++++++++++++++++++ src/types.internal.ts | 2 ++ 2 files changed, 22 insertions(+) diff --git a/src/core/checkFinishedScroll.ts b/src/core/checkFinishedScroll.ts index 0db7f9b73..b8438eeea 100644 --- a/src/core/checkFinishedScroll.ts +++ b/src/core/checkFinishedScroll.ts @@ -12,6 +12,7 @@ import type { StateContext } from "@/state/state"; type ActiveScrollTarget = NonNullable; const INITIAL_SCROLL_MAX_FALLBACK_CHECKS = 20; +const MAX_FALLBACK_CHECKS_PER_SESSION = 40; const INITIAL_SCROLL_COMPLETION_TARGET_EPSILON = 1; const INITIAL_SCROLL_ZERO_TARGET_EPSILON = 1; const SILENT_INITIAL_SCROLL_RETRY_DELAY_MS = 16; @@ -141,6 +142,13 @@ function checkFinishedScrollFrame(ctx: StateContext) { // to make sure it does eventually get cleared, just waiting for scroll to end export function checkFinishedScrollFallback(ctx: StateContext) { const state = ctx.state; + // Re-arming replaces the pending watchdog instead of orphaning it: each + // orphaned closure kept its own retry loop alive, multiplying timers and + // native scroll dispatches while a scroll session stayed unresolved. + if (state.timeoutCheckFinishedScrollFallback) { + clearTimeout(state.timeoutCheckFinishedScrollFallback); + state.timeoutCheckFinishedScrollFallback = undefined; + } const scrollingTo = state.scrollingTo; const shouldFinishInitialZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); const silentInitialDispatch = isSilentInitialDispatch(state, scrollingTo); @@ -169,6 +177,18 @@ export function checkFinishedScrollFallback(ctx: StateContext) { const isStillScrollingTo = state.scrollingTo; if (isStillScrollingTo) { numChecks++; + // Hard bound per scroll session, stored on state so it survives + // watchdog re-arms: without it, continuous re-dispatches reset the + // closure-local numChecks and the finish escape never fires. + if (state.fallbackScrollSession !== isStillScrollingTo) { + state.fallbackScrollSession = isStillScrollingTo; + state.fallbackScrollSessionChecks = 0; + } + state.fallbackScrollSessionChecks = (state.fallbackScrollSessionChecks ?? 0) + 1; + if (state.fallbackScrollSessionChecks > MAX_FALLBACK_CHECKS_PER_SESSION) { + finishScrollTo(ctx); + return; + } const isNativeInitialPending = isNativeInitialNonZeroTarget(state) && !state.hasScrolled; const maxChecks = silentInitialDispatch ? 5 diff --git a/src/types.internal.ts b/src/types.internal.ts index 581335f38..f80081379 100644 --- a/src/types.internal.ts +++ b/src/types.internal.ts @@ -247,6 +247,8 @@ export interface InternalState { timeouts: Set; timeoutSetPaddingTop?: any; timeoutCheckFinishedScrollFallback?: any; + fallbackScrollSession?: InternalScrollTarget | undefined; + fallbackScrollSessionChecks?: number; totalSize: number; triggerCalculateItemsInView?: (params?: { doMVCP?: boolean; From 26e65ff179db99e97dc1236c515b00969e3bd1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Thu, 2 Jul 2026 18:11:25 +0200 Subject: [PATCH 4/8] fix: complete end-aligned scrolls within slack instead of pixel-chasing While content streams into the last item, the recomputed end-aligned target moves on every measurement and pendingTotalSize keeps it roughly one uncommitted delta beyond the reachable native range. Requiring sub-pixel alignment (< 1) meant the completion check could never pass, so the fallback watchdog kept re-dispatching unanimated scrolls against a moving target, which shows up as visible jumping while a chat response streams in. Treat end-aligned last-item targets as resolved within a small slack and finish the session promptly. The post-commit correction from the previous change then snaps the exact final position once, after the pending size has been committed. --- src/core/checkFinishedScroll.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/checkFinishedScroll.ts b/src/core/checkFinishedScroll.ts index b8438eeea..ed3133234 100644 --- a/src/core/checkFinishedScroll.ts +++ b/src/core/checkFinishedScroll.ts @@ -13,6 +13,7 @@ import type { StateContext } from "@/state/state"; type ActiveScrollTarget = NonNullable; const INITIAL_SCROLL_MAX_FALLBACK_CHECKS = 20; const MAX_FALLBACK_CHECKS_PER_SESSION = 40; +const END_ALIGNED_COMPLETION_EPSILON = 30; const INITIAL_SCROLL_COMPLETION_TARGET_EPSILON = 1; const INITIAL_SCROLL_ZERO_TARGET_EPSILON = 1; const SILENT_INITIAL_SCROLL_RETRY_DELAY_MS = 16; @@ -111,10 +112,17 @@ function getResolvedScrollCompletionState(ctx: StateContext, scrollingTo: Active const adjustedTargetOffset = clampedTargetOffset + adjust; const diff2 = Math.abs(scroll - adjustedTargetOffset); const canUseAdjustedCompletion = !scrollingTo.animated || Platform.OS === "ios"; + // End-aligned targets move while content grows (and pendingTotalSize keeps + // them ~one commit ahead of the reachable native range). Chasing them to + // sub-pixel precision causes visible retry jumps; finish within slack + // instead and let the post-commit end correction snap the exact position. + const completionEpsilon = isEndAlignedLastItemTarget(ctx, scrollingTo) ? END_ALIGNED_COMPLETION_EPSILON : 1; return { clampedTargetOffset, - isAtResolvedTarget: Math.abs(scroll - maxOffset) < 1 && (diff1 < 1 || (canUseAdjustedCompletion && diff2 < 1)), + isAtResolvedTarget: + Math.abs(scroll - maxOffset) < completionEpsilon && + (diff1 < completionEpsilon || (canUseAdjustedCompletion && diff2 < completionEpsilon)), }; } From 354abe1a67b2f98966c31998e7cfaf6088e77f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Fri, 7 Aug 2026 17:18:31 +0200 Subject: [PATCH 5/8] fix: keep end-aligned retries animated for animated scroll sessions An animated setContentOffset gets clamped to the natively committed range at dispatch time. When an anchoredEndSpace end inset (or the pending total size) has not committed yet, a send-time scrollToEnd animation stalls short, and both recovery paths re-dispatched with animated: false - a visible teleport instead of the requested glide. Thread the session's animated flag through scrollToFallbackOffset in the unaligned-end-scroll watchdog retry and the post-commit end correction. Corrections under 40px stay instant so the settle after the completion slack remains imperceptible. Co-Authored-By: Claude --- __tests__/core/checkFinishedScroll.test.ts | 45 +++++++- __tests__/core/endAlignedScrollTarget.test.ts | 109 ++++++++++++++++++ src/core/checkFinishedScroll.ts | 6 +- src/core/endAlignedScrollTarget.ts | 19 ++- 4 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 __tests__/core/endAlignedScrollTarget.test.ts diff --git a/__tests__/core/checkFinishedScroll.test.ts b/__tests__/core/checkFinishedScroll.test.ts index 417bf43cb..d2014d44b 100644 --- a/__tests__/core/checkFinishedScroll.test.ts +++ b/__tests__/core/checkFinishedScroll.test.ts @@ -180,7 +180,7 @@ describe("checkFinishedScrollFallback", () => { checkFinishedScrollFallback(ctx); flushTimers(1); - expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 393999 }]); + expect(scrollToCalls).toEqual([{ animated: true, x: 0, y: 393999 }]); expect(ctx.state.scrollingTo).toBeDefined(); ctx.state.scroll = 393999; @@ -189,6 +189,49 @@ describe("checkFinishedScrollFallback", () => { expect(ctx.state.scrollingTo).toBeUndefined(); }); + it("retries an unresolved unanimated iOS scroll to end without animation", () => { + Platform.OS = "ios"; + const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; + const data = Array.from({ length: 1000 }, (_, index) => ({ id: index })); + const positions = Array.from({ length: 1000 }, (_, index) => index * 401); + positions[999] = 394259; + + const ctx = createMockContext( + { totalSize: 394700 }, + { + didContainersLayout: true, + hasScrolled: true, + positions, + props: { + data, + estimatedItemSize: 401, + } as any, + refScroller: { + current: { + scrollTo: (params: { animated: boolean; x: number; y: number }) => scrollToCalls.push(params), + }, + } as any, + scroll: 393753.3333333333, + scrollingTo: { + animated: false, + index: 999, + offset: 397479, + targetOffset: 397179, + viewOffset: 0, + viewPosition: 1, + } as any, + scrollLength: 701, + scrollPending: 393753.3333333333, + sizesKnown: new Map([["item_999", 441]]), + }, + ); + + checkFinishedScrollFallback(ctx); + + flushTimers(1); + expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 393999 }]); + }); + it("reissues native scrollTo while an initial non-zero target is still pending", () => { Platform.OS = "android"; const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; diff --git a/__tests__/core/endAlignedScrollTarget.test.ts b/__tests__/core/endAlignedScrollTarget.test.ts new file mode 100644 index 000000000..a75b6116c --- /dev/null +++ b/__tests__/core/endAlignedScrollTarget.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import "../setup"; + +import { maybeCorrectEndAlignedScrollAfterCommit } from "../../src/core/endAlignedScrollTarget"; +import { createMockContext } from "../__mocks__/createMockContext"; + +describe("maybeCorrectEndAlignedScrollAfterCommit", () => { + let originalRequestAnimationFrame: typeof globalThis.requestAnimationFrame; + + beforeEach(() => { + originalRequestAnimationFrame = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as typeof globalThis.requestAnimationFrame; + }); + + afterEach(() => { + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + }); + + // End content at 394259 + 441 = 394700, scrollLength 701 → end target 393999. + const createEndAlignedContext = ( + scroll: number, + scrollToCalls: Array<{ animated: boolean; x: number; y: number }>, + ) => { + const data = Array.from({ length: 1000 }, (_, index) => ({ id: index })); + const positions = Array.from({ length: 1000 }, (_, index) => index * 401); + positions[999] = 394259; + + return createMockContext( + { totalSize: 394700 }, + { + didContainersLayout: true, + hasScrolled: true, + positions, + props: { + data, + estimatedItemSize: 401, + } as any, + refScroller: { + current: { + scrollTo: (params: { animated: boolean; x: number; y: number }) => scrollToCalls.push(params), + }, + } as any, + scroll, + scrollLength: 701, + scrollPending: scroll, + sizesKnown: new Map([["item_999", 441]]), + }, + ); + }; + + const endAlignedTarget = (animated: boolean) => + ({ + animated, + index: 999, + offset: 397479, + targetOffset: 397179, + viewOffset: 0, + viewPosition: 1, + }) as any; + + it("glides a large remainder when the finished session was animated", () => { + const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; + const ctx = createEndAlignedContext(393753, scrollToCalls); + + maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(true)); + + expect(scrollToCalls).toEqual([{ animated: true, x: 0, y: 393999 }]); + }); + + it("snaps a small residue instantly even when the finished session was animated", () => { + const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; + const ctx = createEndAlignedContext(393969, scrollToCalls); + + maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(true)); + + expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 393999 }]); + }); + + it("corrects unanimated sessions without animation", () => { + const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; + const ctx = createEndAlignedContext(393753, scrollToCalls); + + maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(false)); + + expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 393999 }]); + }); + + it("does not dispatch when already at the corrected target", () => { + const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; + const ctx = createEndAlignedContext(393999, scrollToCalls); + + maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(true)); + + expect(scrollToCalls).toEqual([]); + }); + + it("bails when a new scroll session started before the correction frame", () => { + const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; + const ctx = createEndAlignedContext(393753, scrollToCalls); + ctx.state.scrollingTo = endAlignedTarget(true); + + maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(true)); + + expect(scrollToCalls).toEqual([]); + }); +}); diff --git a/src/core/checkFinishedScroll.ts b/src/core/checkFinishedScroll.ts index ed3133234..0983c7640 100644 --- a/src/core/checkFinishedScroll.ts +++ b/src/core/checkFinishedScroll.ts @@ -242,7 +242,11 @@ export function checkFinishedScrollFallback(ctx: StateContext) { }); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); + // An animated dispatch gets clamped short when the end target sits + // beyond the natively committed range (uncommitted total size or end + // inset). Retry with the session's animation so the remaining distance + // glides instead of teleporting. + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); scheduleFallbackCheck(100); } else if ( shouldFinishZeroTarget || diff --git a/src/core/endAlignedScrollTarget.ts b/src/core/endAlignedScrollTarget.ts index 59dd8774b..cd69266fa 100644 --- a/src/core/endAlignedScrollTarget.ts +++ b/src/core/endAlignedScrollTarget.ts @@ -5,6 +5,8 @@ import type { StateContext } from "@/state/state"; type ActiveScrollTarget = NonNullable; +const ANIMATED_CORRECTION_MIN_DISTANCE = 40; + export function isEndAlignedLastItemTarget(ctx: StateContext, scrollingTo: ActiveScrollTarget) { return scrollingTo.index === ctx.state.props.data.length - 1 && scrollingTo.viewPosition === 1; } @@ -21,9 +23,9 @@ export function getCurrentTargetOffset(ctx: StateContext, scrollingTo: ActiveScr return clampScrollOffset(ctx, requestedTargetOffset, scrollingTo); } -export function scrollToFallbackOffset(ctx: StateContext, offset: number) { +export function scrollToFallbackOffset(ctx: StateContext, offset: number, animated = false) { ctx.state.refScroller.current?.scrollTo({ - animated: false, + animated, x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset, }); @@ -33,9 +35,12 @@ export function scrollToFallbackOffset(ctx: StateContext, offset: number) { // end-aligned scroll already settled: while the scroll was active the native // container still had the previous committed size, so the dispatched target sat // beyond the reachable range and retries could not move past it. Once the -// committed size lands natively (two frames), re-dispatch a single unanimated -// correction toward the end. One-shot and end-directed, so it cannot loop or -// fight the user. +// committed size lands natively (two frames), re-dispatch a single correction +// toward the end. One-shot and end-directed, so it cannot loop or fight the +// user. A large remainder on an animated session means the original dispatch +// was clamped short (uncommitted size or end inset), so the correction glides +// instead of teleporting; small residue snaps instantly to keep the settle +// imperceptible. export function maybeCorrectEndAlignedScrollAfterCommit(ctx: StateContext, scrollingTo: ActiveScrollTarget) { const state = ctx.state; if (!isEndAlignedLastItemTarget(ctx, scrollingTo)) { @@ -49,7 +54,9 @@ export function maybeCorrectEndAlignedScrollAfterCommit(ctx: StateContext, scrol } const correctedTarget = getCurrentTargetOffset(ctx, scrollingTo); if (correctedTarget > state.scroll + 1) { - scrollToFallbackOffset(ctx, correctedTarget); + const animated = + !!scrollingTo.animated && correctedTarget - state.scroll > ANIMATED_CORRECTION_MIN_DISTANCE; + scrollToFallbackOffset(ctx, correctedTarget, animated); } }); }); From 6454e97953dd7eb0967e100b49eb7462d3d58d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Fri, 7 Aug 2026 18:09:21 +0200 Subject: [PATCH 6/8] chore: drop unrelated bun.lock drift Peer-dependency metadata churn from a newer bun; not related to the fix. Co-Authored-By: Claude --- bun.lock | 6 ------ 1 file changed, 6 deletions(-) diff --git a/bun.lock b/bun.lock index 52e947bfd..bbf70be02 100644 --- a/bun.lock +++ b/bun.lock @@ -29,13 +29,7 @@ }, "peerDependencies": { "react": "*", - "react-dom": "*", - "react-native": "*", }, - "optionalPeers": [ - "react-dom", - "react-native", - ], }, }, "packages": { From d582751206f344c85a910479c7ef81a63b4db946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Fri, 7 Aug 2026 18:10:00 +0200 Subject: [PATCH 7/8] refactor: carry the fallback check count on the scroll session The hard bound tracked the active session by object identity through two InternalState fields plus a reset-on-identity-change block. The session object already has the right lifecycle: count on state.scrollingTo directly, which deletes both fields and the identity dance, and stops retaining a reference to the previous finished session. Co-Authored-By: Claude --- src/core/checkFinishedScroll.ts | 14 +++++--------- src/types.internal.ts | 3 +-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/core/checkFinishedScroll.ts b/src/core/checkFinishedScroll.ts index 0983c7640..6e0c71f94 100644 --- a/src/core/checkFinishedScroll.ts +++ b/src/core/checkFinishedScroll.ts @@ -185,15 +185,11 @@ export function checkFinishedScrollFallback(ctx: StateContext) { const isStillScrollingTo = state.scrollingTo; if (isStillScrollingTo) { numChecks++; - // Hard bound per scroll session, stored on state so it survives - // watchdog re-arms: without it, continuous re-dispatches reset the - // closure-local numChecks and the finish escape never fires. - if (state.fallbackScrollSession !== isStillScrollingTo) { - state.fallbackScrollSession = isStillScrollingTo; - state.fallbackScrollSessionChecks = 0; - } - state.fallbackScrollSessionChecks = (state.fallbackScrollSessionChecks ?? 0) + 1; - if (state.fallbackScrollSessionChecks > MAX_FALLBACK_CHECKS_PER_SESSION) { + // Hard bound carried on the session itself so it survives watchdog + // re-arms: continuous re-dispatches reset the closure-local numChecks, + // and without a persistent count the finish escape never fires. + isStillScrollingTo.fallbackChecks = (isStillScrollingTo.fallbackChecks ?? 0) + 1; + if (isStillScrollingTo.fallbackChecks > MAX_FALLBACK_CHECKS_PER_SESSION) { finishScrollTo(ctx); return; } diff --git a/src/types.internal.ts b/src/types.internal.ts index f80081379..1cb7f1427 100644 --- a/src/types.internal.ts +++ b/src/types.internal.ts @@ -89,6 +89,7 @@ type BootstrapInitialScrollSession = { }; type InternalScrollTarget = ScrollTarget & { + fallbackChecks?: number; waitForInitialScrollCompletionFrame?: boolean; }; @@ -247,8 +248,6 @@ export interface InternalState { timeouts: Set; timeoutSetPaddingTop?: any; timeoutCheckFinishedScrollFallback?: any; - fallbackScrollSession?: InternalScrollTarget | undefined; - fallbackScrollSessionChecks?: number; totalSize: number; triggerCalculateItemsInView?: (params?: { doMVCP?: boolean; From a7209aebec3d341fb6533cf571c528610aead2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Such=C3=BD?= Date: Fri, 7 Aug 2026 18:12:13 +0200 Subject: [PATCH 8/8] refactor: unify the end-aligned re-dispatch glide policy The glide-vs-snap decision lived in two call sites with two near-equal thresholds (the 30px completion slack and a separate 40px animation floor) and leaked as a boolean param on scrollToFallbackOffset, whose name promises an instant dispatch. One redispatchEndAlignedTarget helper now owns the policy against the single completion slack, and the fallback dispatcher is instant-only again. Also extracts the shared end-aligned test fixture used three times across the two test files into __mocks__/createEndAlignedScrollContext. Co-Authored-By: Claude --- .../createEndAlignedScrollContext.ts | 49 +++++++++++ __tests__/core/checkFinishedScroll.test.ts | 81 +++---------------- __tests__/core/endAlignedScrollTarget.test.ts | 80 +++++------------- src/core/checkFinishedScroll.ts | 9 +-- src/core/endAlignedScrollTarget.ts | 31 ++++--- 5 files changed, 108 insertions(+), 142 deletions(-) create mode 100644 __tests__/__mocks__/createEndAlignedScrollContext.ts diff --git a/__tests__/__mocks__/createEndAlignedScrollContext.ts b/__tests__/__mocks__/createEndAlignedScrollContext.ts new file mode 100644 index 000000000..21c8f3ea3 --- /dev/null +++ b/__tests__/__mocks__/createEndAlignedScrollContext.ts @@ -0,0 +1,49 @@ +import { createMockContext } from "./createMockContext"; + +export type RecordedScrollTo = { animated: boolean; x: number; y: number }; + +// 1000 items of ~401px with the last item at 394259 sized 441, so the content +// ends at 394700. With a 701px viewport the end-aligned target is 393999. +export function createEndAlignedScrollContext( + scroll: number, + scrollToCalls: RecordedScrollTo[], + stateOverrides: Record = {}, +) { + const data = Array.from({ length: 1000 }, (_, index) => ({ id: index })); + const positions = Array.from({ length: 1000 }, (_, index) => index * 401); + positions[999] = 394259; + + return createMockContext( + { totalSize: 394700 }, + { + didContainersLayout: true, + hasScrolled: true, + positions, + props: { + data, + estimatedItemSize: 401, + } as any, + refScroller: { + current: { + scrollTo: (params: RecordedScrollTo) => scrollToCalls.push(params), + }, + } as any, + scroll, + scrollLength: 701, + scrollPending: scroll, + sizesKnown: new Map([["item_999", 441]]), + ...stateOverrides, + }, + ); +} + +export function createEndAlignedScrollTarget(animated: boolean) { + return { + animated, + index: 999, + offset: 397479, + targetOffset: 397179, + viewOffset: 0, + viewPosition: 1, + } as any; +} diff --git a/__tests__/core/checkFinishedScroll.test.ts b/__tests__/core/checkFinishedScroll.test.ts index d2014d44b..bc605e3de 100644 --- a/__tests__/core/checkFinishedScroll.test.ts +++ b/__tests__/core/checkFinishedScroll.test.ts @@ -3,6 +3,11 @@ import "../setup"; import { checkFinishedScroll, checkFinishedScrollFallback } from "../../src/core/checkFinishedScroll"; import { Platform } from "../../src/platform/Platform"; +import { + createEndAlignedScrollContext, + createEndAlignedScrollTarget, + type RecordedScrollTo, +} from "../__mocks__/createEndAlignedScrollContext"; import { createMockContext } from "../__mocks__/createMockContext"; describe("checkFinishedScrollFallback", () => { @@ -142,40 +147,10 @@ describe("checkFinishedScrollFallback", () => { it("retries an unresolved iOS scroll to end at the current measured end target", () => { Platform.OS = "ios"; - const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; - const data = Array.from({ length: 1000 }, (_, index) => ({ id: index })); - const positions = Array.from({ length: 1000 }, (_, index) => index * 401); - positions[999] = 394259; - - const ctx = createMockContext( - { totalSize: 394700 }, - { - didContainersLayout: true, - hasScrolled: true, - positions, - props: { - data, - estimatedItemSize: 401, - } as any, - refScroller: { - current: { - scrollTo: (params: { animated: boolean; x: number; y: number }) => scrollToCalls.push(params), - }, - } as any, - scroll: 393753.3333333333, - scrollingTo: { - animated: true, - index: 999, - offset: 397479, - targetOffset: 397179, - viewOffset: 0, - viewPosition: 1, - } as any, - scrollLength: 701, - scrollPending: 393753.3333333333, - sizesKnown: new Map([["item_999", 441]]), - }, - ); + const scrollToCalls: RecordedScrollTo[] = []; + const ctx = createEndAlignedScrollContext(393753.3333333333, scrollToCalls, { + scrollingTo: createEndAlignedScrollTarget(true), + }); checkFinishedScrollFallback(ctx); @@ -191,40 +166,10 @@ describe("checkFinishedScrollFallback", () => { it("retries an unresolved unanimated iOS scroll to end without animation", () => { Platform.OS = "ios"; - const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; - const data = Array.from({ length: 1000 }, (_, index) => ({ id: index })); - const positions = Array.from({ length: 1000 }, (_, index) => index * 401); - positions[999] = 394259; - - const ctx = createMockContext( - { totalSize: 394700 }, - { - didContainersLayout: true, - hasScrolled: true, - positions, - props: { - data, - estimatedItemSize: 401, - } as any, - refScroller: { - current: { - scrollTo: (params: { animated: boolean; x: number; y: number }) => scrollToCalls.push(params), - }, - } as any, - scroll: 393753.3333333333, - scrollingTo: { - animated: false, - index: 999, - offset: 397479, - targetOffset: 397179, - viewOffset: 0, - viewPosition: 1, - } as any, - scrollLength: 701, - scrollPending: 393753.3333333333, - sizesKnown: new Map([["item_999", 441]]), - }, - ); + const scrollToCalls: RecordedScrollTo[] = []; + const ctx = createEndAlignedScrollContext(393753.3333333333, scrollToCalls, { + scrollingTo: createEndAlignedScrollTarget(false), + }); checkFinishedScrollFallback(ctx); diff --git a/__tests__/core/endAlignedScrollTarget.test.ts b/__tests__/core/endAlignedScrollTarget.test.ts index a75b6116c..b69641b8d 100644 --- a/__tests__/core/endAlignedScrollTarget.test.ts +++ b/__tests__/core/endAlignedScrollTarget.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import "../setup"; import { maybeCorrectEndAlignedScrollAfterCommit } from "../../src/core/endAlignedScrollTarget"; -import { createMockContext } from "../__mocks__/createMockContext"; +import { + createEndAlignedScrollContext, + createEndAlignedScrollTarget, + type RecordedScrollTo, +} from "../__mocks__/createEndAlignedScrollContext"; describe("maybeCorrectEndAlignedScrollAfterCommit", () => { let originalRequestAnimationFrame: typeof globalThis.requestAnimationFrame; @@ -19,90 +23,48 @@ describe("maybeCorrectEndAlignedScrollAfterCommit", () => { globalThis.requestAnimationFrame = originalRequestAnimationFrame; }); - // End content at 394259 + 441 = 394700, scrollLength 701 → end target 393999. - const createEndAlignedContext = ( - scroll: number, - scrollToCalls: Array<{ animated: boolean; x: number; y: number }>, - ) => { - const data = Array.from({ length: 1000 }, (_, index) => ({ id: index })); - const positions = Array.from({ length: 1000 }, (_, index) => index * 401); - positions[999] = 394259; - - return createMockContext( - { totalSize: 394700 }, - { - didContainersLayout: true, - hasScrolled: true, - positions, - props: { - data, - estimatedItemSize: 401, - } as any, - refScroller: { - current: { - scrollTo: (params: { animated: boolean; x: number; y: number }) => scrollToCalls.push(params), - }, - } as any, - scroll, - scrollLength: 701, - scrollPending: scroll, - sizesKnown: new Map([["item_999", 441]]), - }, - ); - }; - - const endAlignedTarget = (animated: boolean) => - ({ - animated, - index: 999, - offset: 397479, - targetOffset: 397179, - viewOffset: 0, - viewPosition: 1, - }) as any; - it("glides a large remainder when the finished session was animated", () => { - const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; - const ctx = createEndAlignedContext(393753, scrollToCalls); + const scrollToCalls: RecordedScrollTo[] = []; + const ctx = createEndAlignedScrollContext(393753, scrollToCalls); - maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(true)); + maybeCorrectEndAlignedScrollAfterCommit(ctx, createEndAlignedScrollTarget(true)); expect(scrollToCalls).toEqual([{ animated: true, x: 0, y: 393999 }]); }); it("snaps a small residue instantly even when the finished session was animated", () => { - const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; - const ctx = createEndAlignedContext(393969, scrollToCalls); + const scrollToCalls: RecordedScrollTo[] = []; + const ctx = createEndAlignedScrollContext(393969, scrollToCalls); - maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(true)); + maybeCorrectEndAlignedScrollAfterCommit(ctx, createEndAlignedScrollTarget(true)); expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 393999 }]); }); it("corrects unanimated sessions without animation", () => { - const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; - const ctx = createEndAlignedContext(393753, scrollToCalls); + const scrollToCalls: RecordedScrollTo[] = []; + const ctx = createEndAlignedScrollContext(393753, scrollToCalls); - maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(false)); + maybeCorrectEndAlignedScrollAfterCommit(ctx, createEndAlignedScrollTarget(false)); expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 393999 }]); }); it("does not dispatch when already at the corrected target", () => { - const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; - const ctx = createEndAlignedContext(393999, scrollToCalls); + const scrollToCalls: RecordedScrollTo[] = []; + const ctx = createEndAlignedScrollContext(393999, scrollToCalls); - maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(true)); + maybeCorrectEndAlignedScrollAfterCommit(ctx, createEndAlignedScrollTarget(true)); expect(scrollToCalls).toEqual([]); }); it("bails when a new scroll session started before the correction frame", () => { - const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = []; - const ctx = createEndAlignedContext(393753, scrollToCalls); - ctx.state.scrollingTo = endAlignedTarget(true); + const scrollToCalls: RecordedScrollTo[] = []; + const ctx = createEndAlignedScrollContext(393753, scrollToCalls); + ctx.state.scrollingTo = createEndAlignedScrollTarget(true); - maybeCorrectEndAlignedScrollAfterCommit(ctx, endAlignedTarget(true)); + maybeCorrectEndAlignedScrollAfterCommit(ctx, createEndAlignedScrollTarget(true)); expect(scrollToCalls).toEqual([]); }); diff --git a/src/core/checkFinishedScroll.ts b/src/core/checkFinishedScroll.ts index 6e0c71f94..77efb29c9 100644 --- a/src/core/checkFinishedScroll.ts +++ b/src/core/checkFinishedScroll.ts @@ -1,7 +1,9 @@ import { clampScrollOffset } from "@/core/clampScrollOffset"; import { + END_ALIGNED_COMPLETION_EPSILON, getCurrentTargetOffset, isEndAlignedLastItemTarget, + redispatchEndAlignedTarget, scrollToFallbackOffset, } from "@/core/endAlignedScrollTarget"; import { finishScrollTo } from "@/core/finishScrollTo"; @@ -13,7 +15,6 @@ import type { StateContext } from "@/state/state"; type ActiveScrollTarget = NonNullable; const INITIAL_SCROLL_MAX_FALLBACK_CHECKS = 20; const MAX_FALLBACK_CHECKS_PER_SESSION = 40; -const END_ALIGNED_COMPLETION_EPSILON = 30; const INITIAL_SCROLL_COMPLETION_TARGET_EPSILON = 1; const INITIAL_SCROLL_ZERO_TARGET_EPSILON = 1; const SILENT_INITIAL_SCROLL_RETRY_DELAY_MS = 16; @@ -238,11 +239,7 @@ export function checkFinishedScrollFallback(ctx: StateContext) { }); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { - // An animated dispatch gets clamped short when the end target sits - // beyond the natively committed range (uncommitted total size or end - // inset). Retry with the session's animation so the remaining distance - // glides instead of teleporting. - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); + redispatchEndAlignedTarget(ctx, isStillScrollingTo, completionState.clampedTargetOffset); scheduleFallbackCheck(100); } else if ( shouldFinishZeroTarget || diff --git a/src/core/endAlignedScrollTarget.ts b/src/core/endAlignedScrollTarget.ts index cd69266fa..ea55c745d 100644 --- a/src/core/endAlignedScrollTarget.ts +++ b/src/core/endAlignedScrollTarget.ts @@ -5,7 +5,11 @@ import type { StateContext } from "@/state/state"; type ActiveScrollTarget = NonNullable; -const ANIMATED_CORRECTION_MIN_DISTANCE = 40; +// End-aligned targets move while content grows (pendingTotalSize keeps them +// ~one commit ahead of the reachable native range), so completion and +// re-dispatch share this slack: sessions finish within it, and re-dispatched +// remainders beyond it glide while smaller ones snap imperceptibly. +export const END_ALIGNED_COMPLETION_EPSILON = 30; export function isEndAlignedLastItemTarget(ctx: StateContext, scrollingTo: ActiveScrollTarget) { return scrollingTo.index === ctx.state.props.data.length - 1 && scrollingTo.viewPosition === 1; @@ -23,7 +27,21 @@ export function getCurrentTargetOffset(ctx: StateContext, scrollingTo: ActiveScr return clampScrollOffset(ctx, requestedTargetOffset, scrollingTo); } -export function scrollToFallbackOffset(ctx: StateContext, offset: number, animated = false) { +export function scrollToFallbackOffset(ctx: StateContext, offset: number) { + dispatchScrollTo(ctx, offset, false); +} + +// Re-dispatch an end-aligned session at its recomputed target. An animated +// dispatch gets clamped short when the target sits beyond the natively +// committed range (uncommitted total size or end inset), so an animated +// session with a remainder beyond the completion slack glides the rest of +// the way instead of teleporting; smaller remainders snap imperceptibly. +export function redispatchEndAlignedTarget(ctx: StateContext, scrollingTo: ActiveScrollTarget, target: number) { + const animated = !!scrollingTo.animated && target - ctx.state.scroll > END_ALIGNED_COMPLETION_EPSILON; + dispatchScrollTo(ctx, target, animated); +} + +function dispatchScrollTo(ctx: StateContext, offset: number, animated: boolean) { ctx.state.refScroller.current?.scrollTo({ animated, x: ctx.state.props.horizontal ? offset : 0, @@ -37,10 +55,7 @@ export function scrollToFallbackOffset(ctx: StateContext, offset: number, animat // beyond the reachable range and retries could not move past it. Once the // committed size lands natively (two frames), re-dispatch a single correction // toward the end. One-shot and end-directed, so it cannot loop or fight the -// user. A large remainder on an animated session means the original dispatch -// was clamped short (uncommitted size or end inset), so the correction glides -// instead of teleporting; small residue snaps instantly to keep the settle -// imperceptible. +// user. export function maybeCorrectEndAlignedScrollAfterCommit(ctx: StateContext, scrollingTo: ActiveScrollTarget) { const state = ctx.state; if (!isEndAlignedLastItemTarget(ctx, scrollingTo)) { @@ -54,9 +69,7 @@ export function maybeCorrectEndAlignedScrollAfterCommit(ctx: StateContext, scrol } const correctedTarget = getCurrentTargetOffset(ctx, scrollingTo); if (correctedTarget > state.scroll + 1) { - const animated = - !!scrollingTo.animated && correctedTarget - state.scroll > ANIMATED_CORRECTION_MIN_DISTANCE; - scrollToFallbackOffset(ctx, correctedTarget, animated); + redispatchEndAlignedTarget(ctx, scrollingTo, correctedTarget); } }); });