Skip to content

[web] SemanticScrollable sets scrollTop before resizing its overflow, so the browser clamps it #194549

Open

Description

@jorgelrj

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

  1. Build the code sample below for web (flutter build web --profile), serve build/web and open it in Chrome.
  2. Turn semantics on, for example by running document.querySelector('flt-semantics-placeholder').click() in the DevTools console, or by using a screen reader.
  3. 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.
  4. Read the scrollTop of the scrolling flt-semantics element.
  5. Scroll that element up by 40px, as an assistive technology's scroll or a browser scroll-into-view would. For example, run sc.scrollTop -= 40 on 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:

  1. element.scrollTop = scrollPosition (L136).
  2. _previousDomScrollPosition = _domScrollPosition (L137), which reads back the clamped value.
  3. _updateScrollableState() (L140), which only then sizes flt-semantics-scroll-overflow to the new extent and reads verticalScrollAdjustment from element.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

  1. suryaKommana2662 commented on Oct 19, 2026

    @suryaKommana2662
    Contributor

    @jorgelrj Thanks for the report. Reproduced on Flutter web in Chrome using the provided sample. routing to the team-accessibility for further investigation.

    After enabling semantics in Chrome DevTools with:

    document.querySelector('flt-semantics-placeholder').click();

    I inspected the scrollable semantics element using:

    const sc = [...document.querySelectorAll('flt-semantics')].find(
      (e) => /scroll/.test(getComputedStyle(e).overflowY)
    );
    
    console.log({
      scrollTop: sc?.scrollTop,
      clientHeight: sc?.clientHeight,
      scrollHeight: sc?.scrollHeight,
      maxScroll: sc ? sc.scrollHeight - sc.clientHeight : null,
    });

    Before pressing Grow, the output showed scrollTop: 0, clientHeight: 400, scrollHeight: 800, and maxScroll: 400.

    After pressing Grow, the content height increased to 1200px and maxScroll became 800, but scrollTop was only 400 instead of the expected 800.

    I then executed:

    sc.scrollTop -= 40;
    console.log({
      scrollTop: sc.scrollTop,
      maxScroll: sc.scrollHeight - sc.clientHeight,
    });

    The resulting scrollTop was 360 while maxScroll remained 800.

    Flutter Doctor Output

    [✓] Flutter (Channel stable, 3.47.7, on macOS 27.0 26A428 darwin-arm64, locale en-IN) [223ms]
        • Flutter version 3.47.7 on channel stable at /Users/suryakommana/develop/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision abaf9c5237 (11 days ago), 2026-10-08 09:51:21 -0700
        • Engine revision deb287481e
        • Dart version 3.13.5
        • 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
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,594ms]
        • Android SDK at /Users/suryakommana/Library/Android/sdk
        • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
        • Platform android-37.0, build-tools 36.0.0
        • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
          This is the JDK bundled with the latest Android Studio installation on this machine.
          To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
        • Java version OpenJDK Runtime Environment (build 25.0.2+-15348964-b329.117)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 27.0) [1,276ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 27A266a
        • CocoaPods version 1.17.0
    
    [✓] Chrome - develop for the web [3ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (2 available) [5.9s]
        • macOS (desktop) • macos  • darwin-arm64   • macOS 27.0 26A428 darwin-arm64
        • Chrome (web)    • chrome • web-javascript • Google Chrome 155.0.8059.84
    
    [✓] Network resources [705ms]
        • All expected network resources are available.
    
    • No issues found!
  2. added
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    platform-webWeb applications specifically
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    fyi-webFor the attention of Web platform team
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    and removed
    in triagePresently being triaged by the triage team
    on Oct 19, 2026
  3. flutter-zl commented on Oct 21, 2026

    @flutter-zl
    Contributor
    • Demo: https://flutter-demo-10-before.web.app, third section. Click Grow, which adds ten rows and calls jumpTo(800) in the same frame. The offset readout shows 800. Then in the console:
      const sc = [...document.querySelectorAll('flt-semantics')].filter((e) => /scroll/.test(getComputedStyle(e).overflowY) && /Row 0/.test(e.textContent)).at(-1);
      console.log(sc.scrollTop); sc.scrollTop -= 40;
      • Bug: logs 400, the clamped value, and the readout jumps to 360 with Row 9 on top. Fixed: logs 800 and the readout moves to 760 with Row 19 on top.
  4. added
    P2Important issues not at the top of the work list
    on Oct 21, 2026
  5. hannah-hyj commented on Oct 21, 2026

    @hannah-hyj
    Member

    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.

  6. added
    team-webOwned by Web platform team
    triaged-webTriaged by Web platform team
    and removed
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)has reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-webWeb applications specificallyteam-webOwned by Web platform teamtriaged-webTriaged by Web platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions