Skip to content

fix(scroll): keep Android directional swipe endpoints inside the resolved scrollable - #3746

Open
airowe wants to merge 1 commit into
callstack:mainfrom
airowe:fix/scroll-clamp-to-container
Open

airowe wants to merge 1 commit into
callstack:mainfrom
airowe:fix/scroll-clamp-to-container

Conversation

@airowe

@airowe airowe commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Clamps Android directional scroll endpoints to the resolved scrollable (#2714), following the rule agreed with #1821: endpoints stay inside the intersection of the container and the safe bounds (still the 10% padding until #1821), and a shortened swipe reports pixels (dispatched) beside requestedPixels.

  • The daemon reads the container from the current stored tree before the gesture. There is no container for a stale tree, a horizontal scroll, or desktop/web.
  • buildScrollGesturePlan takes an optional container. A span that fits is unchanged.
  • Android composes it with the keyboard clip.

8 files. The planner vectors are TS-only, because the Swift twin doesn't clamp yet.

Not fixed yet: iOS, including the Oct 7 keyboard-clip repro. I'd like that as a stacked follow-up (Swift port, wire field, parity vectors). @thymikee, does that split work for you?

Validation

check:affected --run --head 95ba7e823 (exactCommit): format, lint, typecheck, layering, fallow and build pass. vitest-related has 856/857 files passing. The one failure is a new URL(import.meta.url).pathname test that breaks on this host's space-containing path, and it fails identically on main. Run directly after it: unit has 14,565 passing and 6 failures that also fail on main; provider-integration and eager-closure-budgets pass.

Live on Pixel_7_API_36 (tree on 0149438, before rebasing over #3736's top/bottom change). Settings list inset at y=480, scroll up 0.8:

  • main: drag starts at y=312 on the search bar → scroll_no_progress
  • branch: 480→2808, pixels 2328, requestedPixels 2496, moved

Screenshots and recordings are in the first comment.

View guided diff

…lved scrollable

A directional scroll centred its swipe on the window, so on an inset
scrollable the drag could start on a header above the list and the
command failed with scroll_no_progress. The daemon now resolves the
scroll container from the current stored tree before the gesture and
hands its rect to the owner. The shared planner keeps both endpoints
inside the container's intersection with the padded frame: a span
that fits is unchanged, one that leaves the band moves into it, and
one longer than the band shrinks to it and reports `requestedPixels`
beside the dispatched `pixels`. Android applies it after the keyboard
clip, so the two compose. iOS plans in the runner and follows separately.

Refs callstack#2714
@airowe

airowe commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Live evidence: Pixel_7_API_36, Android Settings. The list is inset at y=480 under the search bar. Steps: scroll down, then scroll up 0.8.

Comparison strip, left to right: main before, main after (no movement, scroll_no_progress), branch before, branch after (scrolled back to the top).

comparison-strip

main (0149438): scroll up 0.8 drags from y=312 on the search bar, and the list never moves.

base-scroll-up-0.8.mp4

branch: scroll up 0.8 drags 480→2808 inside the list (pixels 2328, requestedPixels 2496, movement: moved).

head-scroll-up-0.8.mp4

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

5 issues found across 8 files

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/daemon/src/execution/scroll-movement.ts">

<violation number="1" location="packages/daemon/src/execution/scroll-movement.ts:194">
P3: `readScrollTargetContainer` is gated only by `isMobilePlatform`, which includes iOS. iOS does not clamp yet, so this computes an edge analysis of the whole tree on every iOS directional scroll and discards the result. Gate it to Android so the iOS path stays as it was.</violation>
</file>

<file name="packages/contracts/src/scroll-gesture.ts">

<violation number="1" location="packages/contracts/src/scroll-gesture.ts:157">
P3: For odd travel distances, a shortened swipe can report `pixels` equal to `requestedPixels`, which contradicts the documented contract that `pixels` is smaller when shortened. Compare against the unclamped lane length, or derive `requestedPixels` from it.</violation>

<violation number="2" location="packages/contracts/src/scroll-gesture.ts:182">
P2: When the container does not intersect the padded band, this returns the unclamped lane outside the container, so the swipe misses the target scrollable. Either surface that case to the caller or update the JSDoc, which currently promises the endpoints stay inside the intersection.</violation>

<violation number="3" location="packages/contracts/src/scroll-gesture.ts:186">
P2: The cross-axis fallback can place a container-clamped swipe outside the 10% safe bounds. Intersect the container’s cross-axis range with the corresponding padded frame range before choosing its center (or handle an empty intersection explicitly).</violation>
</file>

<file name="packages/contracts/src/scroll-gesture-container.test.ts">

<violation number="1" location="packages/contracts/src/scroll-gesture-container.test.ts:45">
P3: No test covers a left or right swipe that is actually shortened or moved by a container. The `['up', 'down', 'left', 'right']` case only checks an already-fitting container, so the horizontal axis mapping is unverified. Add a horizontal case with a narrow container, such as `{ x: 100, y: 0, width: 200, height: 874 }`, asserting the x endpoints and `pixels`/`requestedPixels`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

const containerSize = vertical ? container.height : container.width;
const bandMin = Math.max(padded.min, Math.round(containerMin));
const bandMax = Math.min(padded.max, Math.round(containerMin + containerSize));
if (bandMax <= bandMin) return lane;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When the container does not intersect the padded band, this returns the unclamped lane outside the container, so the swipe misses the target scrollable. Either surface that case to the caller or update the JSDoc, which currently promises the endpoints stay inside the intersection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/contracts/src/scroll-gesture.ts, line 182:

<comment>When the container does not intersect the padded band, this returns the unclamped lane outside the container, so the swipe misses the target scrollable. Either surface that case to the caller or update the JSDoc, which currently promises the endpoints stay inside the intersection.</comment>

<file context>
@@ -125,16 +134,65 @@ export function buildScrollGesturePlan(options: ScrollGestureOptions): ScrollGes
+  const containerSize = vertical ? container.height : container.width;
+  const bandMin = Math.max(padded.min, Math.round(containerMin));
+  const bandMax = Math.min(padded.max, Math.round(containerMin + containerSize));
+  if (bandMax <= bandMin) return lane;
+
+  const crossMin = vertical ? container.x : container.y;
</file context>


const crossMin = vertical ? container.x : container.y;
const crossSize = vertical ? container.width : container.height;
const cross =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The cross-axis fallback can place a container-clamped swipe outside the 10% safe bounds. Intersect the container’s cross-axis range with the corresponding padded frame range before choosing its center (or handle an empty intersection explicitly).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/contracts/src/scroll-gesture.ts, line 186:

<comment>The cross-axis fallback can place a container-clamped swipe outside the 10% safe bounds. Intersect the container’s cross-axis range with the corresponding padded frame range before choosing its center (or handle an empty intersection explicitly).</comment>

<file context>
@@ -125,16 +134,65 @@ export function buildScrollGesturePlan(options: ScrollGestureOptions): ScrollGes
+
+  const crossMin = vertical ? container.x : container.y;
+  const crossSize = vertical ? container.width : container.height;
+  const cross =
+    lane.cross >= crossMin && lane.cross <= crossMin + crossSize
+      ? lane.cross
</file context>

session: SessionState,
direction: ScrollDirection,
): Promise<Rect | undefined> {
if (!isMobilePlatform(device)) return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: readScrollTargetContainer is gated only by isMobilePlatform, which includes iOS. iOS does not clamp yet, so this computes an edge analysis of the whole tree on every iOS directional scroll and discards the result. Gate it to Android so the iOS path stays as it was.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/execution/scroll-movement.ts, line 194:

<comment>`readScrollTargetContainer` is gated only by `isMobilePlatform`, which includes iOS. iOS does not clamp yet, so this computes an edge analysis of the whole tree on every iOS directional scroll and discards the result. Gate it to Android so the iOS path stays as it was.</comment>

<file context>
@@ -164,17 +164,40 @@ export function readScrollSurfaceBaseline(
+  session: SessionState,
+  direction: ScrollDirection,
+): Promise<Rect | undefined> {
+  if (!isMobilePlatform(device)) return undefined;
+  const edge = verticalEdgeFor(direction);
+  const nodes = session.snapshot?.nodes;
</file context>
Suggested change
if (!isMobilePlatform(device)) return undefined;
if (device.platform !== 'android') return undefined;

vertical ? lane.axisEnd : lane.cross,
);
const placed = shortened
? { ...plan, pixels: lane.axisEnd - lane.axisStart, requestedPixels: travelPixels }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: For odd travel distances, a shortened swipe can report pixels equal to requestedPixels, which contradicts the documented contract that pixels is smaller when shortened. Compare against the unclamped lane length, or derive requestedPixels from it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/contracts/src/scroll-gesture.ts, line 157:

<comment>For odd travel distances, a shortened swipe can report `pixels` equal to `requestedPixels`, which contradicts the documented contract that `pixels` is smaller when shortened. Compare against the unclamped lane length, or derive `requestedPixels` from it.</comment>

<file context>
@@ -125,16 +134,65 @@ export function buildScrollGesturePlan(options: ScrollGestureOptions): ScrollGes
+    vertical ? lane.axisEnd : lane.cross,
+  );
+  const placed = shortened
+    ? { ...plan, pixels: lane.axisEnd - lane.axisStart, requestedPixels: travelPixels }
+    : plan;
+  // `up`/`left` drag from the low end toward the high end; `down`/`right` drag the other way.
</file context>

},
);

test.each<ScrollDirection>(['up', 'down', 'left', 'right'])(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: No test covers a left or right swipe that is actually shortened or moved by a container. The ['up', 'down', 'left', 'right'] case only checks an already-fitting container, so the horizontal axis mapping is unverified. Add a horizontal case with a narrow container, such as { x: 100, y: 0, width: 200, height: 874 }, asserting the x endpoints and pixels/requestedPixels.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/contracts/src/scroll-gesture-container.test.ts, line 45:

<comment>No test covers a left or right swipe that is actually shortened or moved by a container. The `['up', 'down', 'left', 'right']` case only checks an already-fitting container, so the horizontal axis mapping is unverified. Add a horizontal case with a narrow container, such as `{ x: 100, y: 0, width: 200, height: 874 }`, asserting the x endpoints and `pixels`/`requestedPixels`.</comment>

<file context>
@@ -0,0 +1,94 @@
+  },
+);
+
+test.each<ScrollDirection>(['up', 'down', 'left', 'right'])(
+  'a %s swipe already inside its container keeps today’s endpoints and distance',
+  (direction) => {
</file context>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant