Skip to content

[web][a11y] Focused TextField inside a SingleChildScrollView loses DOM focus when an ancestor Theme rebuilds with an unequal ThemeData (semantics role scrollable↔generic swap) #192792

Closed

Description

@aabajian

Steps to reproduce

  1. flutter build web --release with the app below. Serve it, open it in Chrome.
  2. Turn on accessibility: click the "Enable accessibility" placeholder, or use a screen reader (the report came from Playwright clicking flt-semantics-placeholder).
  3. Click the text field and type.

Expected

The <textarea> inside flt-semantics keeps document.activeElement, and the typed text lands in the field.

Actual

Focus moves to <body> right after the click, and nothing typed lands. Without semantics, typing works.

Removing EITHER the scroll view OR the per-build theme churn makes it work. Both controls were verified with single-file variants of the code below: one ThemeData object reused for every build → typing lands; the SingleChildScrollView replaced by a plain SizedBox (theme churn kept) → typing lands. The code below as written → focus to <body>, nothing lands, and no editing element outside the semantics tree (so this is not the separate "field focused before semantics were enabled" case).

Minimal code

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: Repro()));

class Repro extends StatefulWidget {
  const Repro({super.key});
  @override
  State<Repro> createState() => _ReproState();
}

class _ReproState extends State<Repro> {
  final _focus = FocusNode();
  final _text = TextEditingController();

  @override
  void initState() {
    super.initState();
    // Any rebuild on focus / typing is enough.
    _focus.addListener(() => setState(() {}));
    _text.addListener(() => setState(() {}));
  }

  @override
  Widget build(BuildContext context) {
    return Theme(
      // A ThemeData that is never == the previous one (a fresh
      // WidgetStateProperty.resolveWith object, which has no == override).
      data: ThemeData(
        checkboxTheme: CheckboxThemeData(
          fillColor: WidgetStateProperty.resolveWith((_) => null),
        ),
      ),
      child: Scaffold(
        body: SizedBox(
          width: 420,
          height: 180,
          child: LayoutBuilder(
            builder: (context, c) => SingleChildScrollView(
              child: ConstrainedBox(
                constraints: BoxConstraints(minHeight: c.maxHeight),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    SizedBox(
                      height: 160,
                      child: TextField(
                        controller: _text,
                        focusNode: _focus,
                        maxLines: null,
                        expands: true,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Repeatability: the code above failed in 5 of 5 runs (headless Chromium via Playwright: engine placeholder clicked, a real mouse click whose pointerdown target was recorded as the field's semantics <textarea>, then typing). Each control typed cleanly in 3 of 3 runs under the same procedure.

Mechanism (traced in the 3.41.9 sources and in the browser; one inference is marked)

Verified in source:

  1. ThemeData equality fails: WidgetStateProperty.resolveWith allocates a new _WidgetStatePropertyWith, which has no == override, and CheckboxThemeData/ThemeData compare that property. So _InheritedTheme.updateShouldNotify (theme.data != old.theme.data) notifies on every such rebuild.
  2. ScrollableState.didChangeDependencies calls _updatePosition(), which creates a new ScrollPosition; a new position's haveDimensions is false until it is laid out.
  3. _RenderScrollSemantics.describeSemanticsConfiguration sets hasImplicitScrolling only if (position.haveDimensions).
  4. For this node, the web engine's role is EngineSemanticsRole.scrollable exactly when hasImplicitScrolling is set (isScrollContainer); explicit roles and some other flags take precedence in general. On a role change, _updateRole disposes the old role and creates a new one, and the existing child elements are re-appended (element.append(child.element)), which moves the text field's host element in the DOM.

Observed in the browser, by hooking HTMLElement.prototype.focus/blur, DOM append/insertBefore/remove, and the field's focus events on the code above. The release build and a debug (DDC) build agree; timings are from the debug run.

  1. First semantics update after the click (~+50 ms): _updateRole on the scroll container re-appends the child containing the text field (EngineSemanticsOwner.updateSemantics → SemanticsObject.updateSelf → _updateRole → append). The <textarea> gets a blur and document.activeElement becomes <body>. In the same update focus is restored twice:

    • SemanticsTextEditingStrategy.activate → enable → moveFocusToActiveDomElement
    • the SemanticTextField.update() re-focus branch, run from _finalizeTree
  2. Next update: the container's role attribute flips again (observed at +96 ms, with focus still on the field), and _updateRole re-appends the same child (+100 ms). The <textarea> blurs to <body>, and no focus() call follows. Further updates (~+116 ms, ~+122 ms) keep flipping the role and re-appending while focus stays on <body>. Typing then lands nowhere.

  3. Inference (not directly hooked): in those later updates the text field's own semantics node is not updated, because only the scroll container's flags changed. So the SemanticTextField.update() re-focus branch, which is what repaired the first move, never runs again.

    A repair that re-checks focus for descendants of a node whose role was replaced, rather than only for nodes that are themselves updated, would presumably cover this.

Workaround

Either of these stops the transition:

  • Keep the ancestor ThemeData identity-stable, for example by caching it. This is what the stable-theme control does.
  • Give the scroll view physics whose allowImplicitScrolling returns false.
    • A custom ScrollPhysics subclass must return its own type from applyTo.
    • Disabling implicit scrolling also disables automatic scrolling for showOnScreen requests inside that scroll view.

Details

Flutter 3.41.9 (web, release, CanvasKit), Chromium (Playwright headless), semantics enabled via the placeholder. Real screen readers: not yet tested.

Activity

  1. 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)
    on Sep 15, 2026
  2. mbcorona commented on Sep 15, 2026

    @mbcorona
    Member

    Hi @aabajian, thanks for reporting.
    Confirmed on master: _updateRole re-appends the child elements on a role change, and isScrollContainer is driven by hasImplicitScrolling, so a scrollable to generic flip moves the focused textarea and blurs it to body.
    Routing to the accessibility team.

  3. github-actions commented on Oct 16, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  4. locked as resolved and limited conversation to collaborators on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

a: 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-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions