Skip to content

fix: resolvedTheme from useTheme() now reflects forcedTheme - #383

Open
i8ramin wants to merge 1 commit into
pacocoursey:mainfrom
i8ramin:fix/resolved-theme-respects-forced-theme
Open

fix: resolvedTheme from useTheme() now reflects forcedTheme#383
i8ramin wants to merge 1 commit into
pacocoursey:mainfrom
i8ramin:fix/resolved-theme-respects-forced-theme

Conversation

@i8ramin

@i8ramin i8ramin commented Mar 12, 2026

Copy link
Copy Markdown

Summary

useTheme().resolvedTheme ignores forcedTheme — consumers get the user's stored preference instead of the active forced theme.

Closes #252

The Bug

When forcedTheme is set on ThemeProvider, the DOM is updated correctly (the data-theme attribute and color-scheme style reflect the forced value), but the React context value for resolvedTheme is wrong:

// _app.tsx
<ThemeProvider forcedTheme="light">

// SomeComponent.tsx
const { resolvedTheme } = useTheme()
// resolvedTheme === 'dark'  ← WRONG (user's stored preference)
// expected: 'light'          ← the forced theme

This forces consumers to read forcedTheme directly and fall back manually:

const { resolvedTheme, forcedTheme } = useTheme()
const effectiveTheme = forcedTheme ?? resolvedTheme // workaround

Root Cause

In next-themes/src/index.tsx, the provider value computes:

resolvedTheme: theme === 'system' ? resolvedTheme : theme,

This ignores forcedTheme entirely. The theme state variable holds the user's stored preference, not the forced value.

Fix

One-line change — prepend forcedTheme with nullish coalescing and correct parentheses:

resolvedTheme: forcedTheme ?? (theme === 'system' ? resolvedTheme : theme),

Note on the v1 branch

The v1 branch attempted this same fix but has an operator precedence bug — the ternary is not wrapped in parentheses:

// v1 branch (line ~228):
resolvedTheme: forcedTheme ?? theme === 'system' ? resolvedTheme : theme,

// Evaluates as:
(forcedTheme ?? (theme === 'system')) ? resolvedTheme : theme
// NOT:
forcedTheme ?? (theme === 'system' ? resolvedTheme : theme)

This PR uses the correct precedence.

Test

Added a Playwright test that verifies resolvedTheme from useTheme() matches the forced theme on forced-theme pages (the existing tests only checked DOM attributes, which is why this bug wasn't caught).

Impact

  • Minimal — one line changed in the provider value computation
  • No breaking changes — forcedTheme is still available as a separate field
  • DOM behavior unchanged (was already correct via applyTheme)
  • Only the React context value is fixed

When forcedTheme is set on ThemeProvider, useTheme().resolvedTheme
should return the forced theme value. Previously it was computed as:

  resolvedTheme: theme === 'system' ? resolvedTheme : theme

This ignores forcedTheme entirely — consumers reading resolvedTheme
get the user's stored preference instead of the active forced theme.

Fix: prepend forcedTheme with nullish coalescing:

  resolvedTheme: forcedTheme ?? (theme === 'system' ? resolvedTheme : theme)

Note: the v1 branch attempted this same fix but has an operator
precedence bug (missing parentheses around the ternary). This PR
includes the correct precedence.

Also adds a Playwright test that verifies resolvedTheme from
useTheme() matches the forced theme on forced-theme pages.

Closes pacocoursey#252
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.

Should resolvedTheme obey forcedTheme?

1 participant