fix(compose): rich-text formatting, links and inline images destroyed by the next keystroke #480

Open
opened 2026-07-10 19:13:59 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-10 19:13:59 +00:00 (Migrated from github.com)

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).

Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.

app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt:129 — critical

All inline formatting, links, and inline images are silently destroyed by the next keystroke: emit() rebuilds the rich-text model from the TextFieldValue that the legacy CoreTextField's EditProcessor produces, and that value carries NO annotations (verified in compose ui-text 1.11.3 sources: EditProcessor.apply() returns TextFieldValue(annotatedString = EditingBuffer.toAnnotatedString()) where toAnnotatedString() is literally AnnotatedString(toString()) - plain text, spans/spanStyles/string annotations all dropped).

Failure scenario: User bolds a word (or applies color/font/link, or inserts an inline image), then types any character: onValueChange delivers a plain AnnotatedString; toRichContent() finds no spans/URL_TAG/IMAGE_TAG annotations, hasFormatting() goes false, html becomes null, and ComposeViewModel.onBodyChange prunes the now-unreferenced inline-image attachment. The formatting visibly vanishes, the 1.5s debounced autosave persists the stripped draft (permanent loss of a resumed draft's formatting and inline images), and a sent message carries a literal '[image: name]' token with no image attached. Only bullet/quote/numbered markers survive (they live in the text), which is exactly and only what ComposeScreenTest's single formatting E2E exercises - so the entire rich-text feature set (#72/#73/#76/#77) is broken for the normal 'format, then keep typing' flow.

Verifier justification (CONFIRMED): Verified against the exact shipped dependency: the repo's Compose BOM 2026.06.00 resolves ui-text-android:1.11.3, whose sources (in the local Gradle cache) show EditingBuffer.toAnnotatedString() = AnnotatedString(toString()) and EditProcessor.apply() building the post-edit TextFieldValue from that plain string — so every keystroke through the legacy OutlinedTextField(TextFieldValue) delivers an AnnotatedString with no spanStyles or string annotations. RichTextEditor.emit() (line 127-133) stores that value verbatim and rebuilds RichTextContent from it with no span re-application; the reseed guard at line 120 cannot recover because emit sets lastEmitted to exactly what onBodyChange stores back. Concrete trigger: select a word, tap B (applyStyle produces an annotated value, bold renders), then type any character → onValueChange delivers plain text → hasFormatting() false → html null → formatting visibly vanishes and ComposeViewModel.onBodyChange (referencedContentIds(null) = empty set) prunes all non-pending inline-image attachments, leaving a literal '[image: name]' token. The stripped (plain, null-html) pair is what the debounced autosave persists and what send uses. Only block markers (bullet/quote/numbered) survive since they are literal text — and ComposeScreenTest's sole formatting E2E (line 188-198) deliberately tests only the bullet marker and never types after formatting, which is why this shipped green.

Defective line: RichTextEditor.kt:129 val content = newValue.annotatedString.toRichContent(baseStyle)— fed by ui-text 1.11.3 EditProcessor.kt:111annotatedString = mBuffer.toAnnotatedString()where EditingBuffer.kt:295internal fun toAnnotatedString(): AnnotatedString = AnnotatedString(toString())``

Fix hint: In RichTextBodyField's emit/onValueChange (RichTextEditor.kt), never trust the annotations on the incoming TextFieldValue: keep the previous RichTextContent as the source of truth, diff newValue.text against the old text, shift/extend the existing spans/links/images/alignments across the edit range, and rebuild the displayed AnnotatedString via toAnnotatedString(). Longer term, migrate to the TextFieldState/BasicTextField overload where styling is owned outside the edit buffer. Add an instrumented test that applies an inline style (bold) and then types, asserting the emitted html stays non-null and the inline attachment survives.

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict **CONFIRMED**). **Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.** ## `app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt:129` — critical All inline formatting, links, and inline images are silently destroyed by the next keystroke: emit() rebuilds the rich-text model from the TextFieldValue that the legacy CoreTextField's EditProcessor produces, and that value carries NO annotations (verified in compose ui-text 1.11.3 sources: EditProcessor.apply() returns TextFieldValue(annotatedString = EditingBuffer.toAnnotatedString()) where toAnnotatedString() is literally AnnotatedString(toString()) - plain text, spans/spanStyles/string annotations all dropped). **Failure scenario:** User bolds a word (or applies color/font/link, or inserts an inline image), then types any character: onValueChange delivers a plain AnnotatedString; toRichContent() finds no spans/URL_TAG/IMAGE_TAG annotations, hasFormatting() goes false, html becomes null, and ComposeViewModel.onBodyChange prunes the now-unreferenced inline-image attachment. The formatting visibly vanishes, the 1.5s debounced autosave persists the stripped draft (permanent loss of a resumed draft's formatting and inline images), and a sent message carries a literal '[image: name]' token with no image attached. Only bullet/quote/numbered markers survive (they live in the text), which is exactly and only what ComposeScreenTest's single formatting E2E exercises - so the entire rich-text feature set (#72/#73/#76/#77) is broken for the normal 'format, then keep typing' flow. **Verifier justification (CONFIRMED):** Verified against the exact shipped dependency: the repo's Compose BOM 2026.06.00 resolves ui-text-android:1.11.3, whose sources (in the local Gradle cache) show EditingBuffer.toAnnotatedString() = AnnotatedString(toString()) and EditProcessor.apply() building the post-edit TextFieldValue from that plain string — so every keystroke through the legacy OutlinedTextField(TextFieldValue) delivers an AnnotatedString with no spanStyles or string annotations. RichTextEditor.emit() (line 127-133) stores that value verbatim and rebuilds RichTextContent from it with no span re-application; the reseed guard at line 120 cannot recover because emit sets lastEmitted to exactly what onBodyChange stores back. Concrete trigger: select a word, tap B (applyStyle produces an annotated value, bold renders), then type any character → onValueChange delivers plain text → hasFormatting() false → html null → formatting visibly vanishes and ComposeViewModel.onBodyChange (referencedContentIds(null) = empty set) prunes all non-pending inline-image attachments, leaving a literal '[image: name]' token. The stripped (plain, null-html) pair is what the debounced autosave persists and what send uses. Only block markers (bullet/quote/numbered) survive since they are literal text — and ComposeScreenTest's sole formatting E2E (line 188-198) deliberately tests only the bullet marker and never types after formatting, which is why this shipped green. **Defective line:** `RichTextEditor.kt:129 `val content = newValue.annotatedString.toRichContent(baseStyle)` — fed by ui-text 1.11.3 EditProcessor.kt:111 `annotatedString = mBuffer.toAnnotatedString()` where EditingBuffer.kt:295 `internal fun toAnnotatedString(): AnnotatedString = AnnotatedString(toString())`` **Fix hint:** In RichTextBodyField's emit/onValueChange (RichTextEditor.kt), never trust the annotations on the incoming TextFieldValue: keep the previous RichTextContent as the source of truth, diff newValue.text against the old text, shift/extend the existing spans/links/images/alignments across the edit range, and rebuild the displayed AnnotatedString via toAnnotatedString(). Longer term, migrate to the TextFieldState/BasicTextField overload where styling is owned outside the edit buffer. Add an instrumented test that applies an inline style (bold) and then types, asserting the emitted html stays non-null and the inline attachment survives.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#480