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
27 changes: 27 additions & 0 deletions __tests__/core/doMaintainScrollAtEnd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
45 changes: 43 additions & 2 deletions __tests__/core/updateItemSize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
});

Expand All @@ -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();
});

Expand Down
26 changes: 17 additions & 9 deletions src/core/doMaintainScrollAtEnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ 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,
pendingNativeMVCPAdjust,
refScroller,
props: { maintainScrollAtEnd },
} = state;
const animated = options?.animated ?? maintainScrollAtEnd?.animated;
const immediate = options?.immediate === true;
const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
const shouldMaintainScrollAtEnd = !!(
isWithinMaintainScrollAtEndThreshold &&
Expand All @@ -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;
Expand All @@ -50,30 +52,36 @@ 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(
() => {
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;
Expand Down
9 changes: 7 additions & 2 deletions src/core/updateItemSizes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}

Expand Down Expand Up @@ -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;
}

Expand Down