Repository navigation
Description
Reproduced on Flutter 3.47.4 stable (9584c67), Chrome on macOS. The code involved is unchanged on master 697012a (links below).
When one semantics update both grows a scrollable's extent and moves its position past the old maximum, SemanticScrollable assigns scrollTop before it resizes flt-semantics-scroll-overflow. The browser clamps the value to the old maximum, the DOM and the framework disagree from then on, and the next browser-driven scroll (an assistive technology's scroll, or a scroll-into-view) makes the list jump.
Steps to reproduce
- Build the code sample below for web (
flutter build web --profile), servebuild/weband open it in Chrome. - Turn semantics on, for example by running
document.querySelector('flt-semantics-placeholder').click()in the DevTools console, or by using a screen reader. - Activate "Grow". One tap adds ten rows and calls
jumpTo(800)in the same frame, so the larger extent and the new position reach the engine in one semantics update. Before the tap the list can scroll 400px; after it, 800px. - Read the
scrollTopof the scrollingflt-semanticselement. - Scroll that element up by 40px, as an assistive technology's scroll or a browser scroll-into-view would. For example, run
sc.scrollTop -= 40on it.
The scripts that produced the logs below are under "Logs".
Expected results
After Grow, the DOM scrollTop is 800, matching the framework. A 40px scroll up moves the list to 760, which leaves Row 19 at the top.
Actual results
The framework asks for 800, but the browser clamps the value to the old maximum of 400, because the overflow element is still sized for the old extent (800px tall in a 400px viewport). Node positions stay visually right, since verticalScrollAdjustment is read from the clamped scrollTop. The DOM and the framework now disagree by 400px, and nothing corrects that until the scrollable's node is updated again with a different position.
The next browser scroll is computed from 400. _recomputeScrollPosition sends scrollToOffset(360), so the list jumps 440px, from Row 20 to Row 9, instead of 40px. A screenshot taken at that point confirms that the painted list matches: Row 9 is at the top.
In a real app this showed up as a type-ahead list under a text field. Opening it near the foot of the page grows the page's scroll extent, and Scrollable.ensureVisible scrolls to it in the same frame. The page then jumped visibly when the screen reader moved.
Code sample
Code sample
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: Scaffold(body: Grow())));
/// One tap adds rows and scrolls to the new end in the same frame, so the
/// larger scroll extent and the new position reach the engine in one
/// semantics update. Before the tap the list can scroll 400px; after it, 800px.
class Grow extends StatefulWidget {
const Grow({super.key});
@override
State<Grow> createState() => _GrowState();
}
class _GrowState extends State<Grow> {
var n = 20;
final _c = ScrollController();
@override
Widget build(BuildContext context) => Column(
children: [
TextButton(
onPressed: () {
setState(() => n += 10); // 20 -> 30 rows of 40px in a 400px viewport
_c.jumpTo(800); // the new end, past the old maximum of 400
},
child: const Text('Grow'),
),
SizedBox(
height: 400,
child: ListView(
controller: _c,
children: [
for (var i = 0; i < n; i++) SizedBox(height: 40, child: TextButton(onPressed: () {}, child: Text('Row $i'))),
],
),
),
],
);
}Screenshots or Video
Not applicable: the logs below are the evidence. A screenshot after the 40px scroll shows Row 9 at the top of the list, matching the semantics tree.
Logs
Logs
The scrollTop setter of the scroller was instrumented to log the overflow element's height at the moment of the assignment:
set scrollTop 800 -> dom 400 (overflow height at that moment 800px, max 400)
A Playwright script then read the scroller before and after a 40px browser scroll:
after Grow: dom scrollTop=400 max=800 first row in view (by semantics node)=Row 20,0
after a 40px browser scroll up: dom scrollTop=360 max=800 first row in view (by semantics node)=Row 9,0
The scrollTop instrumentation (run in the page after semantics is on, before tapping Grow)
const sc = [...document.querySelectorAll('flt-semantics')].find((e) => /scroll/.test(getComputedStyle(e).overflowY));
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop');
Object.defineProperty(sc, 'scrollTop', {
get() { return desc.get.call(this); },
set(v) {
const of = this.querySelector(':scope > flt-semantics-scroll-overflow');
const before = of?.style.height;
desc.set.call(this, v);
console.log(`set scrollTop ${v} -> dom ${desc.get.call(this)} (overflow height at that moment ${before}, max ${this.scrollHeight - this.clientHeight})`);
},
});The Playwright script behind the "after Grow" lines
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ channel: 'chrome' });
const page = await (await browser.newContext({ viewport: { width: 900, height: 700 }, deviceScaleFactor: 1 })).newPage();
await page.goto('http://localhost:8080/'); // wherever build/web is served
await page.waitForTimeout(3000);
await page.evaluate(() => document.querySelector('flt-semantics-placeholder')?.click()); await page.waitForTimeout(800);
const st = (label) => page.evaluate((label) => {
const sc = [...document.querySelectorAll('flt-semantics')].find((e) => /scroll/.test(getComputedStyle(e).overflowY));
const top = sc.getBoundingClientRect().top;
const first = [...sc.querySelectorAll('flt-semantics[role=button]')].map((e) => [e.textContent.trim(), Math.round(e.getBoundingClientRect().y - top)]).filter(([, y]) => y >= -5).sort((a, b) => a[1] - b[1])[0];
return `${label}: dom scrollTop=${sc.scrollTop} max=${sc.scrollHeight - sc.clientHeight} first row in view (by semantics node)=${first}`;
}, label);
console.log(await st('start'));
await page.locator('flt-semantics[role=button]', { hasText: 'Grow' }).click(); await page.waitForTimeout(1000);
console.log(await st('after Grow'));
await page.screenshot({ path: 'scroll-after-grow.png' });
// A browser-driven scroll of 40px on the scroller, as an assistive technology's scroll would be.
await page.evaluate(() => { const sc = [...document.querySelectorAll('flt-semantics')].find((e) => /scroll/.test(getComputedStyle(e).overflowY)); sc.scrollTop = sc.scrollTop - 40; });
await page.waitForTimeout(1000);
console.log(await st('after a 40px browser scroll up'));
await page.screenshot({ path: 'scroll-after-browser-scroll.png' });
await browser.close();
})();Cause
In SemanticScrollable.update (3.47.4, master), the post-update callback runs in this order:
element.scrollTop = scrollPosition(L136)._previousDomScrollPosition = _domScrollPosition(L137), which reads back the clamped value._updateScrollableState()(L140), which only then sizesflt-semantics-scroll-overflowto the new extent and readsverticalScrollAdjustmentfromelement.scrollTop(L206-L213).
So the assignment in step 1 is made against the old extent and clamped. _recomputeScrollPosition (L55-L89) then turns the next DOM scroll into SemanticsAction.scrollToOffset(element.scrollTop), which is relative to the clamped value rather than to the framework's position.
Suggested fix
In the callback, call _updateScrollableState() before assigning element.scrollTop, and recompute verticalScrollAdjustment / horizontalScrollAdjustment after the assignment. In practice that means calling _updateScrollableState() again, or splitting it into "size the overflow" and "read the adjustment". Patching the compiled closure into this order removed the clamp in a real app (set scrollTop 792 -> dom 792).
Related issues
None found. I searched for semantics scrollTop, SemanticScrollable, flt-semantics-scroll-overflow, web semantics scroll offset wrong position, web semantics scrollable offset, web semantics scroll jump and web semantics scrollable clamp.
Flutter Doctor output
Doctor output
[✓] Flutter (Channel stable, 3.47.4, on macOS 27.0.1 26A434 darwin-arm64, locale en-GB)
• Flutter version 3.47.4 on channel stable at ~/Development/flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 9584c6713b (5 weeks ago), 2026-09-10 15:25:10 -0700
• Engine revision 06a2e2a110
• Dart version 3.13.3
• DevTools version 2.60.0
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
[✓] Chrome - develop for the web
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[✓] Connected device (2 available)
• macOS (desktop) • macos • darwin-arm64 • macOS 27.0.1 26A434 darwin-arm64
• Chrome (web) • chrome • web-javascript • Google Chrome 155.0.8059.82
[✓] Network resources
• All expected network resources are available.
(Android and Xcode checks timed out on this machine and are left out; they do not apply to these web/framework reports.)
Activity
suryaKommana2662 commentedon Oct 19, 2026
@jorgelrj Thanks for the report. Reproduced on Flutter web in Chrome using the provided sample. routing to the
team-accessibilityfor further investigation.After enabling semantics in Chrome DevTools with:
I inspected the scrollable semantics element using:
Before pressing Grow, the output showed
scrollTop: 0,clientHeight: 400,scrollHeight: 800, andmaxScroll: 400.After pressing Grow, the content height increased to 1200px and
maxScrollbecame 800, butscrollTopwas only 400 instead of the expected 800.I then executed:
The resulting
scrollTopwas 360 whilemaxScrollremained 800.Flutter Doctor Output
flutter-zl commentedon Oct 21, 2026
jumpTo(800)in the same frame. The offset readout shows 800. Then in the console:400, the clamped value, and the readout jumps to 360 with Row 9 on top. Fixed: logs800and the readout moves to 760 with Row 19 on top.hannah-hyj commentedon Oct 21, 2026
Hi @flutter-zl, when you add the "triaged-web" tab, can you also replace "team-accessibility" with "team-web", otherwise the bot will drop the triaged label.