Skip to content
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 34 additions & 27 deletions src/core/checkFinishedScroll.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +12,8 @@ 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 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;
Expand Down Expand Up @@ -97,22 +102,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 +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)),
};
}

Expand All @@ -150,18 +146,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 +185,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
Expand Down
56 changes: 56 additions & 0 deletions src/core/endAlignedScrollTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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"]>;

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Convert RTL horizontal correction offsets before dispatch

For horizontal RTL lists on native, the new post-commit correction path passes correctedTarget as a logical offset directly to scrollTo. The normal doScrollTo path converts horizontal RTL offsets with toNativeHorizontalOffset; without that conversion, an inverted/negative RTL scroller can receive the wrong native x value and jump away from the end when this delayed correction fires after an end-aligned scroll.

Useful? React with 👍 / 👎.

}
});
});
}
2 changes: 2 additions & 0 deletions src/core/finishScrollTo.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -45,6 +46,7 @@ export function finishScrollTo(ctx: StateContext) {
}

recalculateSettledScroll(ctx);
maybeCorrectEndAlignedScrollAfterCommit(ctx, scrollingTo);
resolvePendingScroll?.();
}
}
2 changes: 2 additions & 0 deletions src/types.internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ export interface InternalState {
timeouts: Set<number>;
timeoutSetPaddingTop?: any;
timeoutCheckFinishedScrollFallback?: any;
fallbackScrollSession?: InternalScrollTarget | undefined;
fallbackScrollSessionChecks?: number;
totalSize: number;
triggerCalculateItemsInView?: (params?: {
doMVCP?: boolean;
Expand Down