You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The stale cursor is visible whenever the UI thread is busy at the moment the pointer re-enters the client area. On an idle app the framework corrects it within milliseconds (explained under Actual results), which is why the original steps here did not reproduce reliably. The code sample below makes it deterministic by blocking the UI thread periodically and showing when it is blocked (red) or idle (green).
Run the code sample below on desktop (flutter run -d windows) or (flutter run -d macos).
Move the mouse over the left or right window border until it turns into a resize arrow (do not click).
While the indicator is red, move the mouse from the border straight onto the text in the middle of the window.
Expected results
Once the mouse is over the Flutter content, the cursor changes back to the cursor requested by the framework (the basic arrow by default).
Actual results
The resize arrow persists over the app content for as long as the UI thread stays blocked (the whole red phase, 1.2 s in the sample) and snaps back to the arrow the moment the UI thread unblocks. Measured by polling GetCursorInfo while scripting the pointer movement: stale periods of 800+ ms per cycle with the sample, versus roughly 15 ms on an idle app, which is why an idle app appears fine.
The cause is in the Windows embedder: FlutterWindow's WM_SETCURSOR handler returns TRUE for HTCLIENT hits without activating any cursor. Returning TRUE is intentional (it stops DefWindowProc from resetting the cursor to the window class cursor on every mouse move), but it means the visible cursor is only corrected by the framework's next mouse_cursor channel update, so the cursor stays wrong until that round trip completes. A native Win32 app sets the cursor synchronously inside WM_SETCURSOR and has no such gap.
Why an idle app masks it: moving onto the resize border leaves the client area, which sends the framework a pointer remove and clears its cached cursor session (MouseCursorManager.handleDeviceCursorUpdate); the pointer add on re-entry then makes the framework re-send the basic cursor immediately. Whenever the UI thread is busy instead (app startup, shader-compilation jank, heavy frames, debug builds), that correction is delayed and the stale cursor is plainly visible.
Code sample
Code sample
import'dart:async';
import'package:flutter/material.dart';
import'package:flutter/scheduler.dart';
voidmain() {
runApp(constJankDemoApp());
}
classJankDemoAppextendsStatelessWidget {
constJankDemoApp({super.key});
@overrideWidgetbuild(BuildContext context) {
returnconstMaterialApp(
debugShowCheckedModeBanner:false,
home:JankDemoPage(),
);
}
}
classJankDemoPageextendsStatefulWidget {
constJankDemoPage({super.key});
@overrideState<JankDemoPage> createState() =>_JankDemoPageState();
}
class_JankDemoPageStateextendsState<JankDemoPage> {
staticconstDuration _period =Duration(milliseconds:2000);
staticconstint _stallMs =1200;
bool _busy =false;
Timer? _timer;
@overridevoidinitState() {
super.initState();
_timer =Timer.periodic(_period, (_) =>_stall());
}
@overridevoiddispose() {
_timer?.cancel();
super.dispose();
}
void_stall() {
// A minimized window renders no frames, so a pending stall's post-frame// callback has not run yet; do not queue another one on top of it.if (_busy) {
return;
}
setState(() => _busy =true);
// Let the frame showing the red indicator reach the screen before// blocking the UI thread; a blocked thread cannot repaint.SchedulerBinding.instance.addPostFrameCallback((_) {
Future<void>.delayed(constDuration(milliseconds:100), () {
finalStopwatch sw =Stopwatch()..start();
while (sw.elapsedMilliseconds < _stallMs) {}
if (mounted) {
setState(() => _busy =false);
}
});
});
}
@overrideWidgetbuild(BuildContext context) {
returnScaffold(
body:Center(
child:Column(
mainAxisAlignment:MainAxisAlignment.center,
children:<Widget>[
Container(
width:28,
height:28,
decoration:BoxDecoration(
shape:BoxShape.circle,
color: _busy ?Colors.red :Colors.green,
),
),
constSizedBox(height:12),
Text(
_busy ?'UI thread: BLOCKED':'UI thread: idle',
style:constTextStyle(fontSize:24),
),
constSizedBox(height:24),
constText(
'Static content, nothing hoverable.\n''Enter from the left or right window border while the light ''is red:\nthe resize cursor persists until the UI thread ''unblocks.',
textAlign:TextAlign.center,
),
],
),
),
);
}
}
Screenshots or Video
Screenshots / Video demonstrationstale_resize_cursor_busy_ui_thread.mp4
Logs
Logs
Not applicable: the defect is visual and produces no log output.
Flutter Doctor output
Doctor output
[√] Flutter (Channel stable, 3.44.4, on Microsoft Windows [Version 10.0.19045.6466], locale de-DE) [874ms] • Flutter version 3.44.4 on channel stable at C:\Users\serha\flutter • Upstream repository https://github.com/flutter/flutter.git • Framework revision ad70ec4617 (3 weeks ago), 2026-06-24 11:07:06 -0700 • Engine revision 53bfd6d932 • Dart version 3.12.0 (build 3.12.0-139.0.dev) • DevTools version 2.54.0 • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration[√] Windows Version (10 Pro 64-bit, 22H2, 2009) [5,4s][√] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [15,9s] • Android SDK at C:\Users\serha\AppData\Local\Android\sdk • Emulator version 31.2.9.0 (build_id 8316981) (CL:N/A) • Platform android-36, build-tools 36.1.0 • Java binary at: C:\Program Files\Android\Android Studio1\jbr\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.11+0--11852314) • All Android licenses accepted.[√] Chrome - develop for the web [294ms] • Chrome at C:\Program Files (x86)\Google\Chrome\Application\chrome.exe[√] Visual Studio - develop Windows apps (Visual Studio Build Tools 2022 17.14.20) [292ms] • Visual Studio at F:\VS_CPP_Tools • Visual Studio Build Tools 2022 version 17.14.36705.20 • Windows 10 SDK version 10.0.26100.0[√] Connected device (3 available) [281ms] • Windows (desktop) • windows • windows-x64 • Microsoft Windows [Version 10.0.19045.6466] • Chrome (web) • chrome • web-javascript • Google Chrome 150.0.7871.115 • Edge (web) • edge • web-javascript • Microsoft Edge 150.0.4078.65[√] Network resources [752ms] • All expected network resources are available.• No issues found!
Also applies to current master: the WM_SETCURSOR handling is unchanged there.
Steps to reproduce
The stale cursor is visible whenever the UI thread is busy at the moment the pointer re-enters the client area. On an idle app the framework corrects it within milliseconds (explained under Actual results), which is why the original steps here did not reproduce reliably. The code sample below makes it deterministic by blocking the UI thread periodically and showing when it is blocked (red) or idle (green).
flutter run -d windows) or (flutter run -d macos).Expected results
Once the mouse is over the Flutter content, the cursor changes back to the cursor requested by the framework (the basic arrow by default).
Actual results
The resize arrow persists over the app content for as long as the UI thread stays blocked (the whole red phase, 1.2 s in the sample) and snaps back to the arrow the moment the UI thread unblocks. Measured by polling
GetCursorInfowhile scripting the pointer movement: stale periods of 800+ ms per cycle with the sample, versus roughly 15 ms on an idle app, which is why an idle app appears fine.The cause is in the Windows embedder:
FlutterWindow'sWM_SETCURSORhandler returnsTRUEforHTCLIENThits without activating any cursor. ReturningTRUEis intentional (it stopsDefWindowProcfrom resetting the cursor to the window class cursor on every mouse move), but it means the visible cursor is only corrected by the framework's nextmouse_cursorchannel update, so the cursor stays wrong until that round trip completes. A native Win32 app sets the cursor synchronously insideWM_SETCURSORand has no such gap.Why an idle app masks it: moving onto the resize border leaves the client area, which sends the framework a pointer remove and clears its cached cursor session (
MouseCursorManager.handleDeviceCursorUpdate); the pointer add on re-entry then makes the framework re-send the basic cursor immediately. Whenever the UI thread is busy instead (app startup, shader-compilation jank, heavy frames, debug builds), that correction is delayed and the stale cursor is plainly visible.Code sample
Code sample
Screenshots or Video
Screenshots / Video demonstration
stale_resize_cursor_busy_ui_thread.mp4
Logs
Logs
Not applicable: the defect is visual and produces no log output.Flutter Doctor output
Doctor output
Also applies to current master: the
WM_SETCURSORhandling is unchanged there.