Skip to content

Text editing history inserts a new entry after an undo/redo. #120794

@bleroux

Description

@bleroux
  • _TextEditingHistoryState registers a listener to be notified when a TextEditingController value is updated in order to call _TextEditingHistoryState._push (which might add an entry in the history state).

  • After an undo/redo (_TextEditingHistoryState._undo or _redo), _TextEditingHistoryState might call the _TextEditingHistory.onTriggered callback.

  • Current _TextEditingHistory.onTriggered implementation calls EditableTextState.userUpdateTextEditingValue which will change the TextEditingController value and leads to _TextEditingHistoryState being notified and _TextEditingHistoryState._push.

This reentrant call brings complexity when debugging text editing history issues and can lead to complex issues like the following existing test failing when adding a delay between two undos:

https://github.com/flutter/flutter/blob/df41e58f6f4e36475dee4949523f1ae3cb955dfc/packages/flutter/test/widgets/editable_text_test.dart#L13368-13591

Code sample (existing test updated with a delay between two calls to undo).
    testWidgets('does save composing changes on Android', (WidgetTester tester) async {
      final TextEditingController controller = TextEditingController();
      final FocusNode focusNode = FocusNode();
      await tester.pumpWidget(
        MaterialApp(
          home: EditableText(
            controller: controller,
            focusNode: focusNode,
            style: textStyle,
            cursorColor: Colors.blue,
            backgroundCursorColor: Colors.grey,
            cursorOpacityAnimates: true,
            autofillHints: null,
          ),
        ),
      );

      expect(
        controller.value,
        TextEditingValue.empty,
      );

      focusNode.requestFocus();
      expect(
        controller.value,
        TextEditingValue.empty,
      );
      await tester.pump();
      expect(
        controller.value,
        const TextEditingValue(
          selection: TextSelection.collapsed(offset: 0),
        ),
      );

      // Wait for the throttling.
      await tester.pump(const Duration(milliseconds: 500));

      // Enter some regular non-composing text that is undoable.
      await tester.enterText(find.byType(EditableText), '1 ');
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 ',
          selection: TextSelection.collapsed(offset: 2),
        ),
      );
      await tester.pump(const Duration(milliseconds: 500));

      // Enter some composing text.
      final EditableTextState state =
          tester.state<EditableTextState>(find.byType(EditableText));
      state.userUpdateTextEditingValue(
        const TextEditingValue(
          text: '1 ni',
          composing: TextRange(start: 2, end: 4),
          selection: TextSelection.collapsed(offset: 4),
        ),
        SelectionChangedCause.keyboard,
      );
      await tester.pump(const Duration(milliseconds: 500));

      // Enter some more composing text.
      state.userUpdateTextEditingValue(
        const TextEditingValue(
          text: '1 nihao',
          composing: TextRange(start: 2, end: 7),
          selection: TextSelection.collapsed(offset: 7),
        ),
        SelectionChangedCause.keyboard,
      );
      await tester.pump(const Duration(milliseconds: 500));

      // Commit the composing text.
      state.userUpdateTextEditingValue(
        const TextEditingValue(
          text: '1 你好',
          selection: TextSelection.collapsed(offset: 4),
        ),
        SelectionChangedCause.keyboard,
      );
      await tester.pump(const Duration(milliseconds: 500));

      expect(
        controller.value,
        const TextEditingValue(
          text: '1 你好',
          selection: TextSelection.collapsed(offset: 4),
        ),
      );

      // Undo/redo includes the composing changes.
      await sendUndo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 nihao',
          selection: TextSelection.collapsed(offset: 7),
        ),
      );

      // Waiting before two undos should have no effect.
      await tester.pump(const Duration(milliseconds: 500));

      await sendUndo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 ni',
          selection: TextSelection.collapsed(offset: 4),
        ),
      );
      await sendUndo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 ',
          selection: TextSelection.collapsed(offset: 2),
        ),
      );

      await sendRedo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 ni',
          selection: TextSelection.collapsed(offset: 4),
        ),
      );
      await sendRedo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 nihao',
          selection: TextSelection.collapsed(offset: 7),
        ),
      );
      await sendRedo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 你好',
          selection: TextSelection.collapsed(offset: 4),
        ),
      );
      await sendRedo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 你好',
          selection: TextSelection.collapsed(offset: 4),
        ),
      );

      await sendUndo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 nihao',
          selection: TextSelection.collapsed(offset: 7),
        ),
      );
      await sendUndo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 ni',
          selection: TextSelection.collapsed(offset: 4),
        ),
      );
      await sendUndo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 ',
          selection: TextSelection.collapsed(offset: 2),
        ),
      );
      await sendUndo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          selection: TextSelection.collapsed(offset: 0),
        ),
      );
      await sendUndo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          selection: TextSelection.collapsed(offset: 0),
        ),
      );

      await sendRedo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 ',
          selection: TextSelection.collapsed(offset: 2),
        ),
      );
      await sendRedo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 ni',
          selection: TextSelection.collapsed(offset: 4),
        ),
      );
      await sendRedo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 nihao',
          selection: TextSelection.collapsed(offset: 7),
        ),
      );
      await sendRedo(tester);
      expect(
        controller.value,
        const TextEditingValue(
          text: '1 你好',
          selection: TextSelection.collapsed(offset: 4),
        ),
      );

    // On web, these keyboard shortcuts are handled by the browser.
    }, variant: TargetPlatformVariant.only(TargetPlatform.android), skip: kIsWeb); // [intended]

Expected result:
The test should succeed.

Actual result:
The test fails (because the first undo leads to a change in the history).

Preventing history insertion during an undo/redo will be a first step before fixing #120194 and making progress on #99186.

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work lista: desktopRunning on desktopa: text inputEntering text in a text field or keyboard related problemsf: material designflutter/packages/flutter/material repository.found in release: 3.7Found to occur in 3.7found in release: 3.8Found to occur in 3.8frameworkflutter/packages/flutter repository. See also f: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onr: fixedIssue is closed as already fixed in a newer version

Type

No type

Projects

Status

Done (PR merged)

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions