-
Notifications
You must be signed in to change notification settings - Fork 30.1k
Description
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
- Press and hold
[1]then, press and hold[2]then press and hold[3]. - Release
[3], keep holding[2]and[1]. - Release
[2], keep holding[1], theRawKeyUpEventfor[2]is triggered. - Release
[1], theRawKeyUpEventfor[1]is triggered.
This is correct and expected behavior.
Using the VM build with KeyboardListener or Focus.onKeyEvent APIs
- Press and hold
[1]then, press and hold2then press and hold[3]. - Release
[3], keep holding[2]and[1]. - Release
[2], keep holding[1], theKeyUpEventfor[2]is triggered. - Release
[1], theKeyUpEventfor[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
- Press and hold
[1]then, press and hold[2]then press and hold[3]. - Release
[3], keep holding[2]and[1]. - Release
[2], keep holding[1], theRawKeyUpEventfor[2]is triggered. - Release
[1], theRawKeyUpEventfor[1]is triggered.
This is correct and expected behavior.
Using the WEB build with KeyboardListener or Focus.onKeyEvent APIs
- Press and hold
[1]then, press and hold[2]then press and hold[3]. - Release
[3], keep holding[2]and[1], theKeyUpEventfor[2]is triggered shortly after, despite[2]still being pressed. - Release
[2], keep holding[1], the actualKeyUpEventfor[2]is not triggered. - Release
[1], theKeyUpEventfor[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.