Skip to content

fix: scope the transaction compose map to its transaction - #1237

Open
Moonomo wants to merge 1 commit into
AppFlowy-IO:mainfrom
Moonomo:fix/compose-map-per-transaction
Open

fix: scope the transaction compose map to its transaction#1237
Moonomo wants to merge 1 commit into
AppFlowy-IO:mainfrom
Moonomo:fix/compose-map-per-transaction

Conversation

@Moonomo

@Moonomo Moonomo commented Sep 4, 2026

Copy link
Copy Markdown

Summary

  • move the pending-delta cache of TextTransaction (_composeMap) from a static on the extension onto the Transaction instance, so a transaction that is never applied cannot hand its deltas to the next one
  • clear that cache in a finally inside compose(), so a delta that fails to compose is discarded with its transaction instead of being retried by every later one
  • add two regression tests in test/core/transform/transaction_test.dart that fail on main and pass with the fix

Fixes: AppFlowy-IO/AppFlowy#4763

Root cause

TextTransaction (lib/src/core/transform/transaction.dart) queues the deltas built by insertText, deleteText, mergeText, formatText, and replaceText in static final Map<Node, List<Delta>> _composeMap, one map shared by every Transaction in the process. compose() folds each queued delta over its node's current delta, asserts the result contains only TextInserts, and clears the map only after the whole loop has succeeded.

EditorState.apply returns before it reads transaction.operations when the editor is disposed or not editable. A text transaction refused that way is never composed, so its deltas stay queued, and every later transaction on any node re-composes them as if they were its own:

  • a format or insert refused while the editor was read-only is applied by the next transaction that composes, typically the next keystroke, on whatever node it targeted;
  • once a leftover no longer fits its node (a refused delete retried, or a range a later edit already removed), Delta.compose runs past the end of the delta and emits a TextDelete, the inserts-only assertion throws inside compose(), and because the map is never cleared on that path the same assertion fires on every subsequent transaction in the process. Typing stops landing in every document until the app restarts. The stack in #4763 (Delta.composeTextTransaction.composeTransaction.operations) is this path.

Host apps reach it through their own toolbars and menus, which call toggleAttribute / formatDelta or build text transactions directly against an editor that is read-only or was just disposed. The package's own floating toolbar and character shortcuts are gated on editable, and deleteSelection reads transaction.operations for its debug log before apply, so the built-in UI does not trigger it on its own.

The cache was static because an extension cannot hold state; the transaction is its natural owner, and chained calls (transaction..deleteText(..)..insertText(..)) compose together exactly as before. No public API changes.

Tests

  • flutter test test/core/transform/ — 38 passing. The two new cases fail on main: the refused ' world' insert composes in and the text reads Ahello world instead of Ahello; two refused whole-text deletes make operations throw 'composed.every((element) => element is TextInsert)': is not true.
  • flutter analyze — no diagnostics introduced by this change.
  • dart format --set-exit-if-changed . and dart run custom_lint — clean.

Manual reproduction (example app)

The Fixed Toolbar page (example/lib/pages/fixed_toolbar_editor.dart) calls editorState.toggleAttribute from an external toolbar without checking editable, which is the host-app shape. It has no read-only switch, so temporarily make the unused Icons.code case in _FixedToolbar run editorState.editable = !editorState.editable;.

A refused format lands later:

  1. Open Fixed Toolbar, select a word, press </> (editor is now read-only; typing does nothing).
  2. With the word still selected, press Bold. Nothing changes in either state.
  3. Press </> again, click at the end of a different paragraph, type one character.
    • main: the character appears and the word from step 1 turns bold at the same moment.
    • this branch: only the character appears.

A refused delete leaves the editor stuck:

Also route the unused Icons.format_quote case through a host-style delete of the selection:

final selection = editorState.selection;
if (selection == null || selection.isCollapsed) return;
final node = editorState.getNodeAtPath(selection.start.path)!;
editorState.apply(
  editorState.transaction
    ..deleteText(node, selection.startIndex, selection.length),
);
  1. Select the whole text of a one-line paragraph, press </>.
  2. Press the quote button twice. The text stays in either state.
  3. Press </> again, click anywhere, type one character.
    • main (debug build): nothing lands, the console shows the TextInsert assertion from TextTransaction.compose, and typing anywhere else fails the same way until restart.
    • this branch: the character appears and the editor keeps working.

TextTransaction kept its pending-delta cache in a static map shared by
every Transaction in the process, and compose() cleared it only after a
fully successful pass. A transaction that was never applied, because
EditorState.apply refused it while the editor was read-only or disposed,
or because compose() itself threw, left its deltas queued, and every
later transaction re-composed them as its own. A refused format landed
on the next keystroke; once a leftover no longer fit its node, the
inserts-only assertion fired on every transaction in the process until
the app restarted.

The cache now lives on the Transaction instance (an extension cannot
hold state, which is why it was static) and compose() clears it in a
finally, so a dropped or refused transaction takes its deltas with it.
Chained calls on one transaction still compose together.

Two regression tests cover a refused insert leaking into the next
transaction on the same node and two refused deletes tripping the
assertion; both fail on main.

Fixes AppFlowy-IO/AppFlowy#4763
@Moonomo
Moonomo marked this pull request as ready for review September 4, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] After certain operations, the editor will enter a stuck state.

1 participant