Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions __tests__/core/checkFinishedScroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import "../setup";
import { interruptUserScroll } from "../../src/core/cancelScroll";
import { checkFinishedScroll, checkFinishedScrollFallback } from "../../src/core/checkFinishedScroll";
import { doScrollTo } from "../../src/core/doScrollTo.native";
import { finishScrollTo } from "../../src/core/finishScrollTo";
import { getScrollRequestTracker } from "../../src/core/scrollRequestTracker";
import { Platform } from "../../src/platform/Platform";
import { requestAdjust } from "../../src/utils/requestAdjust";
import { createMockContext } from "../__mocks__/createMockContext";

describe("checkFinishedScrollFallback", () => {
Expand Down Expand Up @@ -725,6 +727,139 @@ describe("checkFinishedScrollFallback", () => {
expect(ctx.state.didFinishInitialScroll).toBe(true);
});

it("returns a silent Android retry to its target when the scroll finishes before the retry frame", () => {
Platform.OS = "android";
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
}) as typeof globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
globalThis.cancelAnimationFrame = ((handle: number) => {
frames[handle - 1] = () => {};
}) as typeof globalThis.cancelAnimationFrame;
const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = [];
const ctx = createMockContext(
{},
{
didContainersLayout: true,
hasScrolled: false,
initialScrollSession: {
completion: {
didDispatchNativeScroll: true,
watchdog: {
targetOffset: 220,
},
},
kind: "offset",
previousDataLength: 0,
} as any,
refScroller: {
current: {
scrollTo: (params: { animated: boolean; x: number; y: number }) => scrollToCalls.push(params),
},
} as any,
scroll: 220,
scrollingTo: {
animated: false,
index: 99,
isInitialScroll: true,
offset: 220,
targetOffset: 220,
viewOffset: 0,
} as any,
scrollPending: 220,
},
);

try {
checkFinishedScrollFallback(ctx);
flushTimers(1);
expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 219 }]);

// Another completion check finishes the scroll while the retry frame is still pending.
finishScrollTo(ctx);
for (const frame of frames.splice(0)) {
frame(0);
}

// The list returns to its target once, instead of staying one point away from it.
expect(scrollToCalls).toEqual([
{ animated: false, x: 0, y: 219 },
{ animated: false, x: 0, y: 220 },
]);
expect(ctx.state.scrollingTo).toBeUndefined();
} finally {
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
}
});

it("returns a silent Android retry to the target an adjustment moved while the nudge was pending", () => {
Platform.OS = "android";
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
}) as typeof globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
globalThis.cancelAnimationFrame = ((handle: number) => {
frames[handle - 1] = () => {};
}) as typeof globalThis.cancelAnimationFrame;
const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = [];
const ctx = createMockContext(
{},
{
didContainersLayout: true,
hasScrolled: false,
initialScrollSession: {
completion: {
didDispatchNativeScroll: true,
watchdog: {
targetOffset: 220,
},
},
kind: "offset",
previousDataLength: 0,
} as any,
refScroller: {
current: {
scrollTo: (params: { animated: boolean; x: number; y: number }) => scrollToCalls.push(params),
},
} as any,
scroll: 220,
scrollingTo: {
animated: false,
index: 99,
isInitialScroll: true,
offset: 220,
targetOffset: 220,
viewOffset: 0,
} as any,
scrollPending: 220,
},
);

try {
checkFinishedScrollFallback(ctx);
flushTimers(1);
expect(scrollToCalls).toEqual([{ animated: false, x: 0, y: 219 }]);

// Rows above the target measure smaller while the nudge is pending, which moves the target up.
requestAdjust(ctx, -36, "item-size");
for (const frame of frames.splice(0)) {
frame(0);
}

// The retry returns to the moved target instead of the offset recorded before the adjustment.
expect(scrollToCalls).toEqual([
{ animated: false, x: 0, y: 219 },
{ animated: false, x: 0, y: 184 },
]);
} finally {
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
}
});

it("does not finish a silent iOS bootstrap dispatch before native scroll is observed", () => {
Platform.OS = "ios";
const scrollToCalls: Array<{ animated: boolean; x: number; y: number }> = [];
Expand Down
30 changes: 30 additions & 0 deletions __tests__/utils/requestAdjust.oldarch.cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,34 @@ describe("requestAdjust old-architecture cases", () => {
expect(ctx.state.ignoreScrollFromMVCP).toEqual({ lt: 175 });
expect(scheduledDelay).toBe(250);
});

it("reveals the list after a queued Android data-change workaround runs", () => {
Platform.OS = "android";
const ctx = createMockContext(
{ readyToRender: false },
{
didContainersLayout: true,
didFinishInitialScroll: true,
props: { horizontal: false },
scroll: 100,
scrollAdjustHandler: {
getAdjust: () => 0,
requestAdjust: mock(),
} as any,
},
);
const originalRAF = globalThis.requestAnimationFrame;
const frames: Array<() => void> = [];
globalThis.requestAnimationFrame = ((callback: () => void) => frames.push(callback)) as any;

try {
requestAdjust(ctx, 150, "data");
frames[0]();
} finally {
globalThis.requestAnimationFrame = originalRAF;
}

expect(doScrollTo.mock.calls.at(-1)?.[1]).toEqual({ horizontal: false, isAdjustment: true, offset: 250 });
expect(ctx.values.get("readyToRender")).toBe(true);
});
});
100 changes: 100 additions & 0 deletions __tests__/utils/requestAdjust.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import "../setup"; // Import global test setup

import { doScrollTo } from "../../src/core/doScrollTo.native";
import { initialScrollWatchdog } from "../../src/core/initialScrollSession";
import { Platform } from "../../src/platform/Platform";
import type { StateContext } from "../../src/state/state";
import type { InternalState } from "../../src/types.internal";
import { requestAdjust } from "../../src/utils/requestAdjust";
import { setInitialRenderState } from "../../src/utils/setInitialRenderState";
import { createMockContext } from "../__mocks__/createMockContext";

describe("requestAdjust", () => {
Expand Down Expand Up @@ -185,6 +188,66 @@ describe("requestAdjust", () => {
expect(scrollAdjustHandlerCalls[0]).toBe(25);
});

it("keeps the list hidden until an adjustment queued before it was ready is applied", () => {
// An item above the initial scroll target measured smaller than its estimate while the list was hidden
mockState.didFinishInitialScroll = false;
mockCtx.values.set("readyToRender", false);
const adjustCountsAtLoad: number[] = [];
mockState.props.onLoad = () => adjustCountsAtLoad.push(scrollAdjustHandlerCalls.length);

requestAdjust(mockCtx, -45, "item-size");
setInitialRenderState(mockCtx, { didInitialScroll: true });

expect(mockCtx.values.get("readyToRender")).toBe(false);

rafCallbacks[0]();

expect(scrollAdjustHandlerCalls).toEqual([-45]);
expect(mockCtx.values.get("readyToRender")).toBe(true);
expect(adjustCountsAtLoad).toEqual([1]);
});

it("becomes ready after the last of several queued adjustments", () => {
mockState.didContainersLayout = mockState.didFinishInitialScroll = true;
mockCtx.values.set("readyToRender", false);

requestAdjust(mockCtx, 10);
requestAdjust(mockCtx, 5);
rafCallbacks[0]();

expect(mockCtx.values.get("readyToRender")).toBe(false);

rafCallbacks[1]();

expect(scrollAdjustHandlerCalls).toEqual([10, 5]);
expect(mockCtx.values.get("readyToRender")).toBe(true);
});

it("skips a deferred adjustment that a later native scroll already includes", () => {
// Positions move right away, so a native scroll issued before the frame already targets the
// adjusted offset. Moving the anchor as well would shift the list a second time.
mockCtx.values.set("readyToRender", false);
mockState.refScroller = { current: { scrollTo: () => {} } } as any;

requestAdjust(mockCtx, -36, "item-size");
doScrollTo(mockCtx, { offset: mockState.scroll });
rafCallbacks[0]();

expect(scrollAdjustHandlerCalls).toHaveLength(0);
expect(mockState.adjustingFromInitialMount).toBe(0);
});

it("applies a deferred adjustment when no native scroll lands before its frame", () => {
mockCtx.values.set("readyToRender", false);
mockState.refScroller = { current: { scrollTo: () => {} } } as any;

doScrollTo(mockCtx, { offset: mockState.scroll });
requestAdjust(mockCtx, -36, "item-size");
rafCallbacks[0]();

expect(scrollAdjustHandlerCalls).toEqual([-36]);
});

it("should handle undefined readyToRender as falsy", () => {
mockState.didContainersLayout = mockState.didFinishInitialScroll = true;
mockCtx.values.delete("readyToRender");
Expand All @@ -196,6 +259,43 @@ describe("requestAdjust", () => {
});
});

describe("in-flight initial scroll targets", () => {
const startInitialScroll = () => {
mockState.scrollingTo = {
animated: false,
index: 37,
isInitialScroll: true,
offset: 1618,
targetOffset: 1578,
viewOffset: 40,
} as any;
mockState.initialScrollSession = {
completion: { watchdog: { startScroll: 0, targetOffset: 1578 } },
kind: "bootstrap",
} as any;
};

it("moves a native initial scroll target with the adjustment", () => {
// Retries reissue the stored target, which has to follow the content like state.scroll does.
startInitialScroll();

requestAdjust(mockCtx, -36, "item-size");

expect(mockState.scrollingTo).toMatchObject({ offset: 1582, targetOffset: 1542 });
expect(initialScrollWatchdog.get(mockState)?.targetOffset).toBe(1542);
});

it("leaves web initial scroll targets to the index-based retargeting", () => {
Platform.OS = "web";
startInitialScroll();

requestAdjust(mockCtx, -36, "item-size");

expect(mockState.scrollingTo).toMatchObject({ offset: 1618, targetOffset: 1578 });
expect(initialScrollWatchdog.get(mockState)?.targetOffset).toBe(1578);
});
});

describe("MVCP ignore logic", () => {
const requestDataAdjust = (positionDiff: number) => requestAdjust(mockCtx, positionDiff, "data");

Expand Down
4 changes: 3 additions & 1 deletion src/core/cancelImperativeScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { type StateContext, set$ } from "@/state/state";

type InternalState = StateContext["state"];

export function cancelScrollCompletionChecks({ scheduledWork }: InternalState) {
export function cancelScrollCompletionChecks(state: InternalState) {
const { scheduledWork } = state;
state.silentInitialScrollReturn = undefined;
scheduledWork.cancel("checkFinishedScrollFrame");
scheduledWork.cancel("checkFinishedScrollRetryFrame");
scheduledWork.cancel("checkFinishedScrollFallback");
Expand Down
15 changes: 11 additions & 4 deletions src/core/checkFinishedScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ function checkFinishedScrollFrame(ctx: StateContext) {
}

function scrollToFallbackOffset(ctx: StateContext, offset: number, animated = false) {
if (Platform.OS !== "web") {
// Like doScrollTo, a retry targets an offset that already includes the adjustments requested so far.
ctx.state.nativeScrollEpoch = (ctx.state.nativeScrollEpoch ?? 0) + 1;
}
ctx.state.refScroller.current?.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) });
}

Expand Down Expand Up @@ -230,10 +234,13 @@ export function checkFinishedScrollFallback(ctx: StateContext) {
: targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON;
initialScrollCompletion.markSilentInitialScrollRetry(state);
scrollToFallbackOffset(ctx, jiggleOffset);
state.scheduledWork.frame(
() => scrollToFallbackOffset(ctx, targetOffset),
"checkFinishedScrollRetryFrame",
);
// Adjustments can move the target while the nudge is pending, so return to its current offset.
const returnToTarget = () => {
state.silentInitialScrollReturn = undefined;
scrollToFallbackOffset(ctx, initialScrollWatchdog.get(state)?.targetOffset ?? targetOffset);
};
state.silentInitialScrollReturn = returnToTarget;
state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame");
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
} else if (shouldRetryUnalignedEndScroll) {
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
Expand Down
2 changes: 2 additions & 0 deletions src/core/doScrollTo.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export function doScrollTo(ctx: StateContext, params: DoScrollToParams) {
if (!params.isAdjustment) {
// Explicit requests own their momentum; MVCP re-assertions keep the gesture's ownership.
state.userScrollContentSize = undefined;
// The offset is computed from the current positions, so it already includes every adjustment requested so far.
state.nativeScrollEpoch = (state.nativeScrollEpoch ?? 0) + 1;
}

scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) });
Expand Down
3 changes: 3 additions & 0 deletions src/core/finishScrollTo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import type { StateContext } from "@/state/state";
export function finishScrollTo(ctx: StateContext) {
const state = ctx.state;
if (state?.scrollingTo) {
// A silent-dispatch retry nudges the native offset away and returns on the next frame. Another check can
// finish the scroll first, so return now instead of leaving the list one point off its target.
state.silentInitialScrollReturn?.();
cancelScrollCompletionChecks(state);
// An older active scroll can finish while its replacement still waits for layout.
// Its completion must not settle or release the newer request's ownership.
Expand Down
4 changes: 4 additions & 0 deletions src/types.internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ export interface InternalState {
// Content size at release while a user gesture awaits settlement.
userScrollContentSize?: number;
layoutAdjustmentEpoch?: number;
/** Counts absolute native scrolls, so an adjustment deferred before one of them is not applied again. */
nativeScrollEpoch?: number;
/** Moves a silently dispatched initial scroll back onto its target after the retry nudged it away. */
silentInitialScrollReturn?: () => void;
endFollowInterrupted?: boolean;
visibleContentAnchor?: { key: string; offset: number; sizeOnly?: true };
maintainingScrollAtEnd?: MaintainingScrollAtEndState;
Expand Down
Loading