Repository navigation
fix(scroll): take edge hints from a same-rect scrollable ancestor #3784
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
thymikee
merged 6 commits into
callstack:main
from
airowe:fix/scroll-edge-same-rect-ancestor
Oct 17, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
370e88a
fix(scroll): take edge hints from a same-rect scrollable ancestor
airowe 4e9bcc8
fix(scroll): scope same-rect ancestor hints to the edge verdict
airowe 273e341
fix(scroll): select the same-rect ancestor so scoped recaptures keep it
airowe 3818a90
fix(scroll): guard the targeted scrollable-ancestor walk against cycles
airowe dc24f9a
refactor(scroll): name the same-rect heuristic and share the scoped-s…
airowe f4c2c24
fix(scroll): require a same-rect ancestor to report a vertical edge
airowe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
85 changes: 85 additions & 0 deletions
85
packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-android-settings-fixture.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import { formatRole } from '@agent-device/kernel/snapshot'; | ||
| import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; | ||
|
|
||
| /** | ||
| * The scroll-container chain of the Android 16 Settings home screen (Pixel 7, API 36), trimmed from | ||
| * live `snapshot --json` captures for #3737. `main_content_scrollable_container` and the | ||
| * `recycler_view` nested inside it share one rect. The RecyclerView offers ACTION_SCROLL_BACKWARD | ||
| * even at the top, so it reports `hiddenContentAbove` in both states; only the outer ScrollView's | ||
| * hint tracks where the content actually is. | ||
| */ | ||
| export type SettingsHomeState = 'at-top' | 'scrolled-down'; | ||
|
|
||
| const SCREEN: Rect = { x: 0, y: 0, width: 1440, height: 3120 }; | ||
| const LIST_FRAME: Rect = { x: 0, y: 480, width: 1440, height: 2556 }; | ||
| const ROW_TOPS: Record<SettingsHomeState, readonly number[]> = { | ||
| 'at-top': [480, 807, 1078, 1405], | ||
| 'scrolled-down': [493, 764, 1035, 1362], | ||
| }; | ||
| const ROW_LABELS: Record<SettingsHomeState, readonly string[]> = { | ||
| 'at-top': ['Google', 'Network & internet', 'Connected devices', 'Apps'], | ||
| 'scrolled-down': ['Network & internet', 'Connected devices', 'Apps', 'Notifications'], | ||
| }; | ||
| const RECYCLER_VIEW_INDEX = 23; | ||
|
|
||
| function node( | ||
| index: number, | ||
| type: string, | ||
| rect: Rect, | ||
| overrides: Partial<SnapshotNode> = {}, | ||
| ): SnapshotNode { | ||
| return { ref: `e${index + 1}`, index, type, kind: formatRole(type), rect, ...overrides }; | ||
| } | ||
|
|
||
| export function settingsHomeTree(state: SettingsHomeState): SnapshotNode[] { | ||
| const rows = ROW_TOPS[state].map((y, row) => | ||
| node( | ||
| RECYCLER_VIEW_INDEX + 1 + row, | ||
| 'android.widget.LinearLayout', | ||
| { x: 0, y, width: 1440, height: 271 }, | ||
| { parentIndex: RECYCLER_VIEW_INDEX, label: ROW_LABELS[state][row] }, | ||
| ), | ||
| ); | ||
| return [ | ||
| node(8, 'android.widget.FrameLayout', SCREEN), | ||
| node(10, 'android.widget.FrameLayout', SCREEN, { | ||
| parentIndex: 8, | ||
| identifier: 'android:id/content', | ||
| }), | ||
| node( | ||
| 11, | ||
| 'android.widget.ScrollView', | ||
| { x: 0, y: 0, width: 1440, height: 3036 }, | ||
| { | ||
| parentIndex: 10, | ||
| identifier: 'com.android.settings:id/settings_homepage_container', | ||
| }, | ||
| ), | ||
| node( | ||
| 12, | ||
| 'android.widget.TextView', | ||
| { x: 252, y: 251, width: 518, height: 93 }, | ||
| { | ||
| parentIndex: 11, | ||
| identifier: 'com.android.settings:id/search_bar_title', | ||
| label: 'Search Settings', | ||
| }, | ||
| ), | ||
| node(18, 'android.widget.ScrollView', LIST_FRAME, { | ||
| parentIndex: 11, | ||
| identifier: 'com.android.settings:id/main_content_scrollable_container', | ||
| hiddenContentBelow: true, | ||
| ...(state === 'scrolled-down' ? { hiddenContentAbove: true } : {}), | ||
| }), | ||
| node(22, 'android.widget.FrameLayout', LIST_FRAME, { | ||
| parentIndex: 18, | ||
| identifier: 'android:id/list_container', | ||
| }), | ||
| node(RECYCLER_VIEW_INDEX, 'androidx.recyclerview.widget.RecyclerView', LIST_FRAME, { | ||
| parentIndex: 22, | ||
| identifier: 'com.android.settings:id/recycler_view', | ||
| hiddenContentAbove: true, | ||
| }), | ||
| ...rows, | ||
| ]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
packages/capture-kit/src/snapshot/__tests__/scroll-edge-state-same-rect-ancestor.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { test } from 'vitest'; | ||
| import assert from 'node:assert/strict'; | ||
| import { runScrollEdgePasses } from '../scroll-edge-state.ts'; | ||
| import { capture, scrollNode, subtreeFor, windowRoot } from './scroll-edge-state-fixtures.ts'; | ||
| import { settingsHomeTree } from './scroll-edge-state-android-settings-fixture.ts'; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // A scroller nested in a scrollable ancestor with the same rect takes its edge hints from that | ||
| // ancestor (#3737): the inner RecyclerView on Android Settings claims hidden content above even at | ||
| // the top, while the outer ScrollView framing the same pixels reports where the content is. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| test('scroll top edge: the Android Settings list at the top reports no hidden content above', async () => { | ||
| const state = await capture(settingsHomeTree('at-top'), 'top'); | ||
| assert.equal(state.canScroll, false); | ||
| }); | ||
|
|
||
| test('scroll top on Android Settings already at the top costs no pass under scoped recapture', async () => { | ||
| // Scoped captures return only the scope root's subtree, as an Android scoped snapshot does. | ||
| const tree = settingsHomeTree('at-top'); | ||
| const outcome = await runScrollEdgePasses({ | ||
| edge: 'top', | ||
| captureState: async (scope) => await capture(subtreeFor(tree, scope), 'top'), | ||
| scroll: async () => { | ||
| throw new Error('scroll top dispatched a gesture at the top'); | ||
| }, | ||
| }); | ||
| assert.equal(outcome.passes, 0); | ||
| }); | ||
|
|
||
| test('scroll top edge: a cyclic chain of same-rect scrollables ends the ancestor walk', async () => { | ||
| const rect = { x: 0, y: 100, width: 400, height: 600 }; | ||
| const nodes = [ | ||
| windowRoot(), | ||
| scrollNode(1, { parentIndex: 2, identifier: 'first', hiddenContentAbove: true, rect }), | ||
| scrollNode(2, { parentIndex: 1, identifier: 'second', rect }), | ||
| ]; | ||
| const state = await capture(nodes, 'top'); | ||
| assert.equal(state.scope, 'first'); | ||
| assert.equal(state.canScroll, true); | ||
| }); | ||
|
|
||
| test('scroll top edge: the Android Settings list scrolled down still reports hidden content above', async () => { | ||
| const state = await capture(settingsHomeTree('scrolled-down'), 'top'); | ||
| assert.equal(state.canScroll, true); | ||
| }); | ||
|
|
||
| test('scroll bottom edge: the Android Settings list at the top still reports hidden content below', async () => { | ||
| const state = await capture(settingsHomeTree('at-top'), 'bottom'); | ||
| assert.equal(state.canScroll, true); | ||
| }); | ||
|
|
||
| test('scroll top on Android Settings stops at the top instead of reporting no progress', async () => { | ||
| const frames = [settingsHomeTree('scrolled-down'), settingsHomeTree('at-top')]; | ||
| let scrolls = 0; | ||
| const outcome = await runScrollEdgePasses({ | ||
| edge: 'top', | ||
| captureState: async () => await capture(frames[Math.min(scrolls, frames.length - 1)]!, 'top'), | ||
| scroll: async () => { | ||
| scrolls += 1; | ||
| }, | ||
| }); | ||
| assert.equal(outcome.passes, 1); | ||
| }); | ||
|
|
||
| test('scroll top edge: a nested scroller whose rect differs from its scrollable ancestor keeps its own hint', async () => { | ||
| const nodes = [ | ||
| windowRoot(), | ||
| scrollNode(1, { identifier: 'outer', rect: { x: 0, y: 0, width: 400, height: 800 } }), | ||
| scrollNode(2, { | ||
| parentIndex: 1, | ||
| identifier: 'inner', | ||
| hiddenContentAbove: true, | ||
| rect: { x: 0, y: 100, width: 400, height: 600 }, | ||
| }), | ||
| ]; | ||
| const state = await capture(nodes, 'top'); | ||
| assert.equal(state.scope, 'inner'); | ||
| assert.equal(state.canScroll, true); | ||
| }); | ||
|
|
||
| test('scroll top edge: a targeted node in a cyclic chain with no scrollable ancestor ends the walk', async () => { | ||
| const nodes = [ | ||
| windowRoot(), | ||
| scrollNode(1, { identifier: 'list', hiddenContentAbove: true }), | ||
| { ...scrollNode(2, { parentIndex: 3 }), type: 'Button', kind: 'button' }, | ||
| { ...scrollNode(3, { parentIndex: 2 }), type: 'Group', kind: 'group' }, | ||
| ]; | ||
| const state = await capture(nodes, 'top', { nodeIndex: 2 }); | ||
| assert.equal(state.scope, 'list'); | ||
| assert.equal(state.canScroll, true); | ||
| }); | ||
|
|
||
| test('scroll bottom edge: a same-rect horizontal pager does not stand in for the vertical list on its page', async () => { | ||
| // A horizontal pager reports no vertical edge hints; its page's vertical list carries them. | ||
| const rect = { x: 0, y: 100, width: 400, height: 600 }; | ||
| const nodes = [ | ||
| windowRoot(), | ||
| scrollNode(1, { identifier: 'pager', rect }), | ||
| scrollNode(2, { parentIndex: 1, identifier: 'feed', hiddenContentBelow: true, rect }), | ||
| ]; | ||
| const state = await capture(nodes, 'bottom'); | ||
| assert.equal(state.scope, 'feed'); | ||
| assert.equal(state.canScroll, true); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.