Skip to content

[web][a11y] On Android, removing the semantics placeholder mid-touch can leave a Flutter pointer down forever #194104

Closed

Description

@flutter-zl

Steps to reproduce

  1. Create an app with flutter create, replace lib/main.dart with the code sample below, and add the following block to the <head> of web/index.html. It lets the page scroll and keeps a touch inside Flutter by canceling touchmove, 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>
  2. Build with flutter build web and open the app in Chrome on an Android phone.

  3. 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

  1. self-assigned this
    on Oct 9, 2026
  2. added
    P1High-priority issues at the top of the work list
    team-webOwned by Web platform team
    triaged-webTriaged by Web platform team
    on Oct 9, 2026
  3. added a commit that references this issue on Oct 13, 2026
    7e115a9
  4. github-actions commented on Oct 27, 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.

  5. locked as resolved and limited conversation to collaborators on Oct 27, 2026
  6. added a commit that references this issue on Oct 29, 2026
    3442d9f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work listteam-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