Skip to content

fix(web): restore text selection in rendered markdown on touch devices - #1890

Merged
simple-agent-manager[bot] merged 4 commits into
mainfrom
sam/fix-markdown-remount-kills-selection
Aug 23, 2026
Merged

fix(web): restore text selection in rendered markdown on touch devices#1890
simple-agent-manager[bot] merged 4 commits into
mainfrom
sam/fix-markdown-remount-kills-selection

Conversation

@simple-agent-manager

Copy link
Copy Markdown
Contributor

Summary

Fixes a live production bug reported on Android: long-pressing text in a rendered markdown file selected one word and immediately deselected it, so the selection drag-handles could never be used and quoting a passage for a comment was impossible.

RenderedMarkdown passed its react-markdown overrides as an object literal inside the render body. react-markdown renders each node via createElement(components[tag], ...), so every override got a new function identity on every render — React saw a different component type for every heading, paragraph and list item and unmounted/remounted the entire document instead of reconciling it.

A native text Selection is anchored to real DOM nodes. Rebuilding those nodes drops the selection and its handles. In the file preview the trigger is immediate: the selection handler calls setSelection, which re-renders the modal, which rebuilt the paragraph under the user's finger.

Fix: hoist the overrides to a module-scope MARKDOWN_COMPONENTS (they close over nothing from props) and memo the component so an unrelated parent re-render does not re-render the document at all.

A second, independent cause of the same user-visible symptom was found in review and is also fixed here. SelectionActionBar is fixed bottom-0, full width, ~97px tall, and — unlike SelectionPopover, which anchors to the selection's own rect — ignored the selection's position entirely. Measured at 375x667: a selected paragraph at top:581/bottom:650 against a bar at top:570/bottom:667, i.e. the selection was completely inside the bar, along with its lower drag handle. The bar now latches the selection rect alongside the quote and flips to the top when it would overlap. Without this, the remount fix alone would not have unblocked the reported gesture.

This is pre-existing and wider than the library. PR #1889 did not introduce it — it added a surface that re-renders at exactly the wrong moment. The same component renders chat messages, which re-render on every poll and stream update, and the user independently recalled "a similar but different issue" there. PR #1883 previously fixed an adjacent symptom (the React snapshot being nulled when the browser collapsed the Selection) but not the underlying DOM churn.

Validation

  • pnpm lint — 0 errors
  • pnpm typecheck — clean
  • pnpm test — web 3446 passed / 0 failed / 0 collection errors (289 files; 3440 before, +6 added)
  • Additional validation run — Playwright audits library-file-comments-audit + file-preview-modal-audit at 375x667 and 1280x800: 12/12 passed, no visual regression
  • N/A: this PR does not change candidate selection for any sweep/cron/alarm loop.

Every guard here was verified discriminating by reverting the relevant source and re-running:

Guard Reverted Result
DOM-identity + live-Selection inline components={{...}} restored both fail; the content-change control still passes
components-prop identity components={{ ...MARKDOWN_COMPONENTS }} spread restored container assertion fails; per-tag correctly still passes
action bar overlap placement flip disabled fails with action bar overlaps the selected text
sam/no-inline-markdown-components probe file with an inline literal and a spread both flagged; a hoisted constant passes

Unrelated pre-existing failure, not introduced here: tests/playwright/chat-dom-bound-audit.spec.ts is fully red locally on main as well (0 passed / 6 failed, .sam-message-entry never renders). Verified by checking out main and running it before assuming. Out of scope for this PR.

Staging Verification (REQUIRED for all code changes — merge-blocking)

  • Staging intentionally skipped by explicit instruction from Raphaël. His words: "in a way I think we can almost like skip staging because there's not really a good way for you to test it so just ship it to production once you've run sort of normal tests and ping me when that's good and I'll test it myself".
  • Rationale. The bug is a native touch-selection gesture on a real Android device. Staging cannot verify it: jsdom does not model selection handles, and Playwright's touch emulation sets selections programmatically rather than by dragging, so a staging pass would be evidence of nothing. The honest verification is Raphaël reproducing his original gesture on his own device against production.
  • N/A: no infra changes — nothing under packages/cloud-init/, packages/vm-agent/, DNS, TLS, or scripts/deploy/.
  • Mobile and desktop verification notes added for UI changes (see UI section)

Staging Verification Evidence

Not applicable — see above. Substituted evidence, per .claude/rules/13's requirement that non-staging evidence be stated explicitly:

  • Discriminating regression test that reproduces the mechanism in jsdom (selection collapses pre-fix, survives post-fix)
  • Full web suite green, including every existing markdown and chat-rendering test
  • Playwright visual audits at both viewports, confirming the rendered output is unchanged
  • Post-merge confirmation is Raphaël's own device test, which is the only instrument that can actually observe the reported failure

UI Compliance Checklist (Required for UI changes)

  • Mobile-first layout verified — audits run at 375x667 and 1280x800
  • Accessibility checks completed — no markup or ARIA changed; the overrides were moved, not rewritten
  • Shared UI components used or exception documented — this IS the shared component
  • Playwright visual audit run locally — 10/10 across both viewports, .codex/tmp/playwright-screenshots/

End-to-End Verification (Required for multi-component changes)

  • Data flow traced from user input to final outcome with code path citations
  • Capability test exercises the complete happy path across system boundaries
  • All spec/doc assumptions about existing behavior verified against code
  • Gaps documented below

Data Flow Trace

  1. User long-presses text in the rendered markdown
    apps/web/src/components/library/FilePreviewModal.tsx (data-comment-anchor container wrapping RenderedMarkdown)
  2. Browser fires selectionchange / mouseup
    apps/web/src/components/project-message-view/comments/useCommentSelection.ts:useCommentSelection()readSelection()
  3. setSelection({ anchorId, quote, x, y }) → React re-renders FilePreviewModal
  4. Pre-fix: RenderedMarkdown re-renders → components={{...}} literal yields new component types → React unmounts/remounts every node → the DOM the Selection is anchored to is destroyed → browser collapses the selection and removes the handles.
    Post-fix: memo short-circuits on unchanged content; even on a real re-render, MARKDOWN_COMPONENTS has stable identity so React reconciles and the nodes survive.
  5. Selection persists → user drags handles to extend → SelectionActionBar (coarse pointer) / SelectionPopover (fine) → quoted comment.

Untested Gaps

The gesture itself is untested and cannot be tested here. jsdom has no selection handles; Playwright sets selections via document.createRange() rather than by dragging, which is precisely why the original Playwright audit passed while the feature was broken on a real thumb. The regression test proves the mechanism (a re-render no longer destroys a live Selection); it does not prove the Android gesture. That confirmation is Raphaël's device test post-deploy, by explicit agreement.

Post-Mortem (Required for bug fix PRs)

What broke

Selecting text in rendered markdown was impossible on Android — one word would select and instantly deselect, leaving no handles to drag. Users could not quote a passage when commenting, on library files or (less visibly) chat messages.

Root cause

MarkdownRenderer.tsx has passed components={{ ... }} inline since it was written; it predates both commenting PRs. react-markdown's createElement(components[tag], ...) turns a fresh object literal into fresh component types, which React can only handle by remounting. The DOM churn was invisible until a feature depended on DOM identity — a native Selection — at which point it became a hard blocker.

Class of bug

An unstable prop identity that silently converts reconciliation into remounting. The tell is a config object, render-prop map, or component map constructed in a render body and handed to a library that uses its values as component types. It looks like configuration; it behaves like key churn. Nothing renders wrong, so it is invisible until something depends on node identity — selection, focus, scroll position, media playback, IME composition, CSS transitions.

Why it wasn't caught

No test asserted DOM identity, only rendered output — and output was always correct. The Playwright audit reached the feature but not the way a thumb does: it created the selection programmatically and read it back in the same tick, never sitting through a re-render mid-gesture. This is .claude/rules/62 ("a test must reach the feature the way production does") in a form that rule's examples do not yet cover: the test used the real trigger but not the real interaction duration.

Process fix included in this PR

Two, because the prose rule alone demonstrably failed:

  • .claude/rules/64-unstable-prop-identity-remounts-subtrees.md (new) — the class, the react-markdown trap, what breaks when node identity is lost (selection, focus, IME, scroll, media position, transitions), and the toBe-not-toEqual assertion pattern that catches it.
  • sam/no-inline-markdown-components in packages/eslint-plugin-sam, wired as an error — the mechanical check. The commit that introduced rule 64 violated rule 64 in the same diff, and three independent hand-rolled fixes for this bug already existed in the codebase before this one. Documentation was not preventing recurrence; a lint rule does.

Post-mortem file

This PR description, plus the rule above.

Specialist Review Evidence (Required for agent-authored PRs)

  • All local reviewers completed and findings addressed before merge
  • If any reviewer did NOT complete: needs-human-review label added and merge deferred to human
Reviewer Status Outcome
architecture-reviewer ADDRESSED Confirmed the hoist is safe (no override closes over props) and that memo is load-bearing for reparse cost, not for correctness. Found a HIGH: I had shipped components={{ ...MARKDOWN_COMPONENTS }} in the same commit that added the rule forbidding it — fixed in 59a54a6. Found a MEDIUM: a FOURTH duplicate renderer in GitDiffView.tsx still carrying the bug — fixed. Found that the tests drove their re-render through memo's bail-out and so could not see the spread — closed with an unmemoized-render test and a new components-identity test. Its central point, that three hand-rolled fixes for this already existed and a prose rule is not enough, is why the ESLint rule was added.
ui-ux-specialist ADDRESSED Confirmed byte-for-byte rendered-output parity across all fourteen overrides. Found the second bug: SelectionActionBar covering the selection near the viewport bottom, proven with a measured diagnostic and screenshot rather than inspection — fixed in 12ba07b with a discriminating regression test. Also flagged, and I agree it is unproven from here: readNow firing on a synthesized Android mouseup may surface the bar earlier than the 280ms settle window, amplifying the overlap. Not independently verifiable in this sandbox; noted for on-device observation rather than speculatively "fixed".

Exceptions (If any)

  • Scope: staging deployment and staging verification skipped for this PR
  • Rationale: explicitly instructed by Raphaël; the reported failure is a native Android touch gesture that no automated environment available here can observe, so staging would produce false assurance rather than evidence
  • Expiration: this PR only

Agent Preflight (Required)

  • Preflight completed before code changes

Classification

  • external-api-change
  • cross-component-change
  • business-logic-change
  • public-surface-change
  • docs-sync-change
  • security-sensitive-change
  • ui-change
  • infra-change

External References

Consulted the official react-markdown documentation for the components prop contract, which confirms each mapped value is used as the component type for its node — the mechanism by which a new object literal forces remounts. React's official reconciliation documentation confirms that a changed element type unmounts the existing subtree rather than diffing it. No external API surface changed.

Codebase Impact Analysis

  • apps/web/src/components/MarkdownRenderer.tsx — overrides hoisted to module scope, component memoized
  • apps/web/src/components/GitDiffView.tsx — fourth duplicate renderer, same fix applied
  • apps/web/src/components/project-message-view/comments/CommentPrimitives.tsx — selection-aware action bar placement
  • apps/web/src/components/project-message-view/comments/useCommentSelection.ts — latch the selection rect
  • apps/web/src/components/project-message-view/comments/useProjectMessageCommentUi.tsx, apps/web/src/components/library/FilePreviewModal.tsx — pass the geometry
  • packages/eslint-plugin-sam/src/rules/no-inline-markdown-components.js + eslint.config.mjs — mechanical enforcement
  • apps/web/tests/unit/components/markdown-dom-stability.test.tsx, markdown-components-identity.test.tsx, apps/web/tests/playwright/library-file-comments-audit.spec.ts — regression tests
  • .claude/rules/64-unstable-prop-identity-remounts-subtrees.md — new process rule

Consumers of RenderedMarkdown are unchanged and were reviewed for staleness risk: chat message rendering, tool cards, and the library file preview in apps/web/src/components/.

Documentation & Specs

N/A: no public docs site content changed. This is an internal rendering-correctness fix with no user-facing configuration, API, or setup implication. Agent-facing documentation is the new rule 64.

Constitution & Risk Check

  • Principle XI (No Hardcoded Values) — no new literals, URLs, timeouts or limits; the change relocates existing markup config.
  • Risk: stale rendering. memo could in principle make the component stale. Mitigated by a control test asserting a content change still re-renders, and by the overrides closing over nothing from props so a shared instance cannot serve wrong values.
  • Risk: streaming markdown in chat. Chat streams content incrementally; memo compares content, which changes on every chunk, so streaming still re-renders. Explicitly covered in the architecture review.
  • Risk: shared-component blast radius. Accepted deliberately — the bug is in the shared component and affects every consumer, so a library-only workaround would leave chat broken.

…ection

Reported on production (Android): long-pressing text in a markdown library file
selected one word and immediately deselected it, so the drag handles could never
be used and quoting a passage was impossible. Raphaël also recalled something
similar in chat message comments — same root cause, different trigger.

RenderedMarkdown passed `components={{ ... }}` as an object literal inside its
render body. react-markdown renders each node via
`createElement(components[tag], ...)`, so every override got a NEW function
identity on every render. React therefore saw a different component *type* for
every heading, paragraph and list item and unmounted/remounted the entire
document rather than reconciling it.

A native Selection is anchored to real DOM nodes. Rebuilding those nodes drops
the selection and its handles. In the file preview the trigger is immediate: the
selection handler calls setSelection, which re-renders the modal, which rebuilt
the paragraph under the user's finger. Chat hits the same trap on every poll and
stream update.

Fix: hoist the overrides to a module-scope MARKDOWN_COMPONENTS (they close over
nothing from props) and memo the component so an unrelated parent re-render does
not re-render the document at all.

This is pre-existing — PR #1889 did not introduce it, it just added a surface
that re-renders at exactly the wrong moment. #1883 previously fixed an adjacent
symptom (the React snapshot being nulled) but not the underlying DOM churn.

Regression test asserts DOM node identity across an unrelated re-render AND that
a live Selection survives it, plus a control proving memo did not make the
component stale. Verified discriminating: both assertions fail on the pre-fix
code, the control passes on both.

Web suite 3443 passed / 0 failed; Playwright audits 10/10; typecheck and lint clean.
Process fix for the markdown-remount bug. Covers the class (a config object
built in a render body and handed to a library that uses its values as component
types), what breaks when node identity is lost (selection, focus, IME, scroll,
media position, transitions), and the DOM-identity assertion pattern that
catches it — toBe, not toEqual, because a remount serializes identically.

Also records the corollary to rule 62 this bug exposed: a test can use the real
trigger and still miss the failure if it completes the interaction inside one
render. The Playwright audit drove a real selection and passed while the feature
was unusable, because it created and read the selection in the same tick and
never sat through a re-render mid-gesture.
…ate, test hole

Local architecture-reviewer findings, all fixed rather than deferred.

HIGH — I had shipped `components={{ ...MARKDOWN_COMPONENTS }}` in the very
commit that added the rule forbidding it. The reviewer verified it does not
reintroduce the remount (react-markdown resolves per-tag, and a shallow spread
copies each override by reference) but it allocates a fresh container every
render for no benefit and erodes the hoist it copies. Now passes the constant.

MEDIUM — GitDiffView.tsx carried a FOURTH independent copy of this renderer with
the same inline-components bug, live in the diff viewer's full-file markdown
mode. Selecting text there still deselected. Hoisted + memoized it too. Kept it
separate from MarkdownRenderer because its typography genuinely differs;
consolidating the copies is tracked, not silently ignored.

MEDIUM — the reviewer also found MessageBubble.tsx had ALREADY solved this
independently (hoisted overrides + memo, with a comment describing the same
mechanism). So chat *message* text was never affected — correcting my earlier
claim. Three independent hand-rolled fixes before this one is the argument for
mechanical enforcement, below.

Test hole — the DOM-stability tests drove their re-render through memo's
bail-out, so memo alone satisfied them and they could not see the spread. Added
a test that renders the UNMEMOIZED implementation, forcing a real re-render, and
a new markdown-components-identity.test.tsx that mocks react-markdown to capture
the components prop across renders and assert container + per-tag identity.
Verified discriminating: reintroducing the spread fails the container assertion
while per-tag correctly still passes, exactly matching the reviewer's analysis.
Removed a tautological identity assertion I had written (spreading copies
references, so it could never fail).

Reusable enforcement — added sam/no-inline-markdown-components to
eslint-plugin-sam, wired as an error. A prose rule was demonstrably not enough:
the commit adding .claude/rules/64 violated it in the same diff. Verified the
rule flags an inline literal AND a spread, and passes a hoisted constant.

Web suite 3446 passed / 0 failed; typecheck and lint clean.
Second, independent cause of the reported Android symptom, found by the local
ui-ux-specialist and proven geometrically rather than by inspection.

SelectionActionBar is `fixed bottom-0`, full width, ~97px tall, and — unlike
SelectionPopover, which anchors to the selection's own rect — ignored the
selection's position entirely. A diagnostic at 375x667 measured a selected
paragraph at top:581/bottom:650 against a bar at top:570/bottom:667: the
selection was completely inside the bar. The screenshot shows the paragraph
fully hidden, surviving only as the truncated quote inside the bar itself.

The lower drag handle is under there too, so the user sees a word select and
then cannot extend it — the same user-visible symptom as the remount bug, from a
different cause. It bites hardest in chat, where alignToBottom deliberately puts
the newest message (the one most likely to be quoted) in exactly that band.

Fix: latch the selection's rect alongside the quote (same reason the quote is
latched — the browser may collapse the Selection before the bar renders), and
flip the bar to the top when a bottom-pinned bar would overlap. Height is
measured in useLayoutEffect rather than estimated, because it depends on how
many lines the quote wraps to, and measuring before paint avoids a visible jump.
Callers that pass no geometry keep the old bottom-pinned behaviour.

The existing selection test could not catch this: it always selects the FIRST
paragraph, which sits far from the bottom. Added a test that selects the lowest
fully-visible paragraph, asserts the fixture actually put it in the danger band
(so it cannot pass for the wrong reason), and asserts no overlap. Verified
discriminating — disabling the flip fails it with 'action bar overlaps the
selected text'.

Audits 12/12 across both viewports; web suite 3446/0; typecheck and lint clean.

Unrelated pre-existing finding: tests/playwright/chat-dom-bound-audit.spec.ts is
fully red on main as well (0 passed / 6 failed locally, .sam-message-entry never
renders). Not caused by this branch — verified by running it on main — and
tracked separately rather than fixed here.
@codspeed-hq

codspeed-hq Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing sam/fix-markdown-remount-kills-selection (12ba07b) with main (220b4ce)

Open in CodSpeed

@sonarqubecloud

Copy link
Copy Markdown

@simple-agent-manager
simple-agent-manager Bot merged commit b36f235 into main Aug 23, 2026
27 checks passed
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