Repository navigation
Description
Steps to reproduce
-
Create an app with
flutter create, replacelib/main.dartwith the code sample below, and add the following block to the<head>ofweb/index.html. It lets the page scroll and keeps a touch inside Flutter by cancelingtouchmove, as a page that owns its own scrolling does for a touch on an inner Flutter list. Nothing else in the app changes.<style> html { overflow: auto; } body { touch-action: pan-y !important; } html::after { content: ""; display: block; height: 1000vh; } </style> <script> document.addEventListener('DOMContentLoaded', () => { document.body.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false, capture: true }); }); </script>
-
Build with
flutter build weband open the app in Chrome on an Android phone. -
Reload the page, then do one long, slow drag on the list and lift the finger.
Deployed repro: https://flutter-demo-77-before.web.app
Expected results
After the finger lifts, "Pointers down" is 0 and the list is idle.
Actual results
"Pointers down" stays at 1 and the list never returns to idle. Flutter never receives the pointer up for that drag, so it keeps the pointer down for the rest of the session. A later drag on the list does not clear it either.
Cause
On mobile, the engine covers each view with an flt-semantics-placeholder that waits for a screen reader to activate it. In MobileSemanticsEnabler.tryEnableSemantics, in lib/web_ui/lib/src/engine/semantics/semantics_helper.dart, the engine gives up after kMaxSemanticsActivationAttempts, 20 pointer and touch events, and schedules the placeholder for removal.
On WebKit, the placeholder is only removed on touchend, pointerup, or click, so that Safari does not swallow the rest of a gesture whose target was removed. That check was added in flutter-team-archive/engine#22965. On every other browser engine, the placeholder is removed on the very next event, which is usually a pointermove in the middle of a drag.
The placeholder is the target of that drag. Once it is removed, Chrome on Android no longer delivers the drag's remaining pointer events, including pointerup, to the window when the page allows panning and touchmove is canceled. The engine listens for pointerup on the window, so the framework never sees the pointer go up.
Without the index.html block, with the engine's default touch-action: none, Chrome still delivers the pointerup and the drag ends normally. A default Flutter app does not hit this, but any page that scrolls itself, which is what #69529 asks for, does.
Code sample
Code sample
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:web/web.dart' as web;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: HomePage());
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final Set<int> _pointersDown = <int>{};
String _listState = 'idle';
late final Timer _timer;
@override
void initState() {
super.initState();
// Refreshes the placeholder status, which changes outside Flutter.
_timer = Timer.periodic(
const Duration(milliseconds: 250),
(_) => setState(() {}),
);
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
bool _onScroll(ScrollNotification notification) {
final String state = switch (notification) {
ScrollStartNotification() => 'scrolling',
ScrollUpdateNotification(dragDetails: _?) => 'dragging',
ScrollUpdateNotification() => 'flinging',
ScrollEndNotification() => 'idle',
_ => _listState,
};
if (state != _listState) {
setState(() => _listState = state);
}
return false;
}
@override
Widget build(BuildContext context) {
final bool placeholder =
web.document.querySelector('flt-semantics-placeholder') != null;
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (PointerDownEvent event) {
setState(() => _pointersDown.add(event.pointer));
},
onPointerUp: (PointerUpEvent event) {
setState(() => _pointersDown.remove(event.pointer));
},
onPointerCancel: (PointerCancelEvent event) {
setState(() => _pointersDown.remove(event.pointer));
},
child: Scaffold(
appBar: AppBar(title: const Text('Semantics placeholder pointer')),
body: Column(
children: <Widget>[
Container(
width: double.infinity,
color: _pointersDown.isEmpty ? Colors.white : Colors.yellow,
padding: const EdgeInsets.all(8),
child: Text(
'Pointers down: ${_pointersDown.length}\n'
'List: $_listState\n'
'Semantics placeholder: ${placeholder ? 'present' : 'removed'}',
),
),
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: ListView.builder(
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text('Item $index'));
},
),
),
),
],
),
),
);
}
}Screenshots or Video
Screenshots
After one long drag and lifting the finger: "Pointers down: 1", the list not idle, placeholder removed.
Logs
Logs
Headless Chrome emulating a Pixel 8, one 40-move drag after a fresh load, 3 of 3 runs:
placeholder removed at pointermove 21; the drag's remaining 20 pointermoves and its pointerup never reach window.Flutter Doctor output
Doctor output
Flutter master 01d3eecf3e5, web, built with `flutter build web`.Reproduced on Flutter master 01d3eecf3e5, on a Pixel 8 in Android Chrome, and in headless Chrome emulating a Pixel 8.
Activity
[web] Remove the a11y placeholder only after the gesture ends (flutte…
[web] Don't remove the a11y placeholder mid-gesture when semantics tu…
github-actions commentedon Oct 27, 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] Remove the a11y placeholder only after the gesture ends (flutte…