Skip to content

fix(whatsnew): defeat locale race by threading tag explicitly to loader#531

Merged
rainxchzed merged 1 commit intomainfrom
fix/whatsnew-locale-race
May 6, 2026
Merged

fix(whatsnew): defeat locale race by threading tag explicitly to loader#531
rainxchzed merged 1 commit intomainfrom
fix/whatsnew-locale-race

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 6, 2026

Bug

After #526 the what's-new sheet/history still showed stale-language content when switching app language. Reporter confirmed it's reproducible on the merged build.

Root cause

`WhatsNewLoaderImpl` resolved the locale via `localizationManager.getCurrentLanguageCode()` → `Locale.getDefault()`. That's a mutable global updated by `MainActivity.setActiveLanguageTag()`.

Both the WhatsNewVM and MainActivity subscribe to the same `tweaksRepository.getAppLanguage()` flow. No ordering guarantee between subscribers. When the VM's collector fired first, the loader read `Locale.getDefault()` before MainActivity had a chance to call `setActiveLanguageTag()` → loader resolved the previous locale → stale content cached. `recreate()` afterwards preserved the ViewModelStore + the stale cache.

Fix

Thread the BCP-47 tag explicitly from the flow value into `WhatsNewLoader`:

  • `WhatsNewLoader.loadAll(languageTag: String?)` and `forVersionCode(versionCode, languageTag)` now take an optional explicit tag.
  • `WhatsNewLoaderImpl.candidatePaths` honors the explicit tag when non-null; falls back to `LocalizationManager` only when the caller didn't supply one (preserves the deep-link / one-shot path).
  • `WhatsNewViewModel` stashes the latest emitted tag in `lastLanguageTag` and threads it through every loader call (`reloadHistory`, `reloadPending`, `evaluate`, `forceShowLatest`).

Net result: the loader's resolved locale comes from the same flow value that's about to update the global `Locale.getDefault()`, so the order between subscribers no longer matters.

Test plan

  • Switch language in Tweaks while what's-new sheet is up → content swaps to new language live (no race).
  • Cold-start with previously-picked language → sheet/history render in that language.
  • Profile → "Show latest what's-new" after language switch → renders in current language.
  • Regression: user who never picked a language sees device-default locale (loader falls back to `LocalizationManager.getCurrentLanguageCode()` when tag is null, same as before fix(whatsnew): re-load entries when app language changes #526).

Summary by CodeRabbit

  • New Features
    • "What's New" content now displays in your app's selected language. The system automatically loads language-specific release notes to ensure you receive updates in your preferred language.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Walkthrough

The PR adds language-tag-aware loading to the What's New feature across the domain interface, implementation, and UI layer. The interface gains optional languageTag parameters, the implementation builds language-specific file paths, and the ViewModel observes language changes to reload What's New entries in the current language.

Changes

Language-Aware What's New Loading

Layer / File(s) Summary
Interface Contract
core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt
loadAll() and forVersionCode() now accept optional languageTag parameter with documentation describing fallback behavior when language tag is not provided.
Core Implementation
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt
Public methods now pass languageTag through to private loadOrNull(). New candidatePaths() helper derives full/primary language codes from the provided languageTag or LocalizationManager to build candidate file paths.
Consumer Wiring
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt
Volatile lastLanguageTag tracks current language. Init observer triggers per-language reloads when app language changes. History and pending state loading now accept and pass languageTag. evaluate() and forceShowLatest() use language-aware loader calls.

Sequence Diagram

sequenceDiagram
    participant VM as WhatsNewViewModel
    participant LM as LocalizationManager
    participant Loader as WhatsNewLoaderImpl
    participant FS as File System

    LM->>VM: 🔔 App language changed
    VM->>VM: Update lastLanguageTag
    VM->>Loader: loadAll(languageTag)
    Loader->>Loader: candidatePaths(versionCode, languageTag)
    Loader->>FS: 📂 Check language-specific files
    FS-->>Loader: Load entry
    Loader-->>VM: List<WhatsNewEntry>
    VM->>VM: Update history state
    
    VM->>Loader: forVersionCode(current, languageTag)
    Loader->>Loader: loadOrNull with language paths
    Loader->>FS: 📂 Check language-specific file
    FS-->>Loader: Entry or null
    Loader-->>VM: WhatsNewEntry?
    VM->>VM: Evaluate pending state
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly Related PRs

  • OpenHub-Store/GitHub-Store#526: Modifies WhatsNewViewModel to respond to app-language changes; this PR extends that foundation by adding languageTag parameters throughout the loader API and implementation.

Poem

🐰 A rabbit hops through language trees,
Gathering "what's new" with such ease—
Per-tongue, per-tag, the entries aligned,
Localization magic, beautifully designed! 🌍✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% 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 title precisely describes the main change: addressing a locale race condition by explicitly threading language tags to the loader, which aligns with all three files' modifications.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/whatsnew-locale-race

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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt (1)

80-93: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

evaluate() doesn't clear _pendingEntry on the early-return path during a language swap.

Scenario: user is on the upgrade flow (lastSeen < current) and the sheet is currently shown (_pendingEntry set with old-language content). Language changes → reloadPendingevaluate(newTag). If the new language resolves to entry == null (e.g. translation missing and no fallback hit) or !entry.showAsSheet, the function advances lastSeenWhatsNewVersionCode and returns without clearing _pendingEntry. The stale-language sheet stays visible, and because lastSeen was just bumped, nothing will re-appear after dismissal/relaunch.

🛡️ Proposed fix
         val entry = whatsNewLoader.forVersionCode(current, languageTag)
         if (entry == null || !entry.showAsSheet) {
+            _pendingEntry.value = null
             tweaksRepository.setLastSeenWhatsNewVersionCode(current)
             return
         }
🤖 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
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt`
around lines 80 - 93, evaluate() can leave a stale _pendingEntry when a language
swap causes entry == null or !entry.showAsSheet but you still advance the
last-seen version; update evaluate (the suspend function) so that before calling
tweaksRepository.setLastSeenWhatsNewVersionCode(current) in those early-return
branches you also clear _pendingEntry.value (or set it to null) to ensure the
sheet is dismissed; touch the branch that checks entry == null ||
!entry.showAsSheet and call _pendingEntry.value = null (or equivalent)
immediately before invoking
tweaksRepository.setLastSeenWhatsNewVersionCode(current) so the UI no longer
shows stale-language content after a language change.
🧹 Nitpick comments (1)
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt (1)

46-59: ⚡ Quick win

Catching Throwable swallows CancellationException on VM teardown.

When viewModelScope is cancelled, the suspending collect raises CancellationException, which will be caught and logged as an error. This produces noisy log lines on every VM teardown and breaks structured-cancellation propagation. The same pattern appears in reloadHistory (line 67), evaluate (line 75), markSeen (line 101), and forceShowLatest (line 117).

Rethrow CancellationException to preserve cancellation semantics:

Suggested fix
+import kotlinx.coroutines.CancellationException
@@
-            } catch (t: Throwable) {
-                logger.e(t) { "Failed to observe app-language for what's-new reloads" }
-            }
+            } catch (t: CancellationException) {
+                throw t
+            } catch (t: Throwable) {
+                logger.e(t) { "Failed to observe app-language for what's-new reloads" }
+            }

Apply the same pattern to the other four catch blocks.

🤖 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
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt`
around lines 46 - 59, The catch blocks that currently catch Throwable (e.g., in
the viewModelScope.launch observer block around
tweaksRepository.getAppLanguage().collect and the other methods reloadHistory,
evaluate, markSeen, forceShowLatest) should preserve coroutine cancellation:
change each catch to rethrow CancellationException (or any throwable that is
CancellationException) and only log non-cancellation failures; e.g., in the
catch(t: Throwable) for the collect observer, if (t is CancellationException)
throw t else logger.e(t) { "...message..." }; apply the identical pattern to
reloadHistory, evaluate, markSeen, and forceShowLatest so cancellation
propagates correctly.
🤖 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.

Outside diff comments:
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt`:
- Around line 80-93: evaluate() can leave a stale _pendingEntry when a language
swap causes entry == null or !entry.showAsSheet but you still advance the
last-seen version; update evaluate (the suspend function) so that before calling
tweaksRepository.setLastSeenWhatsNewVersionCode(current) in those early-return
branches you also clear _pendingEntry.value (or set it to null) to ensure the
sheet is dismissed; touch the branch that checks entry == null ||
!entry.showAsSheet and call _pendingEntry.value = null (or equivalent)
immediately before invoking
tweaksRepository.setLastSeenWhatsNewVersionCode(current) so the UI no longer
shows stale-language content after a language change.

---

Nitpick comments:
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt`:
- Around line 46-59: The catch blocks that currently catch Throwable (e.g., in
the viewModelScope.launch observer block around
tweaksRepository.getAppLanguage().collect and the other methods reloadHistory,
evaluate, markSeen, forceShowLatest) should preserve coroutine cancellation:
change each catch to rethrow CancellationException (or any throwable that is
CancellationException) and only log non-cancellation failures; e.g., in the
catch(t: Throwable) for the collect observer, if (t is CancellationException)
throw t else logger.e(t) { "...message..." }; apply the identical pattern to
reloadHistory, evaluate, markSeen, and forceShowLatest so cancellation
propagates correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 07ea1f3f-aa43-4db0-81d3-b59ed5802722

📥 Commits

Reviewing files that changed from the base of the PR and between 51bccf9 and 2b273c0.

📒 Files selected for processing (3)
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt

@rainxchzed rainxchzed merged commit a076045 into main May 6, 2026
1 check passed
@rainxchzed rainxchzed deleted the fix/whatsnew-locale-race branch May 6, 2026 18:21
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