Repository navigation
Description
Steps to reproduce
flutter build web --releasewith the app below. Serve it, open it in Chrome.- Turn on accessibility: click the "Enable accessibility" placeholder, or use a screen reader (the report came from Playwright clicking
flt-semantics-placeholder). - 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:
ThemeDataequality fails:WidgetStateProperty.resolveWithallocates a new_WidgetStatePropertyWith, which has no==override, andCheckboxThemeData/ThemeDatacompare that property. So_InheritedTheme.updateShouldNotify(theme.data != old.theme.data) notifies on every such rebuild.ScrollableState.didChangeDependenciescalls_updatePosition(), which creates a newScrollPosition; a new position'shaveDimensionsisfalseuntil it is laid out._RenderScrollSemantics.describeSemanticsConfigurationsetshasImplicitScrollingonlyif (position.haveDimensions).- For this node, the web engine's role is
EngineSemanticsRole.scrollableexactly whenhasImplicitScrollingis set (isScrollContainer); explicit roles and some other flags take precedence in general. On a role change,_updateRoledisposes 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.
-
First semantics update after the click (~+50 ms):
_updateRoleon the scroll container re-appends the child containing the text field (EngineSemanticsOwner.updateSemantics→SemanticsObject.updateSelf→_updateRole→append). The<textarea>gets abluranddocument.activeElementbecomes<body>. In the same update focus is restored twice:SemanticsTextEditingStrategy.activate→enable→moveFocusToActiveDomElement- the
SemanticTextField.update()re-focus branch, run from_finalizeTree
-
Next update: the container's
roleattribute flips again (observed at+96 ms, with focus still on the field), and+100 ms). The_updateRolere-appends the same child (<textarea>blurs to<body>, and nofocus()call follows. Further updates (~+116 ms, ~+122 ms) keep flipping the role and re-appending while focus stays on<body>. Typing then lands nowhere. -
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
ThemeDataidentity-stable, for example by caching it. This is what the stable-theme control does. - Give the scroll view physics whose
allowImplicitScrollingreturnsfalse.- A custom
ScrollPhysicssubclass must return its own type fromapplyTo. - Disabling implicit scrolling also disables automatic scrolling for
showOnScreenrequests inside that scroll view.
- A custom
Details
Flutter 3.41.9 (web, release, CanvasKit), Chromium (Playwright headless), semantics enabled via the placeholder. Real screen readers: not yet tested.
Activity
mbcorona commentedon Sep 15, 2026
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.
[web] Preserve DOM focus on role update and honor isAccessibilityFocu…
github-actions commentedon Oct 16, 2026
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 -vand a minimal reproduction of the issue.[web] Preserve DOM focus on role update and honor isAccessibilityFocu…