Skip to content

[BUG] [WEB] KeyboardListener's and Focus.onKeyEvent's KeyUpEvent do not work correctly in JS and WASM builds #162305

@rydmike

Description

@rydmike

Steps to reproduce

The KeyboardListener and Focus.onKeyEvent that replace RawKeyboardListener and Focus.onKey have a bug in triggering the KeyUpEvent correctly in the JS and WASM builds. The KeyUpEvent is not triggered when a key is released when pressing multiple keys in sequence and releasing them in reverse order. This bug is not present in VM builds.

A reproduction issue sample app is provided in the code sample section.

This issue reproduces on all current Flutter channels.

Background

We discovered this bug after starting to more actively pushing and using WEB builds of the game platform HypeHype.
We had already migrated from the deprecated RawKeyboardListener to the new KeyboardListener and its related APIs, but due to this issue we had to revert the migration and continue to use the deprecated RawKeyboardListener and its related APIs.

When making games, having correct n-key rollover function and n-key release sequence is critical, as game-play typically depends on being able to press multiple keys at the same time and having the game react correctly to their triggered key down and key up release order.

Request to hold removal of deprecated RawKeyboardListener and related APIs until issue is fixed

We kindly ask if you can hold the removal of the deprecated RawKeyboardListener and its related APIs, until this issue is resolved and its fix has landed in the Flutter stable channel.

The RawKeyboardListener and its related APIs are scheduled for removal here #136419 by @gspencergoog.

Expected results

When pressing multiple keys in sequence and releasing them in reverse order, the KeyUpEvent should be triggered for each key in the correct release order.

Using the reproduction keyboard event listener demo app, we can see that in a VM build the KeyUpEvent is triggered correctly for each key in the correct order.

Using the VM build with RawKeyboardListener or Focus.onKey APIs

  1. Press and hold [1] then, press and hold [2] then press and hold [3].
  2. Release [3], keep holding [2] and [1].
  3. Release [2], keep holding [1], the RawKeyUpEvent for [2] is triggered.
  4. Release [1], the RawKeyUpEvent for [1] is triggered.

This is correct and expected behavior.

Using the VM build with KeyboardListener or Focus.onKeyEvent APIs

  1. Press and hold [1] then, press and hold 2 then press and hold [3].
  2. Release [3], keep holding [2] and [1].
  3. Release [2], keep holding [1], the KeyUpEvent for [2] is triggered.
  4. Release [1], the KeyUpEvent for [1] is triggered.

This is correct and expected behavior.

This is demonstrated in the video recording below:

Issue.keyboard.ok.mp4

Actual results

When pressing multiple keys in sequence and releasing them in reverse order, the KeyUpEvent is not triggered for each key in the correct order on WEB builds. The same incorrect result is present in both JS and WASM builds. The previous key that were pressed are triggered automatically when last key is released, slightly after it, or not at all.

Using WEB build with RawKeyboardListener or Focus.onKey APIs

  1. Press and hold [1] then, press and hold [2] then press and hold [3].
  2. Release [3], keep holding [2] and [1].
  3. Release [2], keep holding [1], the RawKeyUpEvent for [2] is triggered.
  4. Release [1], the RawKeyUpEvent for [1] is triggered.

This is correct and expected behavior.

Using the WEB build with KeyboardListener or Focus.onKeyEvent APIs

  1. Press and hold [1] then, press and hold [2] then press and hold [3].
  2. Release [3], keep holding [2] and [1], the KeyUpEvent for [2] is triggered shortly after, despite [2] still being pressed.
  3. Release [2], keep holding [1], the actual KeyUpEvent for [2] is not triggered.
  4. Release [1], the KeyUpEvent for [1] is not triggered.

This is incorrect and not expected behavior when using the KeyboardListener and related APIs on WEB builds.

This is demonstrated in the video recording below, the incorrect behavior is shown in the right column:

Issue.keyboard.FAIL.mp4

The recording shows a Web WASM build, the result is the same in a Web JS build.

Code sample

Code sample
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(const KeyboardListenerComparisonApp());

// Check if this is a Web-WASM build, Web-JS build or native VM build.
const bool isRunningWithWasm = bool.fromEnvironment('dart.tool.dart2wasm');
const String buildType = isRunningWithWasm
    ? '(WASM build)'
    : kIsWeb
        ? '(JS build)'
        : '(VM build)';

class KeyboardListenerComparisonApp extends StatelessWidget {
  const KeyboardListenerComparisonApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: const KeyboardListenerDemo(),
      ),
    );
  }
}

class KeyboardListenerDemo extends StatefulWidget {
  const KeyboardListenerDemo({super.key});

  @override
  State<KeyboardListenerDemo> createState() => _KeyboardListenerDemoState();
}

class _KeyboardListenerDemoState extends State<KeyboardListenerDemo> {
  final List<String> _rawEvents = [];
  final List<String> _newEvents = [];
  late final FocusNode _rawFocusNode;
  late final FocusNode _newFocusNode;
  bool _useNewListener = false;

  @override
  void initState() {
    super.initState();
    _rawFocusNode = FocusNode(debugLabel: 'RawKeyboardListener');
    _newFocusNode = FocusNode(debugLabel: 'KeyboardListener');
    _rawFocusNode.addListener(_handleFocusChange);
    _newFocusNode.addListener(_handleFocusChange);
    _rawFocusNode.requestFocus();
  }

  @override
  void dispose() {
    _rawFocusNode.removeListener(_handleFocusChange);
    _newFocusNode.removeListener(_handleFocusChange);
    _rawFocusNode.dispose();
    _newFocusNode.dispose();
    super.dispose();
  }

  void _handleFocusChange() => setState(() {});

  void _clearEvents() {
    setState(() {
      _rawEvents.clear();
      _newEvents.clear();
    });
  }

  void _switchListener(bool useNew) {
    setState(() => _useNewListener = useNew);
    if (useNew) {
      _newFocusNode.requestFocus();
      _rawFocusNode.unfocus();
    } else {
      _rawFocusNode.requestFocus();
      _newFocusNode.unfocus();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        AppBar(
          title: const Text('Keyboard Listeners Comparison $buildType'),
          actions: [
            IconButton(
              icon: const Icon(Icons.delete),
              onPressed: _clearEvents,
              tooltip: 'Clear all events',
            ),
          ],
        ),
        Expanded(
          child: Row(
            children: [
              _buildListenerColumn(
                context: context,
                title: 'RawKeyboardListener',
                events: _rawEvents,
                isActive: _rawFocusNode.hasFocus,
                onTap: () => _switchListener(false),
              ),
              _buildListenerColumn(
                context: context,
                title: 'KeyboardListener',
                events: _newEvents,
                isActive: _newFocusNode.hasFocus,
                onTap: () => _switchListener(true),
              ),
            ],
          ),
        ),
        // Raw Keyboard Events (Legacy)
        if (!_useNewListener)
          Focus(
            focusNode: _rawFocusNode,
            onKey: (FocusNode node, RawKeyEvent event) {
              if (event is RawKeyDownEvent || event is RawKeyUpEvent) {
                _handleKeyEvent(event, isNew: false);
              }
              return KeyEventResult.handled; // Suppress system sounds
            },
            child: const SizedBox.shrink(),
          ),
        // New Keyboard Events
        if (_useNewListener)
          Focus(
            focusNode: _newFocusNode,
            onKeyEvent: (FocusNode node, KeyEvent event) {
              _handleKeyEvent(event, isNew: true);
              return KeyEventResult.handled; // Suppress system sounds
            },
            child: const SizedBox.shrink(),
          ),
      ],
    );
  }

  void _handleKeyEvent(dynamic event, {required bool isNew}) {
    final timeStamp = DateTime.now().toIso8601String().substring(11, 23);
    final entry = '[${event.runtimeType}] ${event.logicalKey} ($timeStamp)';

    setState(() {
      if (isNew) {
        _newEvents.add(entry);
      } else {
        _rawEvents.add(entry);
      }
    });
  }

  Widget _buildListenerColumn({
    required BuildContext context,
    required String title,
    required List<String> events,
    required bool isActive,
    required VoidCallback onTap,
  }) {
    final colorScheme = Theme.of(context).colorScheme;

    return Expanded(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: MouseRegion(
          cursor: SystemMouseCursors.click,
          child: GestureDetector(
            onTap: onTap,
            behavior: HitTestBehavior.opaque,
            child: Container(
              decoration: BoxDecoration(
                border: Border.all(
                  color: isActive
                      ? colorScheme.primary
                      : colorScheme.outlineVariant,
                  width: 4,
                ),
                borderRadius: BorderRadius.circular(8),
              ),
              padding: const EdgeInsets.all(8),
              child: Column(
                children: [
                  Text(
                    title,
                    style: TextStyle(
                      color: isActive
                          ? colorScheme.primary
                          : colorScheme.onSurface,
                      fontWeight: FontWeight.bold,
                      fontSize: 16,
                    ),
                  ),
                  Expanded(
                    child: ListView.builder(
                      reverse: true,
                      itemCount: events.length,
                      itemBuilder: (context, index) => Padding(
                        padding: const EdgeInsets.symmetric(vertical: 4),
                        child: Text(
                          events.reversed.elementAt(index),
                          style: TextStyle(
                            color: colorScheme.onSurfaceVariant,
                            fontSize: 12,
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Flutter version

This issue reproduces on all current Flutter channels, the latest tested master version is 3.29.0-1.0.pre.127.

Flutter Doctor output

Doctor output
flutter doctor -v
[✓] Flutter (Channel master, 3.29.0-1.0.pre.127, on macOS 15.2 24C101 darwin-arm64, locale en-US) [1,489ms]
    • Flutter version 3.29.0-1.0.pre.127 on channel master at /Users/rydmike/fvm/versions/master
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 9e273d5e6e (6 hours ago), 2025-01-27 19:43:46 -0800
    • Engine revision 9e273d5e6e
    • Dart version 3.8.0 (build 3.8.0-24.0.dev)
    • DevTools version 2.42.0

[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0) [902ms]
    • Android SDK at /Users/rydmike/Library/Android/sdk
    • Platform android-34, build-tools 34.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 17.0.9+0-17.0.9b1087.7-11185874)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 16.2) [517ms]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 16C5032a
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [68ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2023.2) [68ms]
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874)

[✓] IntelliJ IDEA Community Edition (version 2024.3.2) [67ms]
    • IntelliJ at /Applications/IntelliJ IDEA CE.app
    • Flutter plugin version 83.0.4
    • Dart plugin version 243.23654.44

[✓] VS Code (version 1.96.4) [10ms]
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.102.0

[✓] Connected device (2 available) [5.6s]
    • macOS (desktop) • macos  • darwin-arm64   • macOS 15.2 24C101 darwin-arm64
    • Chrome (web)    • chrome • web-javascript • Google Chrome 132.0.6834.110

[✓] Network resources [618ms]
    • All expected network resources are available.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work lista: desktopRunning on desktopa: gamedevIssues related to game development with Flutterfound in release: 3.28Found to occur in 3.28found in release: 3.29Found to occur in 3.29frameworkflutter/packages/flutter repository. See also f: labels.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