Skip to content

Improve theme handling particularly for SSR sites#29

Merged
ianpaschal merged 5 commits into
masterfrom
rework-theme-handling
Jun 3, 2026
Merged

Improve theme handling particularly for SSR sites#29
ianpaschal merged 5 commits into
masterfrom
rework-theme-handling

Conversation

@ianpaschal
Copy link
Copy Markdown
Owner

@ianpaschal ianpaschal commented Jun 3, 2026

Summary by CodeRabbit

  • New Features

    • Persistent theme selection with optional system-preference mode and ability to lock a theme.
    • Utilities to generate and apply theme styles and to inject a blocking preflight script to prevent first-paint flash.
  • Documentation

    • Expanded README with usage, registration, API reference, and SSR/static-site integration guidance.
  • Improvements

    • Scoped CSS per theme, cached theme variables for faster style generation, and more reliable first-paint visuals.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a9b76822-4461-43fb-8a8c-9089f1785abc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR refactors ThemeProvider to add persistent theme selection and CSS-in-JS stylesheet generation. Theme selection is now persisted to localStorage under THEME_STORAGE_KEY. The internal store was restructured to cache computed CSS variables in ThemeRegistryEntry objects. New utilities—getThemeStyleSheet() and injectThemePreflight()—generate scoped CSS and inject a blocking preflight script to prevent theme flash. The component now initializes from persisted state, applies resolved themes via the data-theme DOM attribute, and exposes system theme fallback via prefers-color-scheme media query. The useResolvedTheme hook was updated to return both the resolved theme and its key, replacing the previous useThemeVars hook.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main focus of the changeset—comprehensive improvements to theme handling with specific emphasis on SSR compatibility.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 rework-theme-handling

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: 6

🤖 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 `@src/components/ThemeProvider/README.md`:
- Around line 125-130: The README for injectThemePreflight is missing the
optional defaults parameter; update the `injectThemePreflight()` docs to include
the `defaults` argument (as implemented in ThemeProvider.store.ts) describing
its shape and purpose (e.g., an object to override the dark/light key names used
when the stored value is "__system"), show the full signature
`injectThemePreflight(defaults?)` and a short example of passing `{ light:
'light-key', dark: 'dark-key' }`, and mention behavior when omitted (falls back
to built-in keys and prefers-color-scheme).

In `@src/components/ThemeProvider/ThemeProvider.hooks.ts`:
- Line 17: Guard direct browser APIs used in the state initializers: change the
useState initializer in useResolvedTheme (function useResolvedTheme) so it does
not call window.matchMedia during SSR (e.g., use a safe default like false or
read window only when typeof window !== 'undefined'), and similarly guard the
localStorage.getItem call in ThemeProvider (component ThemeProvider) or defer
both reads into a useEffect/useLayoutEffect so they run only on the client;
ensure the first client-side useLayoutEffect that sets
document.documentElement.dataset.theme (the existing useLayoutEffect in
ThemeProvider) uses the same initial theme value that injectThemePreflight()
writes to avoid a flash/mismatch on first paint.

In `@src/components/ThemeProvider/ThemeProvider.store.ts`:
- Around line 49-57: getThemeStyleSheet() currently skips updating the DOM when
a <style data-theme-vars> exists and will append a duplicate when SSR injected
styles lack that attribute; change the DOM handling to locate an existing theme
style via document.querySelector('style[data-theme-vars], style[is="inline"]'),
and if found set its data-theme-vars attribute
(style.setAttribute('data-theme-vars', '')) and update existing.textContent =
css; otherwise create and append the new <style> as before—this ensures
re-registered themes update the stylesheet and SSR-inlined styles are tagged and
refreshed instead of duplicated.
- Around line 65-78: Convert the non-interpolated template literal fragments
inside the returned script into single-quoted strings to satisfy
`@stylistic/quotes`, keeping the template literals that include
${THEME_STORAGE_KEY}, ${dark}, and ${light} as-is; for every fragment you
convert (e.g., strings containing 'data-theme', '__system', "['data-theme']",
etc.) escape inner single quotes appropriately so the concatenation still
produces the same script; locate the script assembly around the returned string
in ThemeProvider.store (the helper function a, the localStorage read using
THEME_STORAGE_KEY, the lines referencing ${dark}/${light}, and the
MutationObserver/document.documentElement parts) and replace only the
non-interpolated template literals with single-quoted equivalents while
preserving the concatenation and original behavior.

In `@src/components/ThemeProvider/ThemeProvider.tsx`:
- Around line 40-46: Replace the client-only useLayoutEffect calls with
client-safe effects to avoid SSR warnings: change the two useLayoutEffect usages
around getThemeStyleSheet and the
document.documentElement.setAttribute('data-theme', resolvedKey) to useEffect
(or an isomorphic hook like useIsomorphicLayoutEffect) so DOM mutation only runs
on the client; update references to the existing getThemeStyleSheet invocation
and the effect that depends on resolvedKey (and/or import a shared
useIsomorphicLayoutEffect helper) to ensure behavior remains identical while
eliminating the SSR warning.
- Line 28: The useState initializer currently reads localStorage (const [key,
setKey] = useState(() => localStorage.getItem(THEME_STORAGE_KEY) ??
SYSTEM_THEME_KEY)), which crashes during SSR; change the initializer to default
to SYSTEM_THEME_KEY only, and then add a useEffect that runs on the client to
read localStorage.getItem(THEME_STORAGE_KEY) (guarding with typeof window !==
'undefined' or try/catch) and call setKey with the persisted value to hydrate;
keep THEME_STORAGE_KEY, SYSTEM_THEME_KEY, useState, setKey and the new useEffect
as the referenced symbols.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ee2242ac-7d84-4578-8b8c-63fb810e85bc

📥 Commits

Reviewing files that changed from the base of the PR and between 3e2377e and 25009ca.

📒 Files selected for processing (6)
  • src/components/ThemeProvider/README.md
  • src/components/ThemeProvider/ThemeProvider.hooks.ts
  • src/components/ThemeProvider/ThemeProvider.store.ts
  • src/components/ThemeProvider/ThemeProvider.tsx
  • src/components/ThemeProvider/ThemeProvider.types.ts
  • src/components/ThemeProvider/index.ts

Comment thread src/components/ThemeProvider/README.md Outdated
Comment thread src/components/ThemeProvider/ThemeProvider.hooks.ts Outdated
Comment thread src/components/ThemeProvider/ThemeProvider.store.ts
Comment thread src/components/ThemeProvider/ThemeProvider.store.ts Outdated
Comment thread src/components/ThemeProvider/ThemeProvider.tsx Outdated
Comment thread src/components/ThemeProvider/ThemeProvider.tsx Outdated
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: 2

Caution

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

⚠️ Outside diff range comments (1)
src/components/ThemeProvider/ThemeProvider.hooks.ts (1)

39-41: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep resolvedKey aligned with the fallback theme.

If activeKey is missing from the registry, this branch falls back to the light theme object but still returns the invalid key as resolvedKey. ThemeProvider.tsx Lines 46-48 then writes that stale key into data-theme, while the generated stylesheet only has selectors for registered keys, so a stale persisted value or invalid forced theme can render without any matching CSS variables.

🔧 Proposed fix
-  return {
-    theme: registry[activeKey]?.theme ?? registry['light']?.theme ?? light,
-    resolvedKey: activeKey,
-  };
+  const resolvedKey = registry[activeKey] ? activeKey : 'light';
+  return {
+    theme: registry[resolvedKey]?.theme ?? light,
+    resolvedKey,
+  };
🤖 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 `@src/components/ThemeProvider/ThemeProvider.hooks.ts` around lines 39 - 41,
The returned resolvedKey must match the actual theme source instead of always
echoing activeKey; update the return in ThemeProvider.hooks.ts so resolvedKey is
set to the key that provided the theme (e.g., use activeKey if
registry[activeKey] exists, otherwise use 'light' or the key you fell back to
from registry) so ThemeProvider.tsx writes the correct data-theme that
corresponds to the generated stylesheet selectors; adjust the logic around
registry, activeKey, theme and resolvedKey to compute both values from the same
branch.
🤖 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 `@src/components/ThemeProvider/ThemeProvider.store.ts`:
- Around line 66-78: The injected preflight script built in ThemeProvider.store
(the returned string that defines function a) reads
localStorage.getItem('${THEME_STORAGE_KEY}') directly and can throw
SecurityError in sandboxed iframes; wrap the localStorage access in a try/catch
inside the injected function a so that on any error you treat it as if the value
were SYSTEM_THEME_KEY (i.e., fall back to the system branch using
window.matchMedia), and ensure the rest of the logic (setting
document.documentElement dataset and the MutationObserver) remains unchanged;
update the string-building expression where THEME_STORAGE_KEY and
SYSTEM_THEME_KEY are referenced to include this guarded read.

In `@src/components/ThemeProvider/ThemeProvider.tsx`:
- Around line 42-44: The effect that calls getThemeStyleSheet() runs only once
but needs to re-run whenever the theme registry changes; update the
useIsomorphicLayoutEffect in ThemeProvider.tsx to depend on the reactive
registry from useStore(themeStore) (or the specific registry variable returned
by useStore) so getThemeStyleSheet() regenerates whenever registerTheme() adds
themes, ensuring newly registered themes produce the corresponding
:root[data-theme="..."] blocks.

---

Outside diff comments:
In `@src/components/ThemeProvider/ThemeProvider.hooks.ts`:
- Around line 39-41: The returned resolvedKey must match the actual theme source
instead of always echoing activeKey; update the return in ThemeProvider.hooks.ts
so resolvedKey is set to the key that provided the theme (e.g., use activeKey if
registry[activeKey] exists, otherwise use 'light' or the key you fell back to
from registry) so ThemeProvider.tsx writes the correct data-theme that
corresponds to the generated stylesheet selectors; adjust the logic around
registry, activeKey, theme and resolvedKey to compute both values from the same
branch.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a4b4c11e-270b-4a00-adb6-7e9a7d36ff8c

📥 Commits

Reviewing files that changed from the base of the PR and between 25009ca and c3234b1.

📒 Files selected for processing (6)
  • src/components/ThemeProvider/README.md
  • src/components/ThemeProvider/ThemeProvider.constants.ts
  • src/components/ThemeProvider/ThemeProvider.hooks.ts
  • src/components/ThemeProvider/ThemeProvider.store.ts
  • src/components/ThemeProvider/ThemeProvider.tsx
  • src/components/ThemeProvider/index.ts

Comment thread src/components/ThemeProvider/ThemeProvider.store.ts Outdated
Comment thread src/components/ThemeProvider/ThemeProvider.tsx Outdated
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: 5

Caution

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

⚠️ Outside diff range comments (1)
src/components/ThemeProvider/ThemeProvider.hooks.ts (1)

48-50: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return the fallback key when the registry lookup misses.

If activeKey comes from stale or user-edited localStorage, Line 49 falls back the theme object to light but Line 50 still returns the invalid key. ThemeProvider.tsx then writes that unknown value to data-theme, so the generated stylesheet has no matching block while the context says “light”. Resolve theme and resolvedKey from the same fallback path.

🐛 Proposed fix
   if (activeKey === SYSTEM_THEME_KEY) {
     const resolvedKey = isDark ? 'dark' : 'light';
     return {
       theme: registry[resolvedKey]?.theme ?? registry['light']?.theme ?? light,
       resolvedKey,
     };
   }
+  const resolvedKey = registry[activeKey] ? activeKey : 'light';
   return {
-    theme: registry[activeKey]?.theme ?? registry['light']?.theme ?? light,
-    resolvedKey: activeKey,
+    theme: registry[resolvedKey]?.theme ?? registry['light']?.theme ?? light,
+    resolvedKey,
   };
 };
🤖 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 `@src/components/ThemeProvider/ThemeProvider.hooks.ts` around lines 48 - 50,
The return currently picks theme from registry[activeKey] but always returns
resolvedKey = activeKey, which can be invalid; change the logic so both theme
and resolvedKey come from the same fallback path: compute a resolvedKey variable
(e.g., resolvedKey = registry[activeKey] ? activeKey : 'light') and then return
theme = registry[resolvedKey]?.theme ?? light and resolvedKey = resolvedKey so
ThemeProvider.tsx writes a matching data-theme; update the code in
ThemeProvider.hooks.ts where registry, activeKey and resolvedKey are used
accordingly.
🤖 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 `@src/components/ThemeProvider/ThemeProvider.hooks.ts`:
- Around line 30-32: Replace the direct useLayoutEffect inside the
useResolvedTheme hook with the SSR-safe isomorphic layout effect used elsewhere:
import and use the same isomorphic helper (the one used by ThemeProvider.tsx,
e.g. useIsomorphicLayoutEffect) instead of React's useLayoutEffect around the
setIsDark(window.matchMedia(...).matches) call so the effect runs client-side
without causing SSR warnings; update the import and call in useResolvedTheme
accordingly, keeping the setIsDark and media query logic unchanged.

In `@src/components/ThemeProvider/ThemeProvider.store.ts`:
- Around line 40-43: The code silently falls back to light when a provided
parentKey is missing; update the themeStore.setState callback (where parentKey,
key, deepmerge, makeEntry and light are used) to detect when parentKey is
non-empty but state[parentKey] is undefined and then surface that (either throw
a clear Error mentioning parentKey and key or at minimum console.warn/log with
the missing parentKey and affected key) instead of silently using light; ensure
the check runs before computing parent and merged so typos/missing parents are
obvious during development.
- Around line 35-43: registerTheme currently accepts arbitrary keys that are
later interpolated into the CSS selector in getThemeStyleSheet, allowing
characters like " or ] or newlines to break the stylesheet; fix by validating
and/or escaping keys: in registerTheme validate the incoming key against a safe
pattern (e.g. allow only alphanumerics, hyphen, underscore) and throw a clear
error for invalid keys, or normalize/sanitize the key before storing it in
themeStore (reference registerTheme, themeStore, makeEntry); additionally, to be
defensive update getThemeStyleSheet to use a CSS-safe escaping function when
serializing the key into :root[data-theme="..."] so any future callers cannot
inject invalid selectors (reference getThemeStyleSheet).
- Around line 103-132: The inline script built by injectThemePreflight embeds
defaults.dark and defaults.light directly into single-quoted JS which can be
broken by quotes, backslashes, newlines or a `</script>` sequence; update
injectThemePreflight to serialize and escape these values before interpolation
(e.g. use a JS string literal serializer like JSON.stringify or a small escape
helper on defaults?.dark and defaults?.light), then also ensure the resulting
string replaces `</script>` with `<\/script>` to avoid terminating the tag
early; keep references to SYSTEM_THEME_KEY and THEME_STORAGE_KEY unchanged and
interpolate the sanitized values instead of raw defaults.dark/defaults.light.

In `@src/components/ThemeProvider/ThemeProvider.tsx`:
- Around line 50-55: The handleSetTheme function currently calls
localStorage.setItem without guarding against exceptions; wrap the setItem call
in a try/catch inside handleSetTheme (while preserving the forcedTheme check) so
that any storage errors (e.g., QuotaExceededError or Safari private mode) are
swallowed/logged but do not prevent calling setKey(newKey); use
THEME_STORAGE_KEY for the key and ensure setKey(newKey) always runs when not
forcedTheme even if setItem throws.

---

Outside diff comments:
In `@src/components/ThemeProvider/ThemeProvider.hooks.ts`:
- Around line 48-50: The return currently picks theme from registry[activeKey]
but always returns resolvedKey = activeKey, which can be invalid; change the
logic so both theme and resolvedKey come from the same fallback path: compute a
resolvedKey variable (e.g., resolvedKey = registry[activeKey] ? activeKey :
'light') and then return theme = registry[resolvedKey]?.theme ?? light and
resolvedKey = resolvedKey so ThemeProvider.tsx writes a matching data-theme;
update the code in ThemeProvider.hooks.ts where registry, activeKey and
resolvedKey are used accordingly.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: eef8ffa3-c33a-42d0-a5f2-860485b68504

📥 Commits

Reviewing files that changed from the base of the PR and between 25009ca and 65ed32c.

📒 Files selected for processing (6)
  • src/components/ThemeProvider/README.md
  • src/components/ThemeProvider/ThemeProvider.constants.ts
  • src/components/ThemeProvider/ThemeProvider.hooks.ts
  • src/components/ThemeProvider/ThemeProvider.store.ts
  • src/components/ThemeProvider/ThemeProvider.tsx
  • src/components/ThemeProvider/index.ts

Comment thread src/components/ThemeProvider/ThemeProvider.hooks.ts Outdated
Comment thread src/components/ThemeProvider/ThemeProvider.store.ts
Comment thread src/components/ThemeProvider/ThemeProvider.store.ts
Comment thread src/components/ThemeProvider/ThemeProvider.store.ts
Comment thread src/components/ThemeProvider/ThemeProvider.tsx
@ianpaschal ianpaschal merged commit 39a0e1d into master Jun 3, 2026
2 checks passed
@ianpaschal ianpaschal deleted the rework-theme-handling branch June 3, 2026 10:47
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