fix(chat): fix 5 message-rendering correctness bugs (#132)

- MessageBubble: memo comparator only checked the last part's `.text`,
  which is always undefined for tool parts, so tool-state/token/cost
  updates never re-rendered when a tool part was last. Replace with a
  full reference-equality sweep over message + all parts (the store
  always replaces changed refs, so this catches every real change).
- DiffView: computeDiff's O(a.length*b.length) LCS table was unbounded,
  risking OOM/ANR on large diffs, and the rendered line list was
  unbounded too. Add a size-guarded fallback (simple truncated
  remove/add diff) and cap the normal path's rendered lines, both with
  a truncation marker. Extracted computeDiff into a plain
  diff-compute.ts module (mirrors src/lib/scroll-config.ts) so it's
  unit-testable with node:test, which can't render .tsx components.
- DiffView: normalize line endings (\r?\n) before diffing so a CRLF
  vs LF mismatch doesn't show a whole file as changed.
- Markdown: the module-scope singleton CustomRenderer's github-slugger
  never reset, so useMarkdown's keys climbed on every streamed token,
  remounting the whole subtree. Scope the renderer per `children` via
  useMemo instead.
- Markdown: theme objects used heading1/heading2/heading3/listItem,
  but react-native-marked's MarkedStyles expects h1/h2/h3/li, so the
  custom heading/list styling was silently dropped. Rename the keys in
  both themes.


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

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Den
2026-07-18 16:52:24 -07:00
committed by GitHub
parent df4a3618c4
commit 1a069b53f5
5 changed files with 185 additions and 79 deletions

View File

@@ -113,17 +113,19 @@ export const MessageBubble = memo(
},
(prev, next) => {
// Only re-render if message content actually changed
// This prevents completed messages from re-rendering during streaming
if (prev.message.id !== next.message.id) return false
// This prevents completed messages from re-rendering during streaming.
// The store replaces changed parts/messages with NEW object references,
// so a reference-equality sweep over every part catches every real change
// (including tool parts, which have no `.text`) while still skipping
// unchanged (completed) messages during other messages' streaming.
if (prev.message !== next.message) return false
if (prev.isDark !== next.isDark) return false
if (prev.onLongPress !== next.onLongPress) return false
if (prev.parts.length !== next.parts.length) return false
// Compare the last part's text content - this is what changes during streaming
const prevLast = prev.parts[prev.parts.length - 1]
const nextLast = next.parts[next.parts.length - 1]
if (!prevLast && !nextLast) return true
if (!prevLast || !nextLast) return false
return prevLast.type === nextLast.type && prevLast.text === nextLast.text
for (let i = 0; i < prev.parts.length; i++) {
if (prev.parts[i] !== next.parts[i]) return false
}
return true
},
)