Skip to content

fix(theme): stabilize multi-window theme sync and eliminate flicker#42

Merged
iguit0 merged 1 commit intomainfrom
fix/theme-flicker
Mar 4, 2026
Merged

fix(theme): stabilize multi-window theme sync and eliminate flicker#42
iguit0 merged 1 commit intomainfrom
fix/theme-flicker

Conversation

@iguit0
Copy link
Copy Markdown
Contributor

@iguit0 iguit0 commented Mar 4, 2026

Summary

This PR fixes theme flicker/blinking when switching between light, dark, and system in the Tauri app.

The previous setup relied on next-themes behavior across multiple live webviews sharing the same storage key, which caused unstable theme application and visible flashes. This PR replaces that runtime path with a local theme provider designed for Arandu's multi-window setup.

What changed

  • Added a local theme system in apps/tauri/src/lib/theme.tsx
  • Replaced next-themes usage in:
    • App.tsx
    • SettingsApp.tsx
    • WhisperApp.tsx
    • TopBar.tsx
    • GeneralSettings.tsx
    • components/ui/sonner.tsx
  • Added guarded pre-hydration theme bootstrapping in:
    • apps/tauri/index.html
    • apps/tauri/settings.html
    • apps/tauri/whisper.html
  • Hardened theme background behavior in apps/tauri/src/index.css
  • Preserved transparent behavior for the whisper window
  • Added regression tests for:
    • stored theme initialization
    • active-window localStorage writes
    • storage sync without echo loops
    • system theme updates
  • Updated existing tests affected by current UI/i18n behavior

Why

Arandu runs multiple windows (main, settings, whisper), and theme changes need to be consistent across them without:

  • storage echo loops
  • transient wrong theme states
  • startup flashes
  • whisper transparency regressions

This implementation makes theme changes deterministic and window-safe.

Notes

  • Cross-window sync now uses both localStorage and explicit Tauri event propagation for safer multi-window behavior.
  • Theme transitions are temporarily disabled during swaps to prevent visible flicker.
  • Boot scripts now validate stored theme values and fail safely if storage access is unavailable.

Summary by CodeRabbit

  • New Features

    • Theme system now initializes before app loads, eliminating startup theme flashes.
    • Automatic light and dark mode switching based on system preferences.
    • Enhanced theme persistence and state management across application windows.
  • Chores

    • Upgraded theme management infrastructure for improved performance and reliability.

@iguit0 iguit0 requested a review from wilcorrea March 4, 2026 05:46
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 4, 2026

📝 Walkthrough

Walkthrough

This pull request implements a custom theme provider system to replace the next-themes library. It introduces a new @/lib/theme module with ThemeProvider and useTheme exports, adds theme bootstrap scripts to HTML entry points, updates all component imports to use the new theme provider, and includes comprehensive tests for the new theme system with test expectation updates.

Changes

Cohort / File(s) Summary
Core Theme System
apps/tauri/src/lib/theme.tsx
New file implementing a complete custom theme provider supporting light/dark/system themes with localStorage persistence, matchMedia system preference detection, and cross-window synchronization via TAURI events.
HTML Entry Points with Theme Bootstrap
apps/tauri/index.html, apps/tauri/settings.html, apps/tauri/whisper.html
Added data-window attributes and inline theme bootstrap scripts that initialize theme from localStorage, resolve system preferences, and apply theme by toggling DOM classes and setting colorScheme.
Application Root Components
apps/tauri/src/App.tsx, apps/tauri/src/SettingsApp.tsx, apps/tauri/src/WhisperApp.tsx
Updated ThemeProvider import from "next-themes" to "@/lib/theme" and added disableTransitionOnChange prop to suppress CSS transitions during theme changes.
Component Theme Hooks
apps/tauri/src/components/TopBar.tsx, apps/tauri/src/components/settings/GeneralSettings.tsx, apps/tauri/src/components/ui/sonner.tsx
Updated useTheme import from "next-themes" to "@/lib/theme"; sonner.tsx also changed to use resolvedTheme instead of theme for Sonner component.
Styling
apps/tauri/src/index.css
Added background-color styling, min-height for root container, and data-window specific styling (whisper window reset to transparent background).
Theme System Tests
apps/tauri/src/__tests__/lib/theme.test.tsx
New comprehensive test suite covering theme initialization, localStorage persistence, DOM class/attribute application, system preference detection via matchMedia, cross-window synchronization, and theme change events.
Component Tests
apps/tauri/src/__tests__/components/ChatPanel.test.tsx, apps/tauri/src/__tests__/components/HomeScreen.test.tsx, apps/tauri/src/__tests__/components/TopBar.test.tsx
Updated placeholder/label text expectations in ChatPanel and HomeScreen tests; TopBar test now wraps component with TooltipProvider and uses updated ThemeProvider import.
Test Infrastructure
apps/tauri/src/__tests__/setup.ts
Added mock for TAURI event.emit to support theme-changed event emissions in tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A theme system springs to life so bright,
From localStorage whispers, dark and light,
The bootstrap scripts dance across the frames,
While TAURI bridges sync all the thematic names,
No transitions blur this thoughtful refight! 🎨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: replacing next-themes with a local theme provider to fix multi-window theme sync and eliminate flicker in a Tauri app.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/theme-flicker

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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

@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: 3

🧹 Nitpick comments (1)
apps/tauri/src/App.tsx (1)

229-235: Consider centralizing repeated ThemeProvider config.

This same prop set is duplicated across app roots; extracting a shared wrapper/config would reduce drift risk in future theme changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/tauri/src/App.tsx` around lines 229 - 235, Several app roots repeat the
same ThemeProvider props (attribute="class", defaultTheme="system",
enableSystem, storageKey="arandu-theme", disableTransitionOnChange"); extract
these into a single shared wrapper component (e.g., AppThemeProvider or
ThemeProviderWrapper) that composes ThemeProvider with that prop set and export
it for reuse, then replace direct ThemeProvider usages with this wrapper across
the codebase (search for ThemeProvider and storageKey="arandu-theme" to locate
all instances) so future theme prop changes are made in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/tauri/src/index.css`:
- Around line 133-135: The font-family declaration in the CSS block with `@apply`
bg-background text-foreground font-sans; is violating stylelint rules
(font-family-name-quotes and declaration spacing); fix it by using a lint-safe
font-family value (either remove unnecessary quotes: font-family: Inter,
system-ui, sans-serif; or use double quotes: font-family: "Inter", system-ui,
sans-serif;) and ensure each declaration in the same rule-set ends with a
semicolon and consistent spacing (keep `@apply` ..., then the font-family line,
then background-color: hsl(var(--background));) so the block containing `@apply`,
font-family, and background-color conforms to stylelint rules.

In `@apps/tauri/src/lib/theme.tsx`:
- Around line 81-82: The ThemeProvider's default storageKey ("theme") is
inconsistent with the bootstrap scripts; update the default value in the
ThemeProviderProps deconstruction inside the ThemeProvider function so
storageKey defaults to "arandu-theme" to match the window bootstrap (e.g.,
change the default from "theme" to "arandu-theme" where storageKey is defined in
the ThemeProvider component in apps/tauri/src/lib/theme.tsx).
- Around line 114-118: The current try/catch around
window.__TAURI__.event.emit(...) and the Promise chain from
window.__TAURI__.event.listen(...) don't handle async rejections; replace the
synchronous try/catch with either an awaited call inside an async function
wrapped in try/catch or append a .catch(...) to the emit() call to handle
promise rejections, and add .catch(...) to the listen() promise chain as well
(e.g., in the code paths using window.__TAURI__.event.emit and
window.__TAURI__.event.listen), ensuring any errors are either logged via the
existing logger or safely ignored so local state stays in sync.

---

Nitpick comments:
In `@apps/tauri/src/App.tsx`:
- Around line 229-235: Several app roots repeat the same ThemeProvider props
(attribute="class", defaultTheme="system", enableSystem,
storageKey="arandu-theme", disableTransitionOnChange"); extract these into a
single shared wrapper component (e.g., AppThemeProvider or ThemeProviderWrapper)
that composes ThemeProvider with that prop set and export it for reuse, then
replace direct ThemeProvider usages with this wrapper across the codebase
(search for ThemeProvider and storageKey="arandu-theme" to locate all instances)
so future theme prop changes are made in one place.

ℹ️ Review info
Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 27bed7b1-91b4-407d-a882-0cdaf3ceb927

📥 Commits

Reviewing files that changed from the base of the PR and between e2fff0f and b937ccf.

📒 Files selected for processing (16)
  • apps/tauri/index.html
  • apps/tauri/settings.html
  • apps/tauri/src/App.tsx
  • apps/tauri/src/SettingsApp.tsx
  • apps/tauri/src/WhisperApp.tsx
  • apps/tauri/src/__tests__/components/ChatPanel.test.tsx
  • apps/tauri/src/__tests__/components/HomeScreen.test.tsx
  • apps/tauri/src/__tests__/components/TopBar.test.tsx
  • apps/tauri/src/__tests__/lib/theme.test.tsx
  • apps/tauri/src/__tests__/setup.ts
  • apps/tauri/src/components/TopBar.tsx
  • apps/tauri/src/components/settings/GeneralSettings.tsx
  • apps/tauri/src/components/ui/sonner.tsx
  • apps/tauri/src/index.css
  • apps/tauri/src/lib/theme.tsx
  • apps/tauri/whisper.html

Comment thread apps/tauri/src/index.css
Comment on lines 133 to +135
@apply bg-background text-foreground font-sans;
font-family: 'Inter', system-ui, sans-serif;
background-color: hsl(var(--background));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Resolve the stylelint violation in the body font declaration block.

Line 134 currently conflicts with font-family-name-quotes, and this block is also where declaration spacing is being linted.

💡 Suggested lint-safe adjustment
   body {
     `@apply` bg-background text-foreground font-sans;
-    font-family: 'Inter', system-ui, sans-serif;
+
+    font-family: Inter, system-ui, sans-serif;
     background-color: hsl(var(--background));
📝 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
@apply bg-background text-foreground font-sans;
font-family: 'Inter', system-ui, sans-serif;
background-color: hsl(var(--background));
`@apply` bg-background text-foreground font-sans;
font-family: Inter, system-ui, sans-serif;
background-color: hsl(var(--background));
🧰 Tools
🪛 Biome (2.4.4)

[error] 133-133: Tailwind-specific syntax is disabled.

(parse)

🪛 Stylelint (17.3.0)

[error] 134-134: Expected empty line before declaration (declaration-empty-line-before)

(declaration-empty-line-before)


[error] 134-134: Unexpected quotes around "Inter" (font-family-name-quotes)

(font-family-name-quotes)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/tauri/src/index.css` around lines 133 - 135, The font-family declaration
in the CSS block with `@apply` bg-background text-foreground font-sans; is
violating stylelint rules (font-family-name-quotes and declaration spacing); fix
it by using a lint-safe font-family value (either remove unnecessary quotes:
font-family: Inter, system-ui, sans-serif; or use double quotes: font-family:
"Inter", system-ui, sans-serif;) and ensure each declaration in the same
rule-set ends with a semicolon and consistent spacing (keep `@apply` ..., then the
font-family line, then background-color: hsl(var(--background));) so the block
containing `@apply`, font-family, and background-color conforms to stylelint
rules.

Comment on lines +81 to +82
storageKey = "theme",
}: ThemeProviderProps) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Default storage key is inconsistent with bootstrap scripts.

Line 81 defaults to "theme", but the window bootstrap scripts read "arandu-theme" (for example, apps/tauri/index.html Line 11 and apps/tauri/whisper.html Line 10). This can reintroduce startup mismatch/flicker whenever ThemeProvider is used without an explicit storageKey.

🔧 Suggested fix
-  storageKey = "theme",
+  storageKey = "arandu-theme",
📝 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
storageKey = "theme",
}: ThemeProviderProps) {
storageKey = "arandu-theme",
}: ThemeProviderProps) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/tauri/src/lib/theme.tsx` around lines 81 - 82, The ThemeProvider's
default storageKey ("theme") is inconsistent with the bootstrap scripts; update
the default value in the ThemeProviderProps deconstruction inside the
ThemeProvider function so storageKey defaults to "arandu-theme" to match the
window bootstrap (e.g., change the default from "theme" to "arandu-theme" where
storageKey is defined in the ThemeProvider component in
apps/tauri/src/lib/theme.tsx).

Comment on lines +114 to +118
try {
void window.__TAURI__.event.emit("theme-changed", nextTheme);
} catch {
// Ignore event bridge failures and keep local state in sync.
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/tauri/src/lib/theme.tsx | head -200

Repository: devitools/arandu

Length of output: 7035


🏁 Script executed:

# Check the specific lines mentioned in the review
sed -n '110,125p' apps/tauri/src/lib/theme.tsx
sed -n '175,185p' apps/tauri/src/lib/theme.tsx

Repository: devitools/arandu

Length of output: 736


🌐 Web query:

Tauri event.emit() Promise rejection handling - does emit return a promise

💡 Result:

Yes. In Tauri’s JavaScript API, emit is async and returns a Promise<void>. So you should await it or attach a .catch(...) to avoid unhandled promise rejections. [1]

Internally it just awaits an IPC invoke('plugin:event|emit', ...), so the promise only rejects if that IPC call fails (e.g., not running in a Tauri context, plugin/IPC error, payload serialization issues), not because a frontend listener “throws”. [2]

import { emit } from "@tauri-apps/api/event";

try {
  await emit("my-event", { hello: "world" });
} catch (e) {
  // handle failed emit (IPC/invoke failure)
  console.error(e);
}

// or, fire-and-forget safely:
void emit("my-event").catch(console.error);

Sources: [1] Tauri JS API docs for event.emit(): Promise<void> (tauri.app), [2] Tauri emit implementation (await invoke('plugin:event|emit', ...)) (github.com)

Citations:


Add .catch() handlers for Promise rejections in Tauri event calls.

window.__TAURI__.event.emit() and listen() return Promises, but synchronous try/catch blocks cannot catch async rejections. Line 115's try/catch won't handle failures from emit(), and line 180's Promise chain lacks rejection handling.

🔧 Suggested fix
-      try {
-        void window.__TAURI__.event.emit("theme-changed", nextTheme);
-      } catch {
-        // Ignore event bridge failures and keep local state in sync.
-      }
+      void window.__TAURI__.event.emit("theme-changed", nextTheme).catch(() => {
+        // Ignore event bridge failures and keep local state in sync.
+      });
-    return () => {
-      void unlisten.then((dispose) => dispose());
-    };
+    return () => {
+      void unlisten
+        .then((dispose) => dispose())
+        .catch(() => {
+          // Ignore unlisten failures during teardown.
+        });
+    };

Also applies to: 179-181

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/tauri/src/lib/theme.tsx` around lines 114 - 118, The current try/catch
around window.__TAURI__.event.emit(...) and the Promise chain from
window.__TAURI__.event.listen(...) don't handle async rejections; replace the
synchronous try/catch with either an awaited call inside an async function
wrapped in try/catch or append a .catch(...) to the emit() call to handle
promise rejections, and add .catch(...) to the listen() promise chain as well
(e.g., in the code paths using window.__TAURI__.event.emit and
window.__TAURI__.event.listen), ensuring any errors are either logged via the
existing logger or safely ignored so local state stays in sync.

@iguit0 iguit0 merged commit c3aa469 into main Mar 4, 2026
1 check passed
@iguit0 iguit0 deleted the fix/theme-flicker branch March 4, 2026 15:10
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.

2 participants