Skip to content

fix: don't split a surrogate pair when applying the composing style - #2754

Open
mtallenca wants to merge 1 commit into
singerdmx:masterfrom
mtallenca:fix/composing-range-surrogate-split
Open

fix: don't split a surrogate pair when applying the composing style#2754
mtallenca wants to merge 1 commit into
singerdmx:masterfrom
mtallenca:fix/composing-range-surrogate-split

Conversation

@mtallenca

Copy link
Copy Markdown
Contributor

Description

_TextLineState._splitAndApplyComposingStyle cuts the node's text at the composing offsets with plain substring:

final composingStart = widget.composingRange.start - node.documentOffset;
final composingEnd = widget.composingRange.end - node.documentOffset;
final text = child.toPlainText();

final textBefore = text.substring(0, composingStart);
final textComposing = text.substring(composingStart, composingEnd);
final textAfter = text.substring(composingEnd);

Nothing checks that those offsets land on a code-point boundary. The composing range comes from the platform IME, and it is carried forward onto the new value every time the document changes — RawEditorStateTextInputClientMixin.updateRemoteValueIfNeeded only drops it when it no longer fits the text length:

final composingRange = _lastKnownRemoteTextEditingValue!.composing;
final actualValue = value.copyWith(
  // Ignore last known composing range if it exceeds current text length.
  composing: composingRange.end > value.text.length ? null : composingRange,
);

So a programmatic edit that shifts the text (in our app, formatting links and inserting a character ahead of the composing range) leaves a range that still "fits" but now points at different characters. When a boundary lands inside a UTF-16 surrogate pair, each part keeps a lone surrogate, and malformed UTF-16 throws in the engine's paragraph builder as soon as the line is laid out — taking down the frame:

Fatal Exception: Invalid argument(s): string is not well-formed UTF-16
0  _NativeParagraphBuilder.addText (dart:ui)
1  TextSpan.build (text_span.dart:298)
2  TextSpan.build (text_span.dart:316)
3  TextPainter._createParagraph (text_painter.dart:1203)
4  TextPainter.layout (text_painter.dart:1264)
5  RenderParagraph.performLayout (paragraph.dart:943)
...
6  RenderEditableTextLine.performLayout (text_line.dart:1328)
7  RenderEditor.performLayout (editor.dart:1142)

We see this in production (Crashlytics, flutter_quill 11.5.1, Android) in an editor whose document is mutated programmatically while the user types, on notes that contain an emoji.

The guards above the split only compare numbers, so they don't help: isComposingRangeOutOfLine checks the range against the line bounds and isNodeInComposingRange against the node bounds — a stale range passes both.

Fix

Skip the composing decoration when the range is out of range or would split a surrogate pair, instead of building a span that cannot be rendered. The composing underline is lost for that frame; the text still renders and the next IME update restores it.

Test

test/bug_fix_test.dart reproduces it: an editor holding ab😀cd plus a composing range ending at code unit 3 (inside the emoji's surrogate pair) throws the exact stack above before the fix and lays out cleanly after it.

Notes

While writing the test I hit a second, unrelated issue: closeConnectionIfNeeded sets _lastKnownRemoteTextEditingValue = null, whose setter notifies composingRange, so QuillRawEditorState._onComposingRangeChanged calls setState from dispose and asserts _lifecycleState != _ElementLifecycle.defunct when the editor is torn down with a composing range still set. The test works around it by clearing the composing range first. Happy to send a separate PR for that if you'd like.

_TextLineState._splitAndApplyComposingStyle cuts the node text at the
composing offsets with substring, without checking that they land on a
code-point boundary. The composing range comes from the platform IME and
is carried forward onto the new value whenever the document changes
(updateRemoteValueIfNeeded only drops it when composingRange.end exceeds
the new text length), so a programmatic edit that shifts the text -- e.g.
inserting a character ahead of the range -- can leave a boundary inside a
UTF-16 surrogate pair. Each part then holds a lone surrogate, and
malformed UTF-16 throws 'Invalid argument(s): string is not well-formed
UTF-16' from _NativeParagraphBuilder.addText when the line is laid out,
taking down the frame (seen in the wild on Android with an emoji in the
text).

Skip the composing decoration when the range is out of range or would
split a pair: the underline is lost for that frame, which beats a span
that cannot be rendered.
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.

1 participant