Skip to content

fix(input): keep styles and list block when typing over a leading selection; stop iOS edit-menu jitter - #612

Open
wildseansy wants to merge 3 commits into
software-mansion:mainfrom
wildseansy:fix/ios-list-bold-replacement-and-menu-flicker
Open

fix(input): keep styles and list block when typing over a leading selection; stop iOS edit-menu jitter#612
wildseansy wants to merge 3 commits into
software-mansion:mainfrom
wildseansy:fix/ios-list-bold-replacement-and-menu-flicker

Conversation

@wildseansy

Copy link
Copy Markdown
Contributor

Summary

Fixes two EnrichedMarkdownTextInput bugs reported on iOS (the first reproduces on Android too — the shared logic is mirrored there):

1. Selecting a bold run at the beginning of a list item and typing over it lost the bold style and deleted the list item.

Root cause is in the shared range edit adjustment: a replacement whose deletion starts exactly at a stored range's start classified as FullyDeleted/ClippedStart and either removed the range outright (bold lost) or moved it past the inserted text — shoving the block range off its line start, where pruneOrphanedBlockAnchors then deleted the whole list block before normalizeToLineBounds could self-heal it.

  • ENRMRangeEditAdjustment / RangeEditAdjustment.kt: replacements starting at a range's start now let the inserted text join the range, matching UIKit's rule that replacement text inherits the attributes of the first replaced character. Links deliberately keep the old behavior — typing over a selected link/mention replaces it rather than extending it.
  • Pending styles are now seeded from the selection start when the selection is non-empty, so a continued typing run stays styled through the post-edit grace period (previously only the first typed character kept the style).
  • Android BlockStore guards against re-adding a collapsed block that survived adjustment via inheritance.

2. The selection edit menu repeatedly jumped up and down while selecting text.

Three compounding causes in EnrichedMarkdownTextInput.mm, all on the selection-change hot path:

  • The atomic-link selection snap wrote selectedRange on every frame of a live selection-handle drag; each programmatic write makes UIKit dismiss and re-present the edit menu while the gesture immediately reasserts the finger-derived range. The snap is now deferred until the gesture settles, and is clamped to the text length (a stale link range extending past the text end could previously drive an unbounded snap/clamp loop).
  • updateEmptyBulletMarker unconditionally forced layoutIfNeeded + a full-view setNeedsDisplay on every selection-change delegate fire; it now early-returns when there is no marker to draw or clear, and only stamps the paragraph style into storage when it is actually missing.
  • Typing attributes were rewritten on every selection change; they are now only synced at a caret (UIKit derives replacement attributes from the first selected character) and only assigned when they actually changed.

Testing

  • Standalone unit checks of the adjustment function (12 replacement/deletion/insertion overlap cases, including the link exclusion and the pure-deletion paths) all pass.
  • Maestro repro of the reported bug — set - **boldword** rest, double-tap-select the bold word, type over it — now serializes - **typed** rest (bold kept, list item intact).
  • Existing input e2e suite (inline + block flows, iOS simulator): identical results before/after the change — no regressions; the only failing flows fail on main as well (two missing iOS screenshot baselines for ordered_list_test/list_toggle_test, link_test's toolbar-link sitting off-screen in the scrolling toolbar, and a sub-threshold pixel diff on a newer iOS runtime than the baselines).
  • iOS example app builds; Android compileDebugKotlin + ktlintCheck pass; tsc clean.

While testing this we also noticed a pre-existing issue on main (reproduces without this change): after setValue() the input renders without the bullet, indent, or bold until the first edit, while the display component preview renders the same markdown correctly. Happy to file it separately.

🤖 Generated with Claude Code

wildseansy and others added 2 commits July 29, 2026 20:35
…ection

Replacing a selection that starts exactly at a stored range's start dropped
the range (bold lost) and shoved the block range off its line start, where
the orphan-anchor prune deleted the list item. Make the shared range
adjustment inheritance-aware: replacement text joins inline styles and block
ranges anchored at the edit start (UIKit gives replacement text the
attributes of the first replaced character); links still never extend. Seed
pending styles from the selection start so continued typing stays styled
through the post-edit grace period. Mirrored on Android.

Also stop the selection edit menu from jumping on iOS: defer the atomic-link
selection snap while a selection gesture is live and clamp it to the text
length (removes a dismiss/re-present loop), skip the empty-bullet redraw when
there is no marker to draw or clear, and stop rewriting typing attributes for
non-empty selections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@eszlamczyk eszlamczyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @wildseansy, the fix seems sound and reasonable! One note: Im not sure why this bump version got included in this, even though this isn't a release commit. Im gonna clean up this and a bunch of other code nitpicks and merge this today!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes selection-replacement and selection-change behavior in EnrichedMarkdownTextInput, addressing (1) style/block loss when typing over a leading selection (iOS + mirrored Android logic) and (2) iOS edit-menu jitter during live selection changes by reducing selection-change side effects and deferring atomic-link snapping.

Changes:

  • Update shared range-edit adjustment (iOS/Android) to optionally let replacement text “inherit” a range when the deletion starts at the range start (with links explicitly excluded from inheritance).
  • Improve typing-attributes/pending-style behavior to preserve styles when typing over a non-empty selection and avoid redundant typing-attributes writes.
  • Reduce iOS selection-change churn by deferring atomic-link snaps until selection gestures settle and avoiding unnecessary layout/storage mutations when updating empty-list bullet markers.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/react-native-enriched-markdown/ios/input/ENRMRangeEditAdjustment.mm Adds inheritsReplacementAtStart behavior to range adjustment for replacement edits starting at a range start.
packages/react-native-enriched-markdown/ios/input/ENRMRangeEditAdjustment.h Documents and exposes the new inheritsReplacementAtStart parameter.
packages/react-native-enriched-markdown/ios/input/ENRMInputTypingAttributesController.mm Avoids redundant typing-attributes writes; seeds pending inline styles from the first selected character for non-empty selections.
packages/react-native-enriched-markdown/ios/input/ENRMFormattingStore.mm Enables replacement inheritance for inline styles except links (links remain “replace” semantics).
packages/react-native-enriched-markdown/ios/input/ENRMBlockStore.mm Forces block ranges to inherit at start so list items remain anchored through replacements.
packages/react-native-enriched-markdown/ios/input/EnrichedMarkdownTextInput.mm Defers atomic-link snapping until gestures settle; clamps snaps to text length; reduces empty-bullet marker layout/storage churn.
packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/RangeEditAdjustment.kt Adds configurable replacement inheritance at range start via a generic inheritsReplacementAtStart predicate.
packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt Applies inheritance to all inline styles except links.
packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt Applies inheritance for blocks and prevents duplicate restoration when inheritance keeps a block alive.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@eszlamczyk eszlamczyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again thanks for this - the block-range reasoning is sound and the range-adjustment parity is carefully done. Unfortunately, testing it interactively, the inline-style behavior regresses, and I think the root cause is that the approach fixes the stored ranges / serialization but not the visual application - and the only automated coverage here asserts serialized markdown, which is exactly the part that stays correct in every failing case. So the green check doesn't tell us the feature works.

I'll keep this to the two regressions I've confirmed are introduced by this PR (there are some other visual glitches in this area, but they reproduce on main too, so they're out of scope here).

Repro (both platforms): - **boldword** rest, double-tap-select the bold word, type a replacement.

Android - only the first typed char stays bold (**t**est instead of **test**)

Confirmed in code. The PR ports the range-inheritance half to Android but not the "seed pending styles from the selection" half that the iOS side got:

  • RangeEditAdjustment inheritance makes the bold range [start, start+1] after the first char. But the insert-only branch doesn't grow a range when inserting at range.end (RangeEditAdjustment.kt - both conditions are false for an insert at range.end), so char 2+ fall outside it.
  • Nothing carries the style forward: EnrichedMarkdownTextInputView.onSelectionChanged only clears pendingStyles/pendingStyleRemovals (~L377-378), it never seeds them from the selection start, unlike iOS ENRMInputTypingAttributesController.rebuildFromContext.

So the PR description's "the shared logic is mirrored there [Android]" only holds for the range-inheritance, not the pending-style seed. Android needs the equivalent seed in onSelectionChanged for a non-empty selection.

iOS - the list bullet disappears when you replace the first word of a list item

Repro: in a list item, double-tap the first word and type over it -> the bullet marker vanishes (indent and serialized
markdown stay correct). This is tied to the new block-range inheritance path (ENRMBlockStore now passes inheritsReplacementAtStart = YES): the list block range is adjusted differently on that edit and the marker stops drawing. I haven't fully root-caused the internal mechanism (it needs on-device tracing of the block-range state after adjustForEdit + normalizeToLineBounds and what the marker drawer reads), but it's reliably reproducible and specific to this change.

Ask before merge

  1. Android: seed pending styles from the selection start (mirror iOS rebuildFromContext).
  2. iOS: fix the bullet-marker regression on first-word replacement.
  3. Add a visual regression check (screenshot/attribute assertion) rather than serialization-only - that's what let both
    of these through.

@wildseansy

Copy link
Copy Markdown
Contributor Author

Thanks for the review @eszlamczyk, I'll take a look tonight and update with points 1-3.

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.

3 participants