Summary
Theme switching on iOS took 2+ seconds; it is now perceptually instant, matching Android's theme-switch speed.
Root cause
- JS code re-tokenization on every re-render. Code-block syntax highlighting runs highlight.js through JavaScriptCore (
HighlightrCodeSyntaxHighlighter.highlightCode) synchronously in the view body. Without memoization, every SwiftUI re-evaluation — streaming deltas, scroll, layout passes, and every theme switch — re-tokenized every visible code block on the main thread. For a code-heavy conversation this alone was multi-second.
- Theme forced through identity.
themeManager.themeVersion was threaded into .id(...) modifiers and bubble Equatable props, tearing down and rebuilding view subtrees on every theme change instead of recoloring in place.
Changes
- Memoized Highlightr tokenization (
MessageBubbleView.swift): a bounded LRU keyed on (code, language, themeName). Because most Litter themes share one Highlightr palette (e.g. the whole "atom-one-dark" family), switching between same-family themes now hits the cache and simply recolors through the @Observable ThemeStore — no JS work at all. Light↔dark re-tokenizes each visible block exactly once. Also eliminates repeated JS during streaming/scroll.
- Diff renderer self-updates on theme change (
DiffRendering.swift): SyntaxHighlightedDiffText now keys its .task(id:) render inputs on the resolved theme slug, so diffs re-render on theme change without the .id(themeVersion) teardown.
- Removed theme identity churn: dropped the
.id(themeManager.themeVersion) teardowns (settings preview, diff sheet, navigation root) and the dead themeVersion props/Equatable usage in the bubbles. ThemeStore is @Observable, so views that read LitterTheme.* recolor directly — the same pattern Android uses with Compose snapshot state.
- Dead code cleanup: removed the now-unused
themeVersion counter and the unused titleHint/colorScheme params in diff rendering.
Verification
- Built with the fast simulator lane and switched themes on device: dark↔dark and dark↔light theme changes are perceptually instant (previously 2+ seconds with a visible stall on the open conversation).
Time Profiler confirms the highlight.js tokenization is no longer hit on the main thread during a theme switch; code blocks are served from the memoization cache.
Notes
- No behavior change: same Highlightr palettes, same theme mapping, same colors — just cached and recolored instead of re-tokenized.
- Follow-up idea: replace Highlightr with a tokenizer decoupled from coloring (like Android's Prism4j with a fixed palette) to also make light↔dark transitions zero-tokenization.
Summary
Theme switching on iOS took 2+ seconds; it is now perceptually instant, matching Android's theme-switch speed.
Root cause
HighlightrCodeSyntaxHighlighter.highlightCode) synchronously in the view body. Without memoization, every SwiftUI re-evaluation — streaming deltas, scroll, layout passes, and every theme switch — re-tokenized every visible code block on the main thread. For a code-heavy conversation this alone was multi-second.themeManager.themeVersionwas threaded into.id(...)modifiers and bubbleEquatableprops, tearing down and rebuilding view subtrees on every theme change instead of recoloring in place.Changes
MessageBubbleView.swift): a bounded LRU keyed on(code, language, themeName). Because most Litter themes share one Highlightr palette (e.g. the whole "atom-one-dark" family), switching between same-family themes now hits the cache and simply recolors through the@Observable ThemeStore— no JS work at all. Light↔dark re-tokenizes each visible block exactly once. Also eliminates repeated JS during streaming/scroll.DiffRendering.swift):SyntaxHighlightedDiffTextnow keys its.task(id:)render inputs on the resolved theme slug, so diffs re-render on theme change without the.id(themeVersion)teardown..id(themeManager.themeVersion)teardowns (settings preview, diff sheet, navigation root) and the deadthemeVersionprops/Equatableusage in the bubbles.ThemeStoreis@Observable, so views that readLitterTheme.*recolor directly — the same pattern Android uses with Compose snapshot state.themeVersioncounter and the unusedtitleHint/colorSchemeparams in diff rendering.Verification
Time Profilerconfirms the highlight.js tokenization is no longer hit on the main thread during a theme switch; code blocks are served from the memoization cache.Notes