Skip to content

fix(theme): resolve ThemeToggle hydration mismatch#688

Closed
nino-chavez wants to merge 1 commit intoOpenCut-app:mainfrom
nino-chavez:fix/theme-toggle-hydration-648
Closed

fix(theme): resolve ThemeToggle hydration mismatch#688
nino-chavez wants to merge 1 commit intoOpenCut-app:mainfrom
nino-chavez:fix/theme-toggle-hydration-648

Conversation

@nino-chavez
Copy link
Copy Markdown

@nino-chavez nino-chavez commented Jan 29, 2026

Summary

  • Added mounted state pattern to prevent SSR/client icon mismatch
  • Shows placeholder during SSR, then correct icon after hydration
  • Icon now correctly reflects current theme (Moon in light, Sun in dark)

Related Issue

Fixes #648

Test plan

  • Load Projects page fresh
  • Verify correct icon shows immediately (no flash of wrong icon)
  • Check console for hydration errors - none present
  • Toggle theme and verify icon changes correctly
  • Playwright E2E test included

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed hydration mismatch in the theme toggle to prevent visual flicker on load.
  • Improvements

    • Shows a neutral placeholder button while the page mounts to preserve layout and accessibility.
    • Maintains existing toggle behavior, icons, and labels so visible functionality remains unchanged.

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 29, 2026

👷 Deploy request for appcut pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit cf0f9a6

@vercel
Copy link
Copy Markdown

vercel Bot commented Jan 29, 2026

@nino-chavez is attempting to deploy a commit to the OpenCut OSS Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cf0f9a6 and 55a29be.

📒 Files selected for processing (1)
  • apps/web/src/components/theme-toggle.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/src/components/theme-toggle.tsx

📝 Walkthrough

Walkthrough

Adds a client-mounted guard to ThemeToggle so theme-dependent UI is rendered only after hydration, preventing SSR/client mismatches; renders a neutral placeholder until mounted and retains existing toggle behavior and API.

Changes

Cohort / File(s) Summary
Theme Toggle
apps/web/src/components/theme-toggle.tsx
Adds a mounted state with useEffect to defer theme-dependent rendering until client-side mount, returns a neutral placeholder Button while not mounted, preserves existing click handler and theme toggle logic, and updates icon/label rendering to depend on client theme state.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 I waited till the client woke,
Then peeked to see which token spoke.
A neutral button held the place,
Till sun or moon could show its face—
Now themes hop true with gentle grace.

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(theme): resolve ThemeToggle hydration mismatch' is concise, specific, and directly describes the main change—fixing a hydration mismatch in the ThemeToggle component.
Description check ✅ Passed The PR description provides a clear summary of changes, references the related issue (#648), and includes a comprehensive test plan with checkmarks indicating testing was completed.
Linked Issues check ✅ Passed The code changes directly address all requirements from issue #648: implementing mounted state pattern to prevent SSR/client mismatch, displaying correct icons per theme, and eliminating hydration errors.
Out of Scope Changes check ✅ Passed All changes are scoped to the ThemeToggle component and directly address the hydration mismatch issue. No unrelated modifications or feature creep detected.

✏️ 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

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

🤖 Fix all issues with AI agents
In `@apps/web/src/components/theme-toggle.tsx`:
- Around line 32-48: Add an explicit type attribute to the Button used in
ThemeToggle to avoid defaulting to type="submit": update the JSX for the Button
component (the one rendering Sun/Moon and calling setTheme based on isDark) to
include type="button" alongside existing props (size, variant, className,
onClick) so it won't submit forms when nested inside a form.
🧹 Nitpick comments (2)
apps/web/src/components/theme-toggle.tsx (2)

13-13: Consider using resolvedTheme instead of theme to handle system preference correctly.

When a user has theme set to "system", checking theme === "dark" will be false even if the system is in dark mode. The useTheme hook provides resolvedTheme which reflects the actual computed theme (accounting for system preference).

♻️ Proposed fix
 export function ThemeToggle({ className }: ThemeToggleProps) {
-  const { theme, setTheme } = useTheme();
+  const { resolvedTheme, setTheme } = useTheme();
   const [mounted, setMounted] = useState(false);
-  const isDark = theme === "dark";
+  const isDark = resolvedTheme === "dark";

Also applies to: 30-30


23-23: The className fallback logic may not behave as expected.

Using className ?? "h-7" means if any className is passed, the default "h-7" is completely replaced rather than merged. If callers expect to add classes while keeping the default height, this could cause layout inconsistencies.

If the intent is to allow full override, this is fine. Otherwise, consider merging classes.

♻️ Example using cn utility (if available)
-      className={className ?? "h-7"}
+      className={cn("h-7", className)}

Also applies to: 36-36

Comment on lines +32 to +48
return (
<Button
size="icon"
variant="text"
className={className ?? "h-7"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? (
<Sun className="!size-[1.1rem]" />
) : (
<Moon className="!size-[1.1rem]" />
)}
<span className="sr-only">
Switch to {isDark ? "light" : "dark"} mode
</span>
</Button>
);
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

🧩 Analysis chain

🏁 Script executed:

find apps/web/src -name "*.tsx" -o -name "*.ts" | xargs grep -l "export.*Button" | head -20

Repository: OpenCut-app/OpenCut

Length of output: 239


🏁 Script executed:

fd -t f -e tsx -e ts apps/web/src | xargs grep -l "const Button\|export.*Button\|function Button" | head -20

Repository: OpenCut-app/OpenCut

Length of output: 416


🏁 Script executed:

cat -n apps/web/src/components/ui/button.tsx

Repository: OpenCut-app/OpenCut

Length of output: 2837


🏁 Script executed:

grep -r "theme-toggle" apps/web/src --include="*.tsx" --include="*.ts" -B 2 -A 2 | head -50

Repository: OpenCut-app/OpenCut

Length of output: 827


🏁 Script executed:

grep -r "ThemeToggle\|theme-toggle" apps/web/src --include="*.tsx" --include="*.ts" | grep -E "import|from" | head -10

Repository: OpenCut-app/OpenCut

Length of output: 239


🏁 Script executed:

cat -n apps/web/src/components/header.tsx

Repository: OpenCut-app/OpenCut

Length of output: 1993


🏁 Script executed:

cat -n apps/web/src/components/editor/editor-header.tsx

Repository: OpenCut-app/OpenCut

Length of output: 5222


Add type="button" to the Button component in ThemeToggle.

Per coding guidelines, button elements must always include a type attribute. The Button component doesn't set type="button" by default, and without it, the browser defaults to type="submit", which could cause unintended behavior if this component is used in a form context in the future.

<Button
  type="button"
  size="icon"
  variant="text"
  className={className ?? "h-7"}
  onClick={() => setTheme(isDark ? "light" : "dark")}
>
🤖 Prompt for AI Agents
In `@apps/web/src/components/theme-toggle.tsx` around lines 32 - 48, Add an
explicit type attribute to the Button used in ThemeToggle to avoid defaulting to
type="submit": update the JSX for the Button component (the one rendering
Sun/Moon and calling setTheme based on isDark) to include type="button"
alongside existing props (size, variant, className, onClick) so it won't submit
forms when nested inside a form.

- Added mounted state to prevent SSR/client hydration mismatch
- Show Sun icon in dark mode, Moon icon in light mode
- Render placeholder during SSR to maintain layout stability
- Fixed className prop not being applied

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@nino-chavez nino-chavez force-pushed the fix/theme-toggle-hydration-648 branch from cf0f9a6 to 55a29be Compare February 24, 2026 00:03
@nino-chavez nino-chavez closed this by deleting the head repository Feb 24, 2026
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.

[BUG] ThemeToggle always shows Sun icon and causes hydration error during SSR

1 participant