mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(app): preserve scroll ownership through momentum
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user