Skip to content

perf: memoize transcript pipeline + fix streaming lockups - #15

Open
robbe1912 wants to merge 3 commits into
alvarolorentedev:mainfrom
robbe1912:performance-automation
Open

perf: memoize transcript pipeline + fix streaming lockups#15
robbe1912 wants to merge 3 commits into
alvarolorentedev:mainfrom
robbe1912:performance-automation

Conversation

@robbe1912

Copy link
Copy Markdown

Problem

The transcript list re-renders every visible cell on every parent state change during streaming. Token deltas from SSE cause isRefreshingMessages toggles, pendingInteractions count updates, running flips — each one re-renders all visible TranscriptMessage rows even though only the streaming row changed. Result: UI lockups during assistant replies, especially on lower-end devices.

Fix

Memoize the transcript pipeline end-to-end so non-streaming cells skip re-render during token deltas.

Wave 1 — cell-level memoization

  • components/chat/chat-cards.tsx: wrap TranscriptMessage export in React.memo with areEqual comparator keyed on entry ref + copied/speaking/canSpeak. Callback identity intentionally excluded — they only fire on user action.
  • components/chat/chat-content.tsx: extract maintainVisibleContentPosition to a module-level as const; memoize extraData via useMemo([copiedMessageId, speakingMessageId]). Add TranscriptSkeleton placeholder (3 pulsing bubbles, native-driver opacity loop) wired into FlashList ListEmptyComponent during initial load.

Wave 2 — pipeline memoization

  • lib/opencode/format.ts: WeakMap<record, entry> cache in toTranscriptEntry. Skips heavy if/else tokenization chain for unchanged records.
  • providers/opencode-provider-selectors.ts: WeakMap cache in getSessionPreviewById keyed on messages array ref.
  • providers/opencode-provider.tsx: refreshMessages merges new fetch with previous by record id, preserving record refs AND array ref when content unchanged. Equality covers BOTH parts and info (catches post-hoc info.error additions during streaming-failure scenarios).
  • components/chat/chat-markdown.tsx: tokenizer wrapped in useMemo + memo wrap.

Style pass

  • Collapsed leading && operator chain to trailing style (matches rest of codebase).
  • Dropped internal "Wave 1/2.2" plan nomenclature from comments.
  • Removed stray double blank line in chat-content.tsx.
  • Documented cross-layer SessionMessageRecord immutability invariant in docs/state-and-data.md § Important Data Invariants.

Build script fix

  • scripts/build-android-development.mjs + scripts/build-android-release.mjs: replaced deprecated --non-interactive flag with CI=1 env var. Expo SDK 54+ removed the flag; scripts failed when run from a TTY.

Files changed

  • components/chat/chat-cards.tsx
  • components/chat/chat-content.tsx
  • components/chat/chat-markdown.tsx
  • components/chat/chat-view-styles.ts
  • lib/opencode/format.ts
  • providers/opencode-provider.tsx
  • providers/opencode-provider-selectors.ts
  • scripts/build-android-development.mjs
  • scripts/build-android-release.mjs
  • docs/state-and-data.md

Validation

  • npm run typecheck
  • npm run lint
  • npm run test:format
  • npm run test:usage
  • npm run test:fake-server:self

Net effect

During a streaming token delta, only the one streaming cell re-renders. Previously every parent state change re-rendered all visible cells. During initial session load, a TranscriptSkeleton placeholder shows pulsing bubbles instead of a blank snap-to-populated.

Expo SDK 54+ removed --non-interactive. Scripts failed when run from a
TTY. Set CI=1 in spawnSync env to force non-interactive mode.
Wave 1 (chat-cards, chat-content):
- TranscriptMessage wrapped in React.memo with areEqual comparator
  keyed on entry ref + copied/speaking/canSpeak. Non-streaming cells
  skip re-render during token deltas.
- maintainVisibleContentPosition extracted to module constant.
- extraData wrapped in useMemo.
- TranscriptSkeleton placeholder (3 pulsing bubbles, native driver)
  wired into FlashList ListEmptyComponent during initial load.

Wave 2 (format, selectors, provider):
- toTranscriptEntry: WeakMap cache keyed on record ref. Skips heavy
  if/else chain for unchanged records.
- getSessionPreviewById: WeakMap cache keyed on messages array ref.
  Skips per-session getHistoryPreview recompute.
- refreshMessages: merge new fetch with previous by record id.
  Preserves record refs AND array ref when content unchanged.
  Equality covers BOTH parts and info (catches post-hoc info.error
  additions during streaming-failure scenarios).
- MarkdownText: tokenizer wrapped in useMemo + memo wrap.

Net effect: during a token delta, only the one streaming cell
re-renders. Previously every parent state change re-rendered all
visible cells.
- Collapse leading '&&' operator to trailing style (0 other instances
  of leading '&&' chains in the repo; rest of file uses single-line
  or trailing operator).
- Drop 'Wave 1' / 'Wave 2.2' plan nomenclature from comments — internal
  planning artifact absent from repo docs. Replaced with descriptive
  references to the actual optimizations.
- Remove stray double blank line between TranscriptSkeleton memo and
  ChatContentProps type declaration.
- Document the new cross-layer invariant in docs/state-and-data.md
  Important Data Invariants section: SessionMessageRecord objects in
  messagesBySession are never mutated in place. The WeakMap caches in
  format.ts and opencode-provider-selectors.ts depend on this contract;
  refreshMessages preserves refs when content is unchanged.
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