Skip to content

[macOS] With semantics enabled, the first composing keystroke into an empty TextField loses its composition and the field stops repainting #194199

Description

@agrapine

Steps to reproduce

  1. flutter create --platforms=macos repro, replace lib/main.dart with the code sample, and add the Swift snippet to macos/Runner/MainFlutterWindow.swift. The snippet's channel posts NSApplicationDidChangeAccessibilityEnhancedUserInterfaceNotification with AXEnhancedUserInterface = YES. AppKit posts the same notification when another process sets that attribute on the app through AXUIElementSetAttributeValue, as assistive apps do. The engine enables semantics when it arrives (FlutterEngine.mm, onAccessibilityStatusChanged:). Setting the attribute from another process has the same effect on a release build.
  2. flutter run -d macos, with the ABC keyboard layout.
  3. Click the first field and type ⌥E, then e, then x. It reads éx.
  4. Click "Enable semantics, as an assistive app does".
  5. Click the second field and type ⌥E, then e, then x.

Expected results

The second field behaves like the first: ⌥E shows the accent as marked text, e turns it into é, and the field reads éx.

Actual results

  • The second field shows an underlined ´ and stops repainting; even the caret stops blinking. No frame runs until something unrelated schedules one, such as hovering the button or the window resigning key.
  • Its controller ends up holding ex: the pending accent is discarded, so é never forms.
  • With an input method that composes with marked text (tested with Sogou Pinyin 6.25.3), typing ceshi leaves the field at an underlined c. The input method commits c on its own and starts a new composition at e, so choosing a candidate yields c followed by the candidate for eshi.
  • Typing into the field again, after it has been edited once, works.
  • With FLTEnableMergedPlatformUIThread set to false, the field repaints normally, but the first composition is still lost.

Mechanism, from the logs below and from method swizzling on FlutterTextInputPlugin and FlutterTextField:

  1. The first keystroke arrives as marked text (setMarkedText:), and the framework applies composing 0..1.
  2. The next frame's flushSemantics sends the field's new value. Shell::OnEngineUpdateSemantics uses RunNowOrPostTask, so with merged threads AccessibilityBridgeMac handles the update synchronously inside the frame. Its VALUE_CHANGED branch calls [FlutterTextField startEditing], which calls selectText:. FlutterTextInputPlugin resigns first responder and becomes the field editor of the NSTextField while the composition is still pending, so the composition ends. The ABC dead key is discarded; Sogou Pinyin commits its letter with insertText:.
  3. The plugin reports the change through updateEditState. Shell::OnPlatformViewDispatchPlatformMessage uses RunNowAndFlushMessages, so the framework's TextInput handler runs immediately, still inside the frame (SchedulerPhase.persistentCallbacks). EditableText applies the value and marks itself dirty. ensureVisualUpdate does not schedule a frame in that phase, and layout and paint have already run. Every later setState and markNeedsPaint returns early because the dirty flags are already set, so hasScheduledFrame stays false.

The same code is on master: AccessibilityBridgeMac.mm calls startEditing synchronously from VALUE_CHANGED and DOCUMENT_SELECTION_CHANGED, with no marked-text check.

Code sample

lib/main.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SchedulerBinding.instance.addPersistentFrameCallback((_) => debugPrint('REPRO frame'));
  runApp(const MaterialApp(home: Repro()));
}

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

  @override
  State<Repro> createState() => _ReproState();
}

class _ReproState extends State<Repro> {
  final _first = TextEditingController();
  final _second = TextEditingController();

  @override
  void initState() {
    super.initState();
    for (final (name, controller) in [('first', _first), ('second', _second)]) {
      controller.addListener(() {
        final scheduler = SchedulerBinding.instance;
        debugPrint('REPRO $name "${controller.text}" composing ${controller.value.composing} '
            'phase ${scheduler.schedulerPhase}');
        scheduleMicrotask(() => debugPrint('REPRO   then hasScheduledFrame ${scheduler.hasScheduledFrame}'));
      });
    }
  }

  @override
  void dispose() {
    _first.dispose();
    _second.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(controller: _first, decoration: const InputDecoration(labelText: 'First field')),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: () => const MethodChannel('repro').invokeMethod<void>('enableSemantics'),
              child: const Text('Enable semantics, as an assistive app does'),
            ),
            const SizedBox(height: 24),
            TextField(controller: _second, decoration: const InputDecoration(labelText: 'Second field')),
          ],
        ),
      ),
    );
  }
}
macos/Runner/MainFlutterWindow.swift, after RegisterGeneratedPlugins
    // Posts what AppKit posts when an assistive app sets AXEnhancedUserInterface on this app.
    FlutterMethodChannel(name: "repro", binaryMessenger: flutterViewController.engine.binaryMessenger)
      .setMethodCallHandler { _, result in
        NotificationCenter.default.post(
          name: NSNotification.Name("NSApplicationDidChangeAccessibilityEnhancedUserInterfaceNotification"),
          object: NSApp, userInfo: ["AXEnhancedUserInterface": true])
        result(nil)
      }

Screenshots or Video

Screenshots / Video demonstration

The sample above, with the ABC keyboard layout. The first field gives éx. After the button, the second field sticks on ´ with a frozen caret, and moving the pointer over the button repaints it as ex.

flutter-sample-deadkey.mp4

Logs

Logs

ABC layout, merged threads (default). The first field, before semantics is on, then the second field after it:

REPRO first "´" composing TextRange(start: 0, end: 1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame true
REPRO first "é" composing TextRange(start: -1, end: -1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame true
REPRO first "éx" composing TextRange(start: -1, end: -1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame true
...
REPRO second "´" composing TextRange(start: 0, end: 1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame true
REPRO second "" composing TextRange(start: -1, end: -1) phase SchedulerPhase.persistentCallbacks
REPRO frame
REPRO   then hasScheduledFrame false
REPRO second "e" composing TextRange(start: -1, end: -1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame false
REPRO second "ex" composing TextRange(start: -1, end: -1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame false

No REPRO frame follows until the pointer moves over the button.

Sogou Pinyin, typing ceshi, merged threads (default), from a single-field version of the sample:

REPRO value "c" composing TextRange(start: 0, end: 1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame true
REPRO value "c" composing TextRange(start: -1, end: -1) phase SchedulerPhase.persistentCallbacks
REPRO frame
REPRO   then hasScheduledFrame false
REPRO value "ce" composing TextRange(start: 1, end: 2) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame false
...
REPRO value "ce'shi" composing TextRange(start: 1, end: 6) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame false

Sogou Pinyin with FLTEnableMergedPlatformUIThread set to false, from the single-field version:

REPRO value "c" composing TextRange(start: 0, end: 1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame true
REPRO frame
REPRO value "c" composing TextRange(start: -1, end: -1) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame true
REPRO value "ce" composing TextRange(start: 1, end: 2) phase SchedulerPhase.idle
REPRO   then hasScheduledFrame true
REPRO frame
...

Native calls on the first keystroke, Sogou Pinyin, from our app (swizzled FlutterTextInputPlugin and FlutterTextField):

setMarkedText "c"                          superview=FlutterViewWrapper
FlutterTextField startEditing              (currentEditor == nil)
plugin resignFirstResponder
insertText "c"                             plugin not first responder
plugin becomeFirstResponder                superview=_NSKeyboardFocusClipView
plugin setClient: <FlutterTextField>
setMarkedText "e" … "e'shi"

Flutter Doctor output

Doctor output
[✓] Flutter (3.47.7, on macOS 26.4 25E246 darwin-arm64)
    • Framework revision abaf9c5237, 2026-10-08
    • Engine revision deb287481e (hash d8a2d6c768c334d53e16277f837970ffa1e2de81)
    • Dart version 3.13.5
[✓] Xcode - develop for iOS and macOS (Xcode 26.6)
    • Build 17F113

Activity

  1. suryaKommana2662 commented on Oct 13, 2026

    @suryaKommana2662

    @agrapine Thanks for the report. I followed the provided reproduction steps on macOS, but I could not reproduce the issue locally. After enabling semantics, the second text field displayed éx as expected when typing Option+E, followed by e and x.

    Screen.Recording.2026-10-13.at.2.03.24.PM.mov

    Flutter Doctor Output

    [✓] Flutter (Channel stable, 3.47.7, on macOS 27.0 26A428 darwin-arm64, locale en-IN) [218ms]
        • Flutter version 3.47.7 on channel stable at /Users/suryakommana/develop/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision abaf9c5237 (5 days ago), 2026-10-08 09:51:21 -0700
        • Engine revision deb287481e
        • Dart version 3.13.5
        • DevTools version 2.60.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
          enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,486ms]
        • Android SDK at /Users/suryakommana/Library/Android/sdk
        • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
        • Platform android-37.0, build-tools 36.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 25.0.2+-15348964-b329.117)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 27.0) [693ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 27A266a
        • CocoaPods version 1.17.0
    
    [✓] Chrome - develop for the web [4ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (2 available) [9.1s]
        • macOS (desktop) • macos  • darwin-arm64   • macOS 27.0 26A428 darwin-arm64
        • Chrome (web)    • chrome • web-javascript • Google Chrome 155.0.8059.40
    
    [✓] Network resources [524ms]
        • All expected network resources are available.
    
    • No issues found!
  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Oct 13, 2026
  3. agrapine commented on Oct 13, 2026

    @agrapine
    Author

    3. @agrapine Thanks for the report. I followed the provided reproduction steps on macOS, but I could not reproduce the issue locally. After enabling semantics, the second text field displayed éx as expected when typing Option+E, followed by e and x.

        Screen.Recording.2026-10-13.at.2.03.24.PM.mov
        
      
    
      
    
      
    
    
    **Flutter Doctor Output**
    [✓] Flutter (Channel stable, 3.47.7, on macOS 27.0 26A428 darwin-arm64, locale en-IN) [218ms]
        • Flutter version 3.47.7 on channel stable at /Users/suryakommana/develop/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision abaf9c5237 (5 days ago), 2026-10-08 09:51:21 -0700
        • Engine revision deb287481e
        • Dart version 3.13.5
        • DevTools version 2.60.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
          enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,486ms]
        • Android SDK at /Users/suryakommana/Library/Android/sdk
        • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
        • Platform android-37.0, build-tools 36.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 25.0.2+-15348964-b329.117)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 27.0) [693ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 27A266a
        • CocoaPods version 1.17.0
    
    [✓] Chrome - develop for the web [4ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (2 available) [9.1s]
        • macOS (desktop) • macos  • darwin-arm64   • macOS 27.0 26A428 darwin-arm64
        • Chrome (web)    • chrome • web-javascript • Google Chrome 155.0.8059.40
    
    [✓] Network resources [524ms]
        • All expected network resources are available.
    
    • No issues found!
    

    Thanks for trying it, @suryaKommana2662.

    I ran it again with the stock 3.47.7 SDK (prebuilt engine deb287481e) on macOS 26.4 (25E246), and it still reproduces, both windowed and full screen:

    REPRO second "´" composing TextRange(start: 0, end: 1) phase SchedulerPhase.idle
    REPRO   then hasScheduledFrame true
    REPRO second "" composing TextRange(start: -1, end: -1) phase SchedulerPhase.persistentCallbacks
    REPRO frame
    REPRO second "e" composing TextRange(start: -1, end: -1) phase SchedulerPhase.idle
    REPRO   then hasScheduledFrame false
    REPRO second "ex" composing TextRange(start: -1, end: -1) phase SchedulerPhase.idle
    REPRO   then hasScheduledFrame false
    

    In your recording the underlined ´ in the second field turns into é, so the composition survived on your machine. Could you rerun with this change to the listener's debugPrint in lib/main.dart and paste the REPRO lines from the flutter run console?

            debugPrint('REPRO $name "${controller.text}" composing ${controller.value.composing} '
                'phase ${scheduler.schedulerPhase} semantics ${WidgetsBinding.instance.semanticsEnabled}');
    • If the second field's lines say semantics false, the button didn't turn semantics on. A MissingPluginException in the console means the MainFlutterWindow.swift snippet isn't in the build.
    • If they say semantics true and no line shows SchedulerPhase.persistentCallbacks, the composition survives on macOS 27.0 (26A428). I can only test 26.4 here, so that would narrow the bug to earlier macOS versions.
  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions