Skip to content
49 changes: 49 additions & 0 deletions __tests__/__mocks__/createEndAlignedScrollContext.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {},
) {
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;
}
58 changes: 23 additions & 35 deletions __tests__/core/checkFinishedScroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -142,45 +147,15 @@ 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);

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;
Expand All @@ -189,6 +164,19 @@ describe("checkFinishedScrollFallback", () => {
expect(ctx.state.scrollingTo).toBeUndefined();
});

it("retries an unresolved unanimated iOS scroll to end without animation", () => {
Platform.OS = "ios";
const scrollToCalls: RecordedScrollTo[] = [];
const ctx = createEndAlignedScrollContext(393753.3333333333, scrollToCalls, {
scrollingTo: createEndAlignedScrollTarget(false),
});

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 }> = [];
Expand Down
71 changes: 71 additions & 0 deletions __tests__/core/endAlignedScrollTarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import "../setup";

import { maybeCorrectEndAlignedScrollAfterCommit } from "../../src/core/endAlignedScrollTarget";
import {
createEndAlignedScrollContext,
createEndAlignedScrollTarget,
type RecordedScrollTo,
} from "../__mocks__/createEndAlignedScrollContext";

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;
});

it("glides a large remainder when the finished session was animated", () => {
const scrollToCalls: RecordedScrollTo[] = [];
const ctx = createEndAlignedScrollContext(393753, scrollToCalls);

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: RecordedScrollTo[] = [];
const ctx = createEndAlignedScrollContext(393969, scrollToCalls);

maybeCorrectEndAlignedScrollAfterCommit(ctx, createEndAlignedScrollTarget(true));

expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 393999 }]);
});

it("corrects unanimated sessions without animation", () => {
const scrollToCalls: RecordedScrollTo[] = [];
const ctx = createEndAlignedScrollContext(393753, scrollToCalls);

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: RecordedScrollTo[] = [];
const ctx = createEndAlignedScrollContext(393999, scrollToCalls);

maybeCorrectEndAlignedScrollAfterCommit(ctx, createEndAlignedScrollTarget(true));

expect(scrollToCalls).toEqual([]);
});

it("bails when a new scroll session started before the correction frame", () => {
const scrollToCalls: RecordedScrollTo[] = [];
const ctx = createEndAlignedScrollContext(393753, scrollToCalls);
ctx.state.scrollingTo = createEndAlignedScrollTarget(true);

maybeCorrectEndAlignedScrollAfterCommit(ctx, createEndAlignedScrollTarget(true));

expect(scrollToCalls).toEqual([]);
});
});
60 changes: 32 additions & 28 deletions src/core/checkFinishedScroll.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { calculateOffsetForIndex } from "@/core/calculateOffsetForIndex";
import { calculateOffsetWithOffsetPosition } from "@/core/calculateOffsetWithOffsetPosition";
import { clampScrollOffset } from "@/core/clampScrollOffset";
import {
END_ALIGNED_COMPLETION_EPSILON,
getCurrentTargetOffset,
isEndAlignedLastItemTarget,
redispatchEndAlignedTarget,
scrollToFallbackOffset,
} from "@/core/endAlignedScrollTarget";
import { finishScrollTo } from "@/core/finishScrollTo";
import { initialScrollCompletion, initialScrollWatchdog } from "@/core/initialScrollSession";
import { Platform } from "@/platform/Platform";
Expand All @@ -9,6 +14,7 @@ import type { StateContext } from "@/state/state";

type ActiveScrollTarget = NonNullable<StateContext["state"]["scrollingTo"]>;
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;
Expand Down Expand Up @@ -97,22 +103,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;
Expand All @@ -123,10 +113,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)),
};
}

Expand All @@ -150,18 +147,17 @@ 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) {
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);
Expand Down Expand Up @@ -190,6 +186,14 @@ export function checkFinishedScrollFallback(ctx: StateContext) {
const isStillScrollingTo = state.scrollingTo;
if (isStillScrollingTo) {
numChecks++;
// 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;
}
const isNativeInitialPending = isNativeInitialNonZeroTarget(state) && !state.hasScrolled;
const maxChecks = silentInitialDispatch
? 5
Expand Down Expand Up @@ -235,7 +239,7 @@ export function checkFinishedScrollFallback(ctx: StateContext) {
});
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
} else if (shouldRetryUnalignedEndScroll) {
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
redispatchEndAlignedTarget(ctx, isStillScrollingTo, completionState.clampedTargetOffset);
scheduleFallbackCheck(100);
} else if (
shouldFinishZeroTarget ||
Expand Down
76 changes: 76 additions & 0 deletions src/core/endAlignedScrollTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { calculateOffsetForIndex } from "@/core/calculateOffsetForIndex";
import { calculateOffsetWithOffsetPosition } from "@/core/calculateOffsetWithOffsetPosition";
import { clampScrollOffset } from "@/core/clampScrollOffset";
import type { StateContext } from "@/state/state";

type ActiveScrollTarget = NonNullable<StateContext["state"]["scrollingTo"]>;

// 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;
}

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) {
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,
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 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) {
redispatchEndAlignedTarget(ctx, scrollingTo, correctedTarget);
}
});
});
}
Loading