Skip to content

IME: multi-character CJK candidate pick duplicates composing run on macOS (user-reported; canonical-delta repro does not fail) #580

@JulianPscheid

Description

@JulianPscheid

Summary

A Traditional Chinese user on macOS reports that picking multi-character candidates in FleatherEditor produces duplicated text. Single-character picks work. Representative output from their note (intended 但神不會過去式):

但神不會但神不會過去會過去式

Same shape as the closed #150.

What I investigated

I couldn't reproduce with a programmatic test. A minimal widget test that drives RawEditorState.updateEditingValueWithDeltas with the canonical delta sequence — N TextEditingDeltaInsertion events for composing keystrokes, then a single TextEditingDeltaReplacement for the candidate pick — passes. Test source is included below.

I also tried a "stale-IME-state" hypothesis (IME emits deltas against its own pre-pick view of the text). That crashes parchment with index == 0 || (index > 0 && index < length), so whatever macOS actually emits must stay index-consistent with the post-pick document.

Ask

Does anyone with a Traditional Chinese IME (Pinyin or Zhuyin) on macOS see this? A debugPrint of the received delta list inside RawEditorStateTextInputClientMixin.updateEditingValueWithDeltas during a real multi-character candidate pick would let us tell whether this is a fleather bug or something on the Flutter macOS engine side that needs to go upstream to flutter/flutter.

Environment

  • OS: macOS (user-reported)
  • Flutter: 3.41.7 (stable, 2026-04-15)
  • Fleather: 1.26.0
  • Parchment: 1.25.1

Canonical-delta test (passes — included as a baseline)

// Baseline test for Fleather's IME delta handling, written as part of
// investigating a user-reported bug where multi-character CJK candidate
// picks produce duplicated text on macOS (fleather 1.26.0, Flutter 3.41.7).
//
// This test confirms that fleather handles the CANONICAL delta sequence
// correctly: a run of TextEditingDeltaInsertion events (one per composing
// keystroke) followed by a TextEditingDeltaReplacement (candidate pick)
// produces exactly the candidate text with no duplication.
//
// Since this passes, the user-reported duplication must originate from a
// different delta stream than the canonical one — most likely from macOS's
// platform-to-Dart translation for CJK input methods, not from fleather's
// delta handler itself. Upstream maintainers with a Chinese IME installed
// should capture the actual delta stream for comparison.
//
// Run with: flutter test test/fleather_ime_test.dart
import 'package:fleather/fleather.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

/// Feeds an IME composition sequence: N insertions (one per phonetic key) and
/// a final replacement when the user picks the candidate.
Future<void> _composeAndPick({
  required WidgetTester tester,
  required RawEditorState editorState,
  required String existingText,
  required String composing,
  required String candidate,
}) async {
  final baseOffset = existingText.length;
  String running = existingText;
  for (var i = 0; i < composing.length; i++) {
    final ch = composing[i];
    final oldText = running;
    running = '$running$ch';
    editorState.updateEditingValueWithDeltas([
      TextEditingDeltaInsertion(
        oldText: oldText,
        textInserted: ch,
        insertionOffset: baseOffset + i,
        selection: TextSelection.collapsed(offset: baseOffset + i + 1),
        composing: TextRange(start: baseOffset, end: baseOffset + i + 1),
      ),
    ]);
    await tester.pump();
  }

  editorState.updateEditingValueWithDeltas([
    TextEditingDeltaReplacement(
      oldText: running,
      replacedRange: TextRange(
          start: baseOffset, end: baseOffset + composing.length),
      replacementText: candidate,
      selection: TextSelection.collapsed(offset: baseOffset + candidate.length),
      composing: TextRange.empty,
    ),
  ]);
  await tester.pump();
}

void main() {
  testWidgets(
    'canonical IME delta sequence: multi-character candidate pick replaces composing run',
    (tester) async {
      final controller = FleatherController();
      final focusNode = FocusNode();

      await tester.pumpWidget(MaterialApp(
        home: Scaffold(
          body: FleatherEditor(
            controller: controller,
            focusNode: focusNode,
            autofocus: true,
          ),
        ),
      ));

      focusNode.requestFocus();
      await tester.pumpAndSettle();
      final editorState = tester.state<RawEditorState>(find.byType(RawEditor));

      await _composeAndPick(
        tester: tester,
        editorState: editorState,
        existingText: '',
        composing: 'guoqu',
        candidate: '過去',
      );
      expect(
        controller.document.toPlainText().replaceAll('\n', ''),
        '過去',
        reason: 'single candidate pick should yield only the candidate',
      );

      await _composeAndPick(
        tester: tester,
        editorState: editorState,
        existingText: '過去',
        composing: 'danshenbuhui',
        candidate: '但神不會',
      );
      expect(
        controller.document.toPlainText().replaceAll('\n', ''),
        '過去但神不會',
        reason:
            'two successive candidate picks must append cleanly (no accumulation)',
      );

      // Let fleather's history-throttle timer drain before teardown.
      await tester.pump(const Duration(milliseconds: 600));
    },
  );
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions