Skip to content

mobile: render Markdown in the editable list-item row - #876

Merged
hanzei merged 8 commits into
masterfrom
claude/github-issue-867-8i8imz
Aug 10, 2026
Merged

mobile: render Markdown in the editable list-item row#876
hanzei merged 8 commits into
masterfrom
claude/github-issue-867-8i8imz

Conversation

@hanzei

@hanzei hanzei commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #867.

Mobile's editable row showed source, so **Milk** read as Milk on the card and in the read-only editor but as **Milk** in the row you actually type in. It now swaps like the webapp's (#868): rendered until it holds the caret, source for exactly as long as it does.

The keyboard question (#824 q8), which is why mobile was split out

Under focused == editing, tapping row A then row B unmounts TextInput A and mounts TextInput B — with an unmount in between. React Native keeps the software keyboard up when focus moves directly between two mounted inputs and generally does not across an unmount, so the naive swap dismisses and reopens the keyboard, and jumps the scroll position with it, on the most common interaction a list note has.

Of the three candidates in the ticket, this takes #2, never unmount, which is what the webapp already does for its textarea:

  • The TextInput is always mounted. When the row shows its rendered form the input is moved out of flow (position: absolute, opacity: 0, pointerEvents: 'none') and the rendered Text takes its place in the column, so the row is as tall as whichever form is showing.
  • Option 1 (keep the outgoing field mounted for a frame) was rejected as a timing guess that fails under load; option 3 (whole-list toggle) would mean leaving preview mode to tick a checkbox, contradicting decision Add checked items functionality for todo lists #1 and giving up the primary interaction on a list note.

Because the field is never unmounted, everything in NoteEditorScreen that reaches for a row imperatively — Enter-to-split, backspace-merge, "add item" focus, all through itemInputRefs — keeps working on a row that happens to be showing rendered text, unchanged.

Two Android focus bugs found on device

Both were invisible to Jest, which has no native view tree and mocks focus() to a no-op. Tapping a rendered row focused the note title instead of the row. Two independent causes, found by instrumenting the path and reading the traces rather than by guessing:

  1. Focus was requested while the field was still out of flow (fba5213). The tap called focus() before the swap committed, so the input was still transparent and inside pointerEvents: 'none'. iOS refuses becomeFirstResponder for a view with user interaction disabled; Android's requestFocus falls through to the next focusable field in the window. The tap now ends the rendered form and an effect keyed on that swap does the focusing.
  2. Android flattened the wrapper view out of the hierarchy (6ed0ffd). The input's wrapper carries a style and pointerEvents: 'none' while rendered, and neither once back in flow — making it a view-flattening candidate on exactly the commit the focus follows. Flattening re-parents the EditText, and an EditText removed and re-added loses focus, which Android hands to the first focusable field. The two raced, which is why the same tap worked roughly one time in four. collapsable={false} pins the wrapper as a real native view in both states.

The device traces also proved the row does not change height across the swap, ruling out the reorderable list's itemLayoutAnimation as a cause.

The rest, carried over from #824

  • Tap-to-edit places the caret where you pointed. RN has no caretPositionFromPoint, so the new mobile/src/utils/inlineCaret.ts reconstructs the rendered offset from the line boxes onTextLayout reports and hands it to the shared inlineSourceOffset — no second offset map. The caret is forced through the controlled selection prop and released once the input reports it landed. The interpolation across a line is exact in a monospaced face and a character or two out inside a long proportional line.
  • A row only swaps when rendering changes something (inlineRendersAsSource), so a list with no Markdown in it keeps the always-live input it has always had.
  • An editable row's links are inert; a read-only row's stay live.
  • Completed rows render like any other, ~~strike~~ collision included.
  • Both forms are the same height — one style object, with an explicit lineHeight and paddingLeft so a Text and a TextInput cannot disagree about the font's metrics or the theme's EditText padding on Android.
  • A drag never changes the row's height — the row's form is frozen for as long as react-native-reorderable-list reports the cell active.

inlineMarkdownNodes now lexes with source tracking on (like the webapp's renderInlineItem), which is what gives the spans the tap mapping walks.

Spec

docs/specs/markdown-rendering.md §1 and §1.2 said the editable-row swap was webapp-only and pointed at #867; §1.2 is now written once for both clients, with the two places they genuinely differ called out inline. The §6 "deliberately not covered" entry is gone, §5 lists the two new mobile files, and §7 records where the swap and the caret mapping are tested.

Tests

  • mobile/__tests__/ListItem.test.tsx — the state machine: which form is showing, that the input survives a swap, that a plain row never swaps, that an editable row's links are inert while a read-only row's are not, that a drag freezes the form, and the tap-to-edit cases including that the swap is committed before focus is requested (the regression test for bug 1 above).
  • mobile/__tests__/inlineCaret.test.ts — the pure half of the mapping, against synthetic line boxes.

task check passes: 1362 mobile tests, 111 suites, plus the docs, migration and translation gates.

Verified, and not

  • Android, on device: tap-to-edit now focuses the tapped row and keeps focus. Confirmed by the same instrumentation that found the bugs (added in 58820b9 / 5fd2618, reverted in a6cbde2).
  • Not verified: iOS. Bug 2 is Android-specific; bug 1 affected both, and the fix is platform-neutral, but nobody has run this on iOS.
  • Not verified: the keyboard across row-to-row transitions, which is the ticket's headline acceptance criterion. It follows from never unmounting the field, but it has not been watched directly.
  • No screen recording, which the issue asks for — there is no simulator in the environment this was built in.
  • One known rough edge: the caret occasionally lands at the end of a two-line row instead of at the tap point. The mapping works (a tap on the same row mapped correctly on other attempts), so it is likely the second line's text failing to match in lineStarts. Cosmetic next to the focus bug; not chased yet.

No API changes, so nothing breaking and no migration.

Mobile's editable row showed source, so `**Milk**` read as **Milk** on the
card and in the read-only editor but as `**Milk**` in the row you type in.
It now swaps like the webapp's: rendered until it holds the caret, source
for exactly as long as it does.

The one question mobile had to answer for itself was the keyboard. Under
"focused == editing", moving between rows unmounts one TextInput and mounts
another, and React Native only keeps the software keyboard up when focus
moves between two *mounted* inputs — so the naive swap would dismiss and
reopen the keyboard on the most common interaction a list note has. The
field is therefore never unmounted, only taken out of flow at opacity 0,
which is what the webapp already does for its textarea.

The rest carries over from #824 and is now written once in the spec:

- Tap-to-edit places the caret where the user pointed. RN has no
  `caretPositionFromPoint`, so `mobile/src/utils/inlineCaret.ts` maps a tap
  to a rendered offset from the line boxes `onTextLayout` reports, then
  hands it to the shared `inlineSourceOffset`. The caret is forced through
  the controlled `selection` prop and released once the input reports it.
- A row only swaps when rendering changes something, so a plain list keeps
  the always-live input it has always had.
- An editable row's links are inert; a read-only row's stay live.
- Both forms are the same height: one style object, with an explicit
  lineHeight and paddingLeft so a Text and a TextInput cannot disagree
  about the font's metrics or Android's EditText padding.
- A drag freezes the row's form for its duration, so the lifted cell keeps
  the size the reorderable list measured.

Closes #867
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e0cff8f-992a-4ba1-80a1-6ba3614f24fb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Mobile editable list-item rows now display rendered Markdown when unfocused and source Markdown while editing. The TextInput remains mounted during swaps. Taps on rendered text map to source caret positions. Dragging freezes the current representation. Tests cover rendering, focus transitions, caret mapping, links, completed rows, and drag behavior. The specification now documents behavior for both mobile and webapp.

Possibly related PRs

  • hanzei/jot#823 — Introduced mobile inline Markdown rendering that this change extends to editable rows.
  • hanzei/jot#825 — Added the mobile ListItem rendering behavior extended here with editing and caret mapping.
  • hanzei/jot#868 — Added the shared Markdown source-offset model used by the mobile caret mapping.

Poem

A rabbit taps where bold words lie,
The caret hops to source nearby.
The input stays, the row holds still,
Dragging cannot change its will.
Markdown shines; the burrow cheers! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the coding objectives in issue #867, including per-row swapping, mounted inputs, caret mapping, inert links, matching metrics, drag stability, tests, and specification updates.
Out of Scope Changes check ✅ Passed The changes are limited to the mobile editable-row implementation, related caret utilities, tests, and Markdown rendering specification updates.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly identifies the primary change: rendering Markdown in mobile editable list-item rows.
Description check ✅ Passed The description directly explains the Markdown rendering, focus behavior, caret mapping, tests, limitations, and related specification updates.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hanzei
hanzei marked this pull request as ready for review August 10, 2026 06:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
mobile/src/components/ListItem.tsx (1)

330-414: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider labelling the hidden input with the rendered words.

While showRendered is true, the rendered Text is removed from the accessibility tree (Lines 405-406), and the still-mounted TextInput keeps the raw source as its value. A screen-reader user therefore hears buy **milk** for a row that displays buy milk. This is the same mismatch the checkbox label avoids at Lines 308-309 with inlineMarkdownToText.

The input must stay accessible because it is the row's real control, so an accessibilityLabel derived from the rendered text keeps the announcement aligned with the screen while editing still operates on the source.

♿ Possible adjustment
                 <TextInput
                   ref={setInputRef}
                   autoFocus={autoFocus}
                   style={[styles.itemText, textTone]}
                   value={text}
+                  // While the rendered form is on top, announce the words the
+                  // user sees rather than the source the field holds.
+                  accessibilityLabel={showRendered ? inlineMarkdownToText(text) : undefined}
                   selection={forcedSelection ?? undefined}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/src/components/ListItem.tsx` around lines 330 - 414, Update the
editable TextInput in the showRendered path to provide an accessibilityLabel
derived from the rendered text, using the existing inlineMarkdownToText-style
conversion used by the checkbox label. Keep the input accessible and preserve
its raw source value and editing behavior while ensuring screen readers announce
the displayed words.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/specs/markdown-rendering.md`:
- Around line 101-126: Update the §5 “Where it is implemented” table to include
rows for “Mobile editable-row swap (§1.2)” linking to
mobile/src/components/ListItem.tsx and “Mobile tap point → rendered offset”
linking to mobile/src/utils/inlineCaret.ts, while preserving the existing webapp
entries.

---

Nitpick comments:
In `@mobile/src/components/ListItem.tsx`:
- Around line 330-414: Update the editable TextInput in the showRendered path to
provide an accessibilityLabel derived from the rendered text, using the existing
inlineMarkdownToText-style conversion used by the checkbox label. Keep the input
accessible and preserve its raw source value and editing behavior while ensuring
screen readers announce the displayed words.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c4ea501-0ce4-4bd9-a436-59da89c37c6b

📥 Commits

Reviewing files that changed from the base of the PR and between ad36e4b and bed11cb.

📒 Files selected for processing (6)
  • docs/specs/markdown-rendering.md
  • mobile/__tests__/ListItem.test.tsx
  • mobile/__tests__/inlineCaret.test.ts
  • mobile/src/components/ListItem.tsx
  • mobile/src/utils/inlineCaret.ts
  • mobile/src/utils/inlineMarkdown.ts

Comment thread docs/specs/markdown-rendering.md
Adds the two files this change introduced to the "where it is implemented"
table (§5), which listed the webapp's editable-row swap and click-to-caret
map but not their mobile counterparts.
claude added 6 commits August 10, 2026 07:27
Tapping a rendered row moved focus to the note title instead of the row.
While the rendered form is showing, the row's TextInput is out of flow,
transparent and inside a `pointerEvents: 'none'` wrapper, and `focus()`
was called on it in that state — iOS refuses `becomeFirstResponder` for a
view with user interaction disabled, and Android's `requestFocus` falls
through to the next focusable field in the window, which is the title.

The tap now ends the rendered form and an effect keyed on that swap does
the focusing, so the field is back in flow and interactive by the time it
is asked for focus. Keying the effect on the swap rather than running it
unconditionally also means a row frozen mid-drag waits for the drop.

Jest never caught this because `focus()` is a no-op there; the regression
test asserts the ordering instead — the rendered form must be gone, and
the row's own field must be the one focused.
Not for merge — revert this commit before the PR leaves draft.

Tapping a rendered row moves focus to the note title instead of the row,
and it survived the fix in fba5213, so this logs the path rather than
guessing at a third cause. `console.info` is what the app persists
(src/utils/logger.ts), so the trace shows up in Settings -> Diagnostics
with no debugger attached.

Logged, in order: the tap (whether the text had been measured, the mapped
offset, whether the row was rendered/dragging), the focus attempt (whether
the ref holds a node), `isFocused()` immediately after and 300ms later,
the row's own focus/blur, and the title input's focus.

Lengths and offsets only, never item text — mobile/CLAUDE.md.
Not for merge — revert with the instrumentation commit before the PR
leaves draft.

The first trace showed focus landing on the tapped row (isFocused true,
onFocus delivered) and being taken away 3ms later, with the title as the
fallback rather than the thief. The remaining suspect is the row's own
reflow: the rendered form measured 2 lines, and the source it swaps to is
longer, so the cell changes height and the list's itemLayoutAnimation
(Reanimated LinearTransition) runs on a cell containing the focused input.

This logs the row height on every layout pass so the change is visible in
the trace next to the blur.
Tapping a rendered row focused the right field and then lost it to the
note title — intermittently, which is what named the cause.

The row's input sits in a wrapper View that carries a style and
`pointerEvents: 'none'` while the rendered form is showing, and neither
once it goes back in flow. A View with no rendering-relevant props is a
flattening candidate on Android, so that commit can remove the wrapper's
native view and re-parent the EditText inside it — and an EditText that is
removed and re-added loses focus, which Android then hands to the first
focusable field in the window. That is the same fallback the reorderable
list's force-remount already works around (NoteEditorScreen, commitDrag).

It races the focus() that follows the same commit, which is why the same
tap worked one time in four. `collapsable={false}` keeps the wrapper a
real native view in both states, so there is nothing to re-parent.

Evidence, from the device traces on #867: the row logged no layout change
across the swap (ruling out a reflow / itemLayoutAnimation cause), focus
was requested successfully every time, and only the 300ms follow-up
disagreed — native dropping focus after JS had taken it.
Reverts 58820b9 and 5fd2618. Their job is done: the traces they produced
identified the native view flattening fixed in 6ed0ffd, and confirmed the
row does not change height across the swap.
Moving between two list items took two taps: the first blurred the focused
row and dismissed the keyboard, the second reached the row that was tapped.

A ScrollView captures the touch responder — blurring the focused input,
dismissing the keyboard and swallowing the tap — when its
keyboardShouldPersistTaps is left at the default and the tap lands on
something that is not a TextInput
(ScrollView.scrollResponderHandleStartShouldSetResponderCapture). The
ScrollViewContainer wrapping the editor already passes "handled", but the
item list is a FlatList underneath and brings a ScrollView of its own,
which was still on the default.

It went unnoticed because a row's tap target used to be its TextInput,
which the check exempts. A rendered row's target is a Text, so every
row-to-row move paid a tap for it.

This is the ticket's headline acceptance criterion (#867): moving between
rows must not flicker the keyboard.
@hanzei
hanzei merged commit 4dcb2d4 into master Aug 10, 2026
9 checks passed
@hanzei
hanzei deleted the claude/github-issue-867-8i8imz branch August 10, 2026 10:51
hanzei added a commit that referenced this pull request Aug 18, 2026
…et (#932)

#870 fixed focus jumping to the note title when a focused list item was
dragged to a new slot: the reorderable list force-remounts any row whose
slot changed, and the editor re-arms `autoFocus` on the previously focused
item so the remounted row re-opens the keyboard on itself.

#876 landed after it and gave the editable row a rendered form. A row
mounts with `isEditing = false`, so any row whose text renders differently
from its source — emphasis, code, a link, a bare URL — mounts *rendered*,
with its input out of flow, transparent and inside a `pointerEvents:
'none'` wrapper. Neither platform focuses a field in that state (the same
thing tap-to-edit already works around), so the armed `autoFocus` lands on
Android's fallback: the note title. The pre-#870 symptom was back for every
row with any Markdown in it.

Seed `isEditing` from `autoFocus`, so a row that mounts asking for the
caret mounts in source form and can actually take it. This covers the new
item `handleAddItem` adds as well as the drag restore.


Claude-Session: https://claude.ai/code/session_01XGq4L5KmepDPud9UNf6Qtq

Co-authored-by: Claude <noreply@anthropic.com>
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.

Render Markdown in mobile's editable list-item rows

2 participants