fix(app): preserve scroll ownership through momentum

This commit is contained in:
Mohamed Boudra
2026-07-23 01:04:00 +02:00
parent c81c7bb7c0
commit f00a940e74
3 changed files with 101 additions and 32 deletions

View File

@@ -257,6 +257,35 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.prepareForStickyContentChange();
harness.driver.beginUserScroll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 1,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
pendingRequest: null,
pendingVerification: null,
});
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
const harness = createDriverHarness({
isNearBottom: false,
@@ -774,18 +803,4 @@ describe("controller helper predicates", () => {
}),
).toBe(true);
});
it("lets an active user drag override pending sticky verification", () => {
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 48,
hasPendingRequest: false,
hasPendingVerification: true,
hasUnverifiedStickyMeasurementChange: true,
isUserScroll: true,
}),
).toBe(true);
});
});

View File

@@ -68,6 +68,8 @@ interface BottomAnchorControllerDriver {
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
endUserScroll: () => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
@@ -84,7 +86,6 @@ interface BottomAnchorControllerDriver {
handleScrollNearBottomChange: (params: {
nextIsNearBottom: boolean;
scrollDelta: number;
isUserScroll?: boolean;
}) => void;
notifyAuthoritativeHistoryMaybeChanged: () => void;
reevaluate: (animated?: boolean) => void;
@@ -244,6 +245,7 @@ function createBottomAnchorControllerDriver(
let lastRouteRequestKey: string | null = null;
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
let isUserScrollActive = false;
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
@@ -482,6 +484,7 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
@@ -498,6 +501,21 @@ function createBottomAnchorControllerDriver(
requestLocalAnchor(request) {
createRequest(request);
},
beginUserScroll() {
isUserScrollActive = true;
cancelPendingRequest("user_scroll_started");
},
endUserScroll() {
isUserScrollActive = false;
if (mode !== "sticky-bottom") {
return;
}
if (input.isNearBottom()) {
markStickyMeasurementVerified();
return;
}
this.detachByUser();
},
detachByUser() {
if (mode === "detached") {
return;
@@ -512,6 +530,9 @@ function createBottomAnchorControllerDriver(
) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
@@ -530,6 +551,9 @@ function createBottomAnchorControllerDriver(
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
@@ -559,6 +583,9 @@ function createBottomAnchorControllerDriver(
return;
}
markStickyMeasurementChanged();
if (isUserScrollActive) {
return;
}
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
@@ -571,7 +598,13 @@ function createBottomAnchorControllerDriver(
evaluate(false, "content_size_change");
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta, isUserScroll = false } = params;
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
if (mode === "sticky-bottom" && !nextIsNearBottom) {
this.detachByUser();
}
return;
}
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
@@ -589,7 +622,6 @@ function createBottomAnchorControllerDriver(
hasPendingRequest: pendingRequest !== null,
hasPendingVerification: pendingVerification !== null,
hasUnverifiedStickyMeasurementChange,
isUserScroll,
})
) {
this.detachByUser();
@@ -659,16 +691,14 @@ export const __private__ = {
hasPendingRequest: boolean;
hasPendingVerification: boolean;
hasUnverifiedStickyMeasurementChange: boolean;
isUserScroll?: boolean;
}): boolean {
const scrolledAwayIntentionally = Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
return (
input.mode === "sticky-bottom" &&
!input.nextIsNearBottom &&
(input.isUserScroll ||
(!input.hasPendingRequest &&
!input.hasPendingVerification &&
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)))
!input.hasPendingRequest &&
!input.hasPendingVerification &&
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)
);
},
};
@@ -741,6 +771,12 @@ export function useBottomAnchorController(input: {
requestLocalAnchor(request: BottomAnchorLocalRequest) {
driverRef.current?.requestLocalAnchor(request);
},
beginUserScroll() {
driverRef.current?.beginUserScroll();
},
endUserScroll() {
driverRef.current?.endUserScroll();
},
detachByUser() {
driverRef.current?.detachByUser();
},
@@ -762,11 +798,7 @@ export function useBottomAnchorController(input: {
prepareForStickyContentChange() {
driverRef.current?.prepareForStickyContentChange();
},
handleScrollNearBottomChange(params: {
nextIsNearBottom: boolean;
scrollDelta: number;
isUserScroll?: boolean;
}) {
handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) {
driverRef.current?.handleScrollNearBottomChange(params);
},
reevaluate(animated = false) {

View File

@@ -90,6 +90,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
@@ -130,6 +131,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const clearPendingUserScrollEnd = useCallback(() => {
if (userScrollEndFrameIdRef.current !== null) {
cancelAnimationFrame(userScrollEndFrameIdRef.current);
userScrollEndFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
@@ -210,6 +218,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
@@ -218,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
};
}, [agentId, clearNativeViewportSettling]);
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
useEffect(() => {
const keyboardEvents = [
@@ -303,32 +313,44 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onNearHistoryStart();
}
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
if (
!isUserScrollActiveRef.current &&
programmaticScrollEventBudgetRef.current > 0 &&
contentOffset.y <= 8
) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
isUserScroll: isUserScrollActiveRef.current,
});
}
});
const handleScrollBeginDrag = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
bottomAnchorController.beginUserScroll();
});
const handleScrollEndDrag = useStableEvent(() => {
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll();
});
});
const handleMomentumScrollBegin = useStableEvent(() => {
isUserScrollActiveRef.current = true;
clearPendingUserScrollEnd();
});
const handleMomentumScrollEnd = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll();
});
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {