Skip to content

perf(details): drop BoxWithConstraints subcompose + lazy keys#636

Merged
rainxchzed merged 10 commits into
mainfrom
perf/details
May 18, 2026
Merged

perf(details): drop BoxWithConstraints subcompose + lazy keys#636
rainxchzed merged 10 commits into
mainfrom
perf/details

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 18, 2026

Summary

Hard fixes for the README/release-notes lag + ANR observed on a Galaxy S25. All heavy work moved off Main.

1. BoxWithConstraintsBox + onSizeChanged

Details previously nested BoxWithConstraints inside Scaffold. Both subcompose during the measure pass — nesting multiplies the cost on rotation, IME show, or any parent size change. The BoxWithConstraints was only consuming maxHeight to derive collapsedSectionHeight = maxHeight * 0.7f. Single-shot size read → Modifier.onSizeChanged. Cites: recomposition/avoiding-subcomposition-pitfalls.

2. items(state.installLogs) keyed + contentType

Lazy item cache could not reuse composables across insertions / scroll. Cites: lists/optimizing-lazy-layouts.

3. Async markdown pre-process + loader + measure debounce

applyThemeAwareImages(raw, isDark) (regex-heavy) ran inside remember { ... } — on the composition thread. Moved to LaunchedEffect + withContext(Dispatchers.Default), with a CircularProgressIndicator clamped to collapsedHeight while running.

Switched from Markdown(content = ...) to Markdown(markdownState = rememberMarkdownState(..., retainState = true)). retainState = true keeps the previously-parsed AST on screen while the next parse runs on Default — no flash to blank Loading on theme toggle.

Hoisted MarkdownParser, GFMFlavourDescriptor, and githubStoreMarkdownComponents(isDark, transformer) into remember(...). Dropped the unnecessary @Composable annotation on the components factory so callers can memoize.

Debounced the onSizeChanged → onMeasured callback. Each forwarded write recopies the full DetailsState; the old code fired on every layout tick during the initial measure cascade — the "flicker after scroll completes" and "flicker on expand".

4. Syntax highlighting moved to Dispatchers.Default + 16KB cutoff (the ANR fix)

SyntaxHighlightedCode.buildHighlighted(...) was tokenizing the entire code block via the Highlights library inside remember { ... } — every code fence parsed on Main during composition. For a README with N fences, that is N synchronous tokenizations on the UI thread. A Background concurrent mark compact GC freed 49MB event with ANR followed exactly this pattern.

Fix: render plain code text immediately; tokenize in a LaunchedEffect + withContext(Dispatchers.Default); swap the styled AnnotatedString in once ready. Code blocks larger than 16 000 chars (embedded JSON dumps, generated YAML) are not highlighted at all — the tokenizer is super-linear, the readability payoff drops fast.

5. Preview truncation when collapsed

The Markdown library composes every element on Main once given an AST. Even with parsing async, a kubernetes/kubernetes-sized README produces hundreds of MarkdownText/MarkdownHeader/MarkdownCodeFence composables on first frame.

Fix: compute a truncated preview (first ~6000 chars at a paragraph boundary) on Dispatchers.Default alongside the full content. Render the preview when !isExpanded; render the full tree only when the user taps "Read more". First-frame composition cost falls by an order of magnitude on big READMEs.

Test plan

  • Open a repo with a huge README (kubernetes/kubernetes, openssl/openssl). First paint shows spinner briefly, then a short preview — no ANR.
  • Scroll over the About section, stop scrolling — no flicker.
  • Tap "Read more" — full markdown lands (may take a beat for parse + compose on really big READMEs, but UI stays responsive).
  • Toggle theme — old README stays visible during reparse.
  • Compile both targets — ✓ verified.

Follow-ups (separate PRs)

  • Stabilize hot List<T> fields in DetailsState via kotlinx.collections.immutable.ImmutableList.
  • Slice DetailsState into focused sub-states (header / releases / readme / logs).
  • Wire Compose Compiler reports + Macrobenchmark to make future perf work measurable.

Summary by CodeRabbit

  • Refactor
    • More reliable layout measurement reducing layout-update churn and improving scrolling stability.
    • Markdown rendering and syntax highlighting moved off the main thread; renderer state retained to avoid blanking.
    • Markdown rendering now streams large content incrementally to the UI for smoother expansion.
  • Bug Fixes
    • Install logs use stable item keys to prevent rendering glitches.
  • New Features
    • Sections show a constrained loading spinner and a safe truncated preview while content is prepared.
    • Images capped in height and served with improved caching to avoid oversized visuals.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

DetailsRoot measures container height with onSizeChanged+LocalDensity instead of BoxWithConstraints. About/WhatsNew preprocess markdown off-main-thread, produce preview + chunked full content, and render progressively via ProgressiveMarkdown. Syntax highlighting and heavy markdown work run on Dispatchers.Default. Logs list items use stable keys; markdown components factory is non-@composable. New truncate/split utilities added.

Changes

Details Presentation Rendering Improvements

Layer / File(s) Summary
Height measurement refactor in DetailsRoot
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
Removes BoxWithConstraints and measures container height via Modifier.onSizeChanged + LocalDensity, storing the height in state and deriving collapsedSectionHeight.
ExpandableMarkdownContent refactor (About + WhatsNew)
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt, feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt
Preprocess markdown asynchronously on Dispatchers.Default, compute a truncated preview and chunked full content, hoist and remember GFMFlavourDescriptor/MarkdownParser/components, show a clamped spinner while preprocessing, and delegate rendering/measurement to ProgressiveMarkdown which streams chunks and reduces noisy onMeasured calls.
SyntaxHighlightedCode off-main-thread
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/SyntaxHighlightedCode.kt
Initialize immediate plain text and perform tokenization/highlight building in a LaunchedEffect on Dispatchers.Default, skipping highlighting for defaults/oversized blocks.
Lazy list item stability in Logs
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt
Switch to LazyListScope.items(items = ..., key = ..., contentType = "install_log") to provide stable per-item keys (timeIso + assetName) and explicit content type.
Markdown components factory non-@composable
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt
Remove @Composable annotation from githubStoreMarkdownComponents and document callers should wrap it (e.g., remember(isDark)) because it returns lambdas invoked later during rendering.
Markdown truncate & chunk utilities
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt
Add truncateMarkdownPreview(content, maxChars) that prefers paragraph breaks then single newlines and splitMarkdownIntoChunks(content, targetChunkChars) that splits on blank lines outside fenced code blocks.
Markdown image transformer limits & caching
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownImageTransformer.kt
Add MAX_BITMAP_DIMENSION_PX cap, set Coil request .size(...), explicit memory/disk cache keys, enable crossfade, and clamp displayed image height via .heightIn(max = 600.dp).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble bytes and hop through code,

Off-thread I fluff the markdown load,
Boxes yield to measured size,
Keys keep logs from losing ties,
Chunks arrive and render—soft delight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'perf(details): drop BoxWithConstraints subcompose + lazy keys' directly corresponds to the main changes: removing BoxWithConstraints to eliminate nested subcomposition and adding keys/contentType to lazy items.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/details

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt`:
- Line 34: The key generation for the lazy list currently concatenates
it.timeIso + it.assetName which can produce ambiguous keys; update the key
lambda in Logs.kt (the key = { ... } expression) to combine timeIso and
assetName with an explicit delimiter (for example a pipe or null-safe separator)
so each item key is unambiguous (reference the properties timeIso and assetName
in the key lambda).
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 28229445-f0fb-4a21-974d-b45530e5026a

📥 Commits

Reviewing files that changed from the base of the PR and between 2c70263 and 84c8232.

📒 Files selected for processing (2)
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 18, 2026

Greptile Summary

This PR moves several expensive composition-time operations off the Main thread: markdown image pre-processing, syntax tokenization, and AST parsing are now dispatched to Dispatchers.Default, with BoxWithConstraints replaced by Box + onSizeChanged to eliminate nested subcomposition overhead.

  • ProgressiveMarkdown streams large documents in chunks (one per frame via yield()), showing chunk 0 as the collapsed preview and adding subsequent chunks only after the user expands — dramatically reducing first-frame composition cost on large READMEs.
  • MarkdownImageTransformer adds a HEAD-probe + process-wide cache to skip images above 5 MB, with Coil bitmap dimension capping.
  • Several issues flagged in prior review rounds remain unaddressed in this diff (see previous thread comments): the named(\"test\") HttpClient qualifier used in production, probeCache HashMap accessed across threads without full synchronization, remember(raw, isDark) keys that reset displayed content to null on every theme toggle, and truncateMarkdownPreview lacking code-fence awareness.

Confidence Score: 4/5

Safe to merge if the previously-flagged issues (named("test") qualifier, probeCache thread safety, isDark/remember key spinner flash) are tracked and resolved before or shortly after landing.

The core async-offloading mechanics are sound and the ANR fix is well-targeted. Several issues identified in earlier review rounds remain unaddressed in this diff — most notably the production use of a named("test") DI qualifier (which crashes at runtime when that binding is absent) and the unsynchronised HashMap accessed from both Main and IO.

About.kt and WhatsNew.kt carry the named("test") HttpClient injection and the isDark-keyed remember that resets content to null on theme toggle. MarkdownImageTransformer.kt has the unsynchronised probeCache HashMap.

Important Files Changed

Filename Overview
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt Moves markdown pre-processing off Main via LaunchedEffect + progressive chunked rendering; still carries unresolved isDark/remember-key spinner-flash issue and named("test") HttpClient qualifier in production.
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt Same async markdown refactor as About.kt; still has unresolved isDark/remember-key flash, named("test") qualifier, and a duplicate dp import at line 45 that will produce a compiler warning/error.
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/SyntaxHighlightedCode.kt Moves tokenization to Dispatchers.Default with 16KB cutoff; correct safe fallback; minor cosmetic flash on theme toggle because remember key includes isDark.
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt splitMarkdownIntoChunks correctly guards code fences; truncateMarkdownPreview does not and can produce unclosed-fence previews — previously flagged, still unaddressed.
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownImageTransformer.kt HEAD-probe image size cap with Coil caching is sound; probeCache HashMap accessed without synchronization across Main and IO threads — previously flagged, still present.
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt BoxWithConstraints removed in favour of Box + onSizeChanged; containerHeightDp starts at 0.dp, so there is a one-frame flash at full height before the first size callback fires — previously flagged.
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt Adds stable itemsIndexed keys using index
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt @composable annotation removed so callers can memoize via remember(isDark); logic unchanged, no issues.
feature/details/presentation/build.gradle.kts Adds ktor-client-core dependency required by the new MarkdownImageTransformer HEAD-probe feature.

Sequence Diagram

sequenceDiagram
    participant UI as Main Thread (Composition)
    participant Def as Dispatchers.Default
    participant IO as Dispatchers.IO

    UI->>UI: "ExpandableMarkdownContent composed, fullChunks = null → show spinner"
    UI->>Def: LaunchedEffect: applyThemeAwareImages(raw, isDark)
    Def-->>UI: processed string
    UI->>Def: splitMarkdownIntoChunks(processed, 4000)
    Def-->>UI: List chunks
    UI->>UI: "fullChunks = chunks → recompose, renderedCount = 1 → chunk[0] visible"

    alt "isExpanded = true"
        loop "while renderedCount < chunks.size"
            UI->>UI: yield() → renderedCount++
            UI->>UI: chunk[n] composed via rememberMarkdownState
        end
    end

    UI->>UI: onSizeChanged fires → onMeasured(px)
    UI->>UI: "needsExpansion = true → show Read more"

    note over UI,IO: Image probing (per URL, once per session)
    UI->>IO: LaunchedEffect: probeOnce(url) via probeClient.head()
    IO-->>UI: ProbeResult.Allowed / Skipped
    UI->>UI: "probeCache[url] = result"

    note over UI,Def: Syntax highlighting (per code fence)
    UI->>UI: SyntaxHighlightedCode: render plain code immediately
    UI->>Def: LaunchedEffect: buildHighlighted(code, lang, isDark)
    Def-->>UI: AnnotatedString with spans
    UI->>UI: "highlighted = result → recompose with colours"
Loading

Fix All in Claude Code

Reviews (7): Last reviewed commit: "feat(details): clickable markdown images..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt (1)

8-10: ⚡ Quick win

Include imageTransformer in the memoization guidance.

The returned lambdas close over both isDark and imageTransformer, so telling callers to remember(isDark) is incomplete. A caller following that literally can keep a stale transformer bound in the cached MarkdownComponents.

Suggested wording
-// Plain (non-@Composable) factory so callers can wrap in `remember(isDark)`
+// Plain (non-@Composable) factory so callers can wrap in `remember(isDark, imageTransformer)`
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt`
around lines 8 - 10, The memoization guidance for the plain factory is
incomplete: the returned lambdas close over both isDark and imageTransformer, so
callers must remember both to avoid closing over a stale transformer. Update the
comment/documentation for GithubStoreMarkdownComponents (the Plain factory) to
instruct callers to use remember(isDark, imageTransformer) (or equivalent) so
the cached MarkdownComponents capture both the current isDark and
imageTransformer values.
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt`:
- Around line 161-167: The current remember call resets displayContent to null
as soon as rawMarkdown or isDark changes, which drops the existing Markdown tree
and shows the spinner; fix this by making the mutable state persistent across
key changes (so the old rendered markdown remains until the new processed string
is ready): replace remember(rawMarkdown, isDark) { mutableStateOf<String?>(null)
} with a key-less remember { mutableStateOf<String?>(null) } (or otherwise
ensure the state is not recreated on rawMarkdown/isDark changes), keep the
LaunchedEffect(rawMarkdown, isDark) that computes processed via
applyThemeAwareImages and assigns displayContent when done, and apply the same
change to the other identical block referenced (lines ~209-232) so
displayContent isn't cleared mid-flight.

In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt`:
- Around line 167-173: The current remember keyed as remember(raw, isDark)
causes displayContent to reset to null before applyThemeAwareImages finishes;
change the state so it is not cleared on key changes—either replace the
remember(raw, isDark) call with an unkeyed remember {
mutableStateOf<String?>(null) } and keep LaunchedEffect(raw, isDark) to update
displayContent after withContext(Dispatchers.Default) {
applyThemeAwareImages(raw, isDark) }, or use produceState(initialValue = null,
raw, isDark) { value = withContext(Dispatchers.Default) {
applyThemeAwareImages(raw, isDark) } } so the Markdown/retainState is not torn
down while preprocessing runs (refer to displayContent, remember,
LaunchedEffect, applyThemeAwareImages, produceState).

---

Nitpick comments:
In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt`:
- Around line 8-10: The memoization guidance for the plain factory is
incomplete: the returned lambdas close over both isDark and imageTransformer, so
callers must remember both to avoid closing over a stale transformer. Update the
comment/documentation for GithubStoreMarkdownComponents (the Plain factory) to
instruct callers to use remember(isDark, imageTransformer) (or equivalent) so
the cached MarkdownComponents capture both the current isDark and
imageTransformer values.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d959550f-8f3f-434d-a732-f99e190d42f5

📥 Commits

Reviewing files that changed from the base of the PR and between 84c8232 and a8a9844.

📒 Files selected for processing (3)
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt (1)

167-179: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid clearing the rendered markdown while preprocessing.

Lines 167-178 still key displayContent and previewContent to raw/isDark, so both reset to null on theme or translation changes and the UI falls back to the spinner until the background work finishes. That reintroduces the flicker this refactor was trying to remove.

Proposed fix
-    var displayContent by remember(raw, isDark) { mutableStateOf<String?>(null) }
-    var previewContent by remember(raw, isDark) { mutableStateOf<String?>(null) }
+    var displayContent by remember { mutableStateOf<String?>(null) }
+    var previewContent by remember { mutableStateOf<String?>(null) }
     LaunchedEffect(raw, isDark) {
         val processed = withContext(Dispatchers.Default) {
             applyThemeAwareImages(raw, isDark)
         }
         val preview = withContext(Dispatchers.Default) {
             zed.rainxch.details.presentation.utils
                 .truncateMarkdownPreview(processed, maxChars = 6000)
         }
         previewContent = preview
         displayContent = processed
     }
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt`
around lines 167 - 179, The current remember calls for displayContent and
previewContent include raw and isDark as keys, causing them to reset to null
(spinner) whenever those inputs change; update the remember usage so
displayContent and previewContent are initialized without raw/isDark as keys
(e.g., remember { mutableStateOf<String?>(null) }) so their previous values are
preserved while LaunchedEffect(raw, isDark) does background work, and continue
to assign previewContent and displayContent inside the LaunchedEffect after
processing (functions: applyThemeAwareImages, truncateMarkdownPreview).
🧹 Nitpick comments (1)
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt (1)

3-18: ⚡ Quick win

Drop the added KDoc/comment block here.

This helper is straightforward enough to read without the new prose, and the repo rules only allow KDoc/inline comments when they capture a non-obvious invariant, workaround, or concurrency detail. As per coding guidelines, "Do not add KDoc or inline comments unless explicitly requested; only add inline comments for non-obvious invariants, tricky concurrency, or workarounds".

🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt`
around lines 3 - 18, Remove the added KDoc/comment block above the
truncateMarkdownPreview function; keep the function signature and implementation
intact but delete the multi-line descriptive comment so only the code and any
necessary inline comments for non-obvious invariants remain, targeting the
truncateMarkdownPreview function and its surrounding comment block.
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt`:
- Around line 249-257: The onSizeChanged handler currently early-returns when
"decisive" is true so only increases above effectiveHeight are reported; remove
that early-return and instead report any significant change (use the existing
abs(measured - lastReportedPx) < 1f threshold) so decreases are propagated too.
In practice, update lastReportedPx when the change passes the threshold and call
onMeasured(measured) regardless of whether measured is greater or smaller than
effectiveHeight; adjust the logic around effectiveHeight, collapsedHeightPx,
measured, lastReportedPx, onSizeChanged and onMeasured to ensure shrinks update
the parent state as well.

In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt`:
- Around line 11-13: The function truncateMarkdownPreview doesn't validate
maxChars and will crash on substring(0, maxChars) if maxChars is negative;
update the function (truncateMarkdownPreview) to either call require(maxChars >=
0) at the start or clamp negative values to zero (e.g., val safeMax = maxOf(0,
maxChars)) before using substring, ensuring all subsequent uses (window =
content.substring(0, safeMax)) use the validated/clamped value and preserving
existing behavior when maxChars >= content.length.

---

Duplicate comments:
In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt`:
- Around line 167-179: The current remember calls for displayContent and
previewContent include raw and isDark as keys, causing them to reset to null
(spinner) whenever those inputs change; update the remember usage so
displayContent and previewContent are initialized without raw/isDark as keys
(e.g., remember { mutableStateOf<String?>(null) }) so their previous values are
preserved while LaunchedEffect(raw, isDark) does background work, and continue
to assign previewContent and displayContent inside the LaunchedEffect after
processing (functions: applyThemeAwareImages, truncateMarkdownPreview).

---

Nitpick comments:
In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt`:
- Around line 3-18: Remove the added KDoc/comment block above the
truncateMarkdownPreview function; keep the function signature and implementation
intact but delete the multi-line descriptive comment so only the code and any
necessary inline comments for non-obvious invariants remain, targeting the
truncateMarkdownPreview function and its surrounding comment block.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e471639f-3f8b-4b41-a071-d4634d041b09

📥 Commits

Reviewing files that changed from the base of the PR and between a8a9844 and 9c42f52.

📒 Files selected for processing (4)
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/SyntaxHighlightedCode.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt

Comment on lines +11 to +13
fun truncateMarkdownPreview(content: String, maxChars: Int): String {
if (content.length <= maxChars) return content
val window = content.substring(0, maxChars)
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate maxChars before slicing.

A negative maxChars reaches substring(0, maxChars) and crashes immediately. Since this helper is now reusable, it's worth making the contract explicit with a require(maxChars >= 0) or by clamping to zero.

Proposed fix
 fun truncateMarkdownPreview(content: String, maxChars: Int): String {
+    require(maxChars >= 0) { "maxChars must be >= 0" }
     if (content.length <= maxChars) return content
     val window = content.substring(0, maxChars)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun truncateMarkdownPreview(content: String, maxChars: Int): String {
if (content.length <= maxChars) return content
val window = content.substring(0, maxChars)
fun truncateMarkdownPreview(content: String, maxChars: Int): String {
require(maxChars >= 0) { "maxChars must be >= 0" }
if (content.length <= maxChars) return content
val window = content.substring(0, maxChars)
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt`
around lines 11 - 13, The function truncateMarkdownPreview doesn't validate
maxChars and will crash on substring(0, maxChars) if maxChars is negative;
update the function (truncateMarkdownPreview) to either call require(maxChars >=
0) at the start or clamp negative values to zero (e.g., val safeMax = maxOf(0,
maxChars)) before using substring, ensuring all subsequent uses (window =
content.substring(0, safeMax)) use the validated/clamped value and preserving
existing behavior when maxChars >= content.length.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt (1)

61-62: 💤 Low value

Fence toggle doesn't track opener type.

A ~~~ line inside a ``` block (or vice versa) incorrectly toggles inFence, potentially splitting mid-fence. CommonMark requires matching fence types. This is rare in practice (mostly affects markdown-about-markdown READMEs).

Optional fix to track fence opener
-    var inFence = false
+    var fenceOpener: String? = null  // "```" or "~~~" or null
     val lines = content.split('\n')
     ...
     for (line in lines) {
         val trimmed = line.trimStart()
-        if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
-            inFence = !inFence
+        val opener = when {
+            trimmed.startsWith("```") -> "```"
+            trimmed.startsWith("~~~") -> "~~~"
+            else -> null
+        }
+        if (opener != null) {
+            fenceOpener = if (fenceOpener == opener) null else fenceOpener ?: opener
         }
         ...
-        if (!inFence &&
+        if (fenceOpener == null &&
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt`
around lines 61 - 62, The fence toggling currently flips inFence on any line
starting with "```" or "~~~", which mis-handles mixed fence types; change the
logic to track the actual opener string instead: introduce a nullable String
fenceOpener (initially null), compute opener via when {
trimmed.startsWith("```") -> "```"; trimmed.startsWith("~~~") -> "~~~"; else ->
null }, and when opener != null set fenceOpener = if (fenceOpener == opener)
null else fenceOpener ?: opener; then replace checks against inFence with
fenceOpener != null (or fenceOpener == null where appropriate) so only matching
fence closers toggle the fence state (update all uses in MarkdownTruncate logic
that referenced inFence).
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt`:
- Line 45: Remove the duplicate import of androidx.compose.ui.unit.dp from the
WhatsNew.kt file: locate the redundant import statement (the one importing dp at
line 45) and delete it so only the original import (already present at line 38)
remains; ensure no other references are changed and that the file still compiles
with the single remaining dp import.

In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt`:
- Around line 44-45: The function splitMarkdownIntoChunks should validate that
targetChunkChars is positive to avoid infinite/incorrect flushing when
targetChunkChars is zero or negative; add a precondition (e.g., use
require(targetChunkChars > 0) or throw IllegalArgumentException) at the start of
splitMarkdownIntoChunks (and any caller like truncateMarkdownPreview if present)
so the contract is explicit and the later check current.length >=
targetChunkChars behaves correctly.

---

Nitpick comments:
In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt`:
- Around line 61-62: The fence toggling currently flips inFence on any line
starting with "```" or "~~~", which mis-handles mixed fence types; change the
logic to track the actual opener string instead: introduce a nullable String
fenceOpener (initially null), compute opener via when {
trimmed.startsWith("```") -> "```"; trimmed.startsWith("~~~") -> "~~~"; else ->
null }, and when opener != null set fenceOpener = if (fenceOpener == opener)
null else fenceOpener ?: opener; then replace checks against inFence with
fenceOpener != null (or fenceOpener == null where appropriate) so only matching
fence closers toggle the fence state (update all uses in MarkdownTruncate logic
that referenced inFence).
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7be79dee-ed15-4d1f-b578-7a9aeb5a5248

📥 Commits

Reviewing files that changed from the base of the PR and between 9c42f52 and fecb9d3.

📒 Files selected for processing (3)
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt

import com.mikepenz.markdown.model.rememberMarkdownState
import zed.rainxch.core.domain.util.applyThemeAwareImages
import zed.rainxch.details.presentation.markdown.githubStoreMarkdownComponents
import androidx.compose.ui.unit.dp
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove duplicate import.

androidx.compose.ui.unit.dp is already imported at line 38.

-import androidx.compose.ui.unit.dp
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import androidx.compose.ui.unit.dp
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt`
at line 45, Remove the duplicate import of androidx.compose.ui.unit.dp from the
WhatsNew.kt file: locate the redundant import statement (the one importing dp at
line 45) and delete it so only the original import (already present at line 38)
remains; ensure no other references are changed and that the file still compiles
with the single remaining dp import.

Comment on lines +44 to +45
fun splitMarkdownIntoChunks(content: String, targetChunkChars: Int): List<String> {
if (content.length <= targetChunkChars) return listOf(content)
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate targetChunkChars to be positive.

If targetChunkChars is zero or negative, the condition at line 67 (current.length >= targetChunkChars) always passes, causing a flush on every blank line. Adding a precondition like truncateMarkdownPreview makes the contract explicit.

Proposed fix
 fun splitMarkdownIntoChunks(content: String, targetChunkChars: Int): List<String> {
+    require(targetChunkChars > 0) { "targetChunkChars must be > 0" }
     if (content.length <= targetChunkChars) return listOf(content)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun splitMarkdownIntoChunks(content: String, targetChunkChars: Int): List<String> {
if (content.length <= targetChunkChars) return listOf(content)
fun splitMarkdownIntoChunks(content: String, targetChunkChars: Int): List<String> {
require(targetChunkChars > 0) { "targetChunkChars must be > 0" }
if (content.length <= targetChunkChars) return listOf(content)
🤖 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
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt`
around lines 44 - 45, The function splitMarkdownIntoChunks should validate that
targetChunkChars is positive to avoid infinite/incorrect flushing when
targetChunkChars is zero or negative; add a precondition (e.g., use
require(targetChunkChars > 0) or throw IllegalArgumentException) at the start of
splitMarkdownIntoChunks (and any caller like truncateMarkdownPreview if present)
so the contract is explicit and the later check current.length >=
targetChunkChars behaves correctly.

Comment on lines +123 to +128
val probeClient = org.koin.compose.koinInject<io.ktor.client.HttpClient>(
qualifier = org.koin.core.qualifier.named("test"),
)
val imageTransformer = remember(probeClient) {
MarkdownImageTransformer(probeClient)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 named("test") qualifier injected in production UI

Both About.kt (here) and WhatsNew.kt (line 184) inject HttpClient under qualifier = named("test"). If this binding is only registered in a test DI module (which the name strongly implies), every user who opens a repo's detail screen will get a NoBeanDefinitionFoundException crash at runtime. Even if it is currently present in the production SharedModule, using a "test"-qualified dependency in production code is fragile — a future cleanup of test bindings would silently break this. The binding should either be registered under a production-stable qualifier (e.g. named("head_probe")) or a dedicated HttpClient parameter should be added to the DI graph.

Fix in Claude Code

@rainxchzed rainxchzed merged commit eb637d1 into main May 18, 2026
1 check passed
@rainxchzed rainxchzed deleted the perf/details branch May 18, 2026 17:05
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