Skip to content

🐛 fix(dark-mode): defer media query initialization to afterNextRenderer for SSR#446

Merged
Luizgomess merged 2 commits intomasterfrom
fix/dark-mode-ssr-friendly
Feb 23, 2026
Merged

🐛 fix(dark-mode): defer media query initialization to afterNextRenderer for SSR#446
Luizgomess merged 2 commits intomasterfrom
fix/dark-mode-ssr-friendly

Conversation

@mikij
Copy link
Contributor

@mikij mikij commented Feb 22, 2026

What was done? 📝

The dark mode service now uses afterNextRender to defer browser-only media query initialization, making it compatible with Angular SSR without requiring ngSkipHydration.

Screenshots or GIFs 📸

|-----Figma-----|
|-----Implementation-----|

Link to Issue 🔗

Type of change 🏗

  • New feature (non-breaking change that adds functionality)
  • Bug fix (non-breaking change that fixes an issue)
  • Refactor (non-breaking change that improves the code or technical debt)
  • Chore (none of the above, such as upgrading libraries)

Breaking change 🚨

Checklist 🧐

  • Tested on Chrome
  • Tested on Safari
  • Tested on Firefox
  • No errors in the console

Summary by CodeRabbit

  • New Features
    • Adds a derived "theme mode" (light/dark/system) for UI use.
  • Bug Fixes
    • Improves dark-mode initialization with lazy media-query setup, safer handling of system preference changes, and more robust listener/cleanup behavior for SSR/tests.
  • Chores
    • Removes a hydration-skip directive from the dark-mode page.

@coderabbitai
Copy link

coderabbitai bot commented Feb 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Removed ngSkipHydration host metadata from DarkmodePage. ZardDarkMode service now lazily initializes its MediaQueryList via injected MediaMatcher, defers setup with afterNextRender (and an explicit init()), introduces a systemDark signal and a public computed themeMode, and reworks theme evaluation and cleanup.

Changes

Cohort / File(s) Summary
Dark Mode Component
apps/web/src/app/domain/pages/dark-mode/dark-mode.page.ts
Removed host: { ngSkipHydration: '' } from the @Component decorator (component no longer marks itself to skip hydration).
Dark Mode Service
libs/zard/src/lib/shared/services/dark-mode.ts
Replaced eager MediaQueryList setup with lazy initialization via injected MediaMatcher; added ensureQueryInitialized() and deferred init() using afterNextRender; added systemDark signal and public computed themeMode; updated handleThemeChange to reconcile system preference and theme signal; improved query error messaging, listener management, and teardown.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(200,220,255,0.5)
    participant Page as DarkmodePage (Component)
  end
  rect rgba(200,255,200,0.5)
    participant Service as ZardDarkMode (Service)
  end
  rect rgba(255,240,200,0.5)
    participant Matcher as MediaMatcher / MQL (Browser)
  end

  Page->>Service: construct / first render -> call init() or rely on afterNextRender
  Service-->>Service: afterNextRender -> ensureQueryInitialized()
  Service->>Matcher: create MediaQueryList("prefers-color-scheme: dark")
  Matcher-->>Service: initial matches -> set systemDark signal
  Matcher->>Service: change events -> handleThemeChange() -> update systemDark / themeMode
  Service->>Page: computed themeMode changes -> applyTheme() -> update DOM (.dark) / bindings
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested reviewers

  • Luizgomess
  • srizzon
  • ribeiromatheuss

Poem

🌙 ngSkipHydration slipped away,
Queries wait until the day,
Signals hum and modes align,
afterNextRender bides its time,
Dark and light in tidy rhyme ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly describes the main fix: deferring media query initialization for SSR compatibility via afterNextRenderer, which aligns with the primary changes in the dark-mode service.
Description check ✅ Passed Description covers the 'What was done' section, identifies it as a bug fix, includes testing checklist with partial results, but lacks issue link and referenced screenshots/GIFs are incomplete.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/dark-mode-ssr-friendly

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

@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

Caution

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

⚠️ Outside diff range comments (2)
libs/zard/src/lib/shared/services/dark-mode.ts (2)

27-46: 🧹 Nitpick | 🔵 Trivial

Redundant DOM update during initialization.

The effect (lines 29–36) fires after initializeTheme() completes because both initialized and themeSignal change. But initializeTheme() already calls updateThemeMode() directly at line 109. This means on first initialization the DOM toggle happens twice — once explicitly, then again via the effect.

Consider either removing the direct updateThemeMode call at line 109 (letting the effect handle it), or moving the initialized.set(true) to the very end of initializeTheme() after the explicit update, and restructuring so the effect only reacts to subsequent themeSignal changes. The cleanest approach is to drop lines 108-109 entirely and let the effect be the single source of truth for DOM updates.

♻️ Suggested simplification in initializeTheme()
   private initializeTheme(): void {
     const storedTheme = this.getStoredTheme();
     if (storedTheme) {
       this.themeSignal.set(storedTheme);
     }

     if (!storedTheme || storedTheme === EDarkModes.SYSTEM) {
       this.handleSystemChanges();
     }
     this.initialized.set(true);
-
-    const theme = this.themeSignal();
-    this.updateThemeMode(this.isDarkModeActive(theme), theme);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/zard/src/lib/shared/services/dark-mode.ts` around lines 27 - 46, The
initialization currently triggers updateThemeMode twice (once directly in
initializeTheme() and again via the reactive effect), so remove the explicit DOM
update inside initializeTheme(): delete the direct call to updateThemeMode(...)
in initializeTheme() and keep setting initialized(true) there so the existing
effect (effect(() => { if (!this.initialized()) return; const theme =
this.themeSignal(); ... this.updateThemeMode(...) })) becomes the single source
of truth for applying the theme; locate initializeTheme(), updateThemeMode(),
initialized, themeSignal, and effect to make this change and ensure
afterNextRender/handleSystemChanges logic remains unchanged.

49-55: ⚠️ Potential issue | 🔴 Critical

Guard isDarkModeActive() against uninitialized media query to prevent throws on early toggleTheme() calls.

During the first render on browser, themeMode() can throw if accessed before afterNextRender completes. Since themeMode() is read at line 76 by toggleTheme(), early user interactions or pre-initialization access will hit: themeMode()isDarkModeActive(SYSTEM)this.query.matches → throws because _query is undefined.

Recommended fix
   private isDarkModeActive(currentTheme: DarkModeOptions): boolean {
     if (!this.isBrowser) {
       return false;
     }
-
-    return currentTheme === EDarkModes.DARK || (currentTheme === EDarkModes.SYSTEM && this.query.matches);
+    if (!this._query) {
+      return false;
+    }
+    return currentTheme === EDarkModes.DARK || (currentTheme === EDarkModes.SYSTEM && this._query.matches);
   }

This prevents the throwing getter access and safely defaults to light mode (consistent with SSR) until initialization completes.

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

In `@libs/zard/src/lib/shared/services/dark-mode.ts` around lines 49 - 55,
themeMode's computed getter can throw because isDarkModeActive reads the
uninitialized media query; update the guard so isDarkModeActive returns false
(i.e., treat as light) when the media-query object is not yet initialized
instead of accessing its .matches. Specifically, in the isDarkModeActive method
(and/or the internal _query accessor used by it), check for the presence of the
media-query object (e.g., this._query or this.query) and return false if
missing; this prevents themeMode() and toggleTheme() from throwing during early
calls before afterNextRender initializes the query.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 57-66: The duplicated setup of the media query and onDestroy
handler in init() and afterNextRender() should be extracted into a private
helper to avoid repetition: create a private method (e.g. ensureQueryInitialized
or setupQuery) that checks this._query, assigns this._query =
this.mediaMatcher.matchMedia('(prefers-color-scheme: dark)') and registers
this.destroyRef.onDestroy(() => this.handleSystemChanges(false)); then replace
the duplicated blocks in init() and afterNextRender() with a call to that new
method so _query and destroyRef wiring live in one place.

---

Outside diff comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 27-46: The initialization currently triggers updateThemeMode twice
(once directly in initializeTheme() and again via the reactive effect), so
remove the explicit DOM update inside initializeTheme(): delete the direct call
to updateThemeMode(...) in initializeTheme() and keep setting initialized(true)
there so the existing effect (effect(() => { if (!this.initialized()) return;
const theme = this.themeSignal(); ... this.updateThemeMode(...) })) becomes the
single source of truth for applying the theme; locate initializeTheme(),
updateThemeMode(), initialized, themeSignal, and effect to make this change and
ensure afterNextRender/handleSystemChanges logic remains unchanged.
- Around line 49-55: themeMode's computed getter can throw because
isDarkModeActive reads the uninitialized media query; update the guard so
isDarkModeActive returns false (i.e., treat as light) when the media-query
object is not yet initialized instead of accessing its .matches. Specifically,
in the isDarkModeActive method (and/or the internal _query accessor used by it),
check for the presence of the media-query object (e.g., this._query or
this.query) and return false if missing; this prevents themeMode() and
toggleTheme() from throwing during early calls before afterNextRender
initializes the query.

@mikij mikij force-pushed the fix/dark-mode-ssr-friendly branch from 9c0ca39 to 3dfed2f Compare February 22, 2026 19:58
Copy link

@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

Caution

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

⚠️ Outside diff range comments (1)
libs/zard/src/lib/shared/services/dark-mode.ts (1)

91-95: ⚠️ Potential issue | 🟡 Minor

Misleading error message — "on server" but also throws in the browser before initialization.

The message 'Cannot access media query on server' conflates two distinct cases. After the themeMode bug is fixed (no longer accessing query from the computed), this getter should only be reachable post-init in the browser, but the message still misguides anyone who hits it during development.

🔧 Suggested fix
-    throw new Error('Cannot access media query on server');
+    throw new Error('MediaQueryList is not initialized. Ensure ensureQueryInitialized() has been called before accessing the query.');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/zard/src/lib/shared/services/dark-mode.ts` around lines 91 - 95, The
getter query currently throws a misleading message ('Cannot access media query
on server') when either running on server or when the service hasn't been
initialized; update the thrown Error in the query getter to a clear message such
as "MediaQueryList not available: either running on server or not initialized"
(or similar) and ensure the conditional still checks both this.isBrowser and
this._query so callers of the query getter (referenced as the query getter and
the private _query field) get an accurate error indicating either server context
or missing initialization.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 26-32: themeMode's computed currently calls
isDarkModeActive(SYSTEM) which accesses this.query (backed by _query) before
ensureQueryInitialized() runs, and it doesn't react to OS changes because
query.matches isn't tracked; fix by adding a new reactive signal (e.g.,
systemDark) initialized/updated in ensureQueryInitialized() and
handleThemeChange(), have isDarkModeActive read systemDark (or consult
systemDark in the computed) instead of accessing this.query directly, ensure
ensureQueryInitialized() creates the MediaQueryList and sets systemDark to its
current matches, and update handleThemeChange() to set systemDark when the OS
preference changes and then call updateThemeMode so themeMode stays correct and
no throw occurs when _query is undefined.

---

Outside diff comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 91-95: The getter query currently throws a misleading message
('Cannot access media query on server') when either running on server or when
the service hasn't been initialized; update the thrown Error in the query getter
to a clear message such as "MediaQueryList not available: either running on
server or not initialized" (or similar) and ensure the conditional still checks
both this.isBrowser and this._query so callers of the query getter (referenced
as the query getter and the private _query field) get an accurate error
indicating either server context or missing initialization.

@mikij mikij force-pushed the fix/dark-mode-ssr-friendly branch from 3dfed2f to 2d3cc72 Compare February 22, 2026 20:22
Copy link

@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

Caution

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

⚠️ Outside diff range comments (1)
libs/zard/src/lib/shared/services/dark-mode.ts (1)

42-49: 🧹 Nitpick | 🔵 Trivial

Consider afterRenderEffect for reactive DOM writes instead of effect.

effect() runs before Angular updates the DOM, so for situations requiring direct DOM inspection or manipulation it's more appropriate to use afterRenderEffect, which functions like effect but runs after Angular has finished rendering and committed its changes.

updateThemeMode writes directly to document.documentElement — a DOM write — making afterRenderEffect the idiomatic Angular v21 alternative:

♻️ Proposed refactor using afterRenderEffect
-      effect(() => {
-        if (!this.initialized()) {
-          return;
-        }
-        const theme = this.themeSignal();
-        const isDarkMode = this.isDarkModeActive(theme);
-        this.updateThemeMode(isDarkMode, theme);
-      });
+      afterRenderEffect({
+        write: () => {
+          if (!this.initialized()) {
+            return;
+          }
+          const theme = this.themeSignal();
+          const isDarkMode = this.isDarkModeActive(theme);
+          this.updateThemeMode(isDarkMode, theme);
+        },
+      });

Note: afterRenderEffect also runs browser-only, so the existing isBrowser guard on the enclosing if block remains correct.

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

In `@libs/zard/src/lib/shared/services/dark-mode.ts` around lines 42 - 49, The
current reactive callback uses effect() but performs DOM writes in
updateThemeMode (which mutates document.documentElement); replace effect with
afterRenderEffect so the callback runs after Angular has rendered. Locate the
effect block that checks this.initialized(), reads this.themeSignal(), calls
this.isDarkModeActive(theme) and then this.updateThemeMode(isDarkMode, theme)
and change it to use afterRenderEffect with the same body, preserving the
existing isBrowser/initialized guards.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 51-57: The afterNextRender call in dark-mode.ts currently uses the
default mixedReadWrite phase; change it to call afterNextRender with the
explicit 'write' phase to avoid layout thrashing—i.e., replace the bare
afterNextRender(() => { ... }) with afterNextRender('write', () => { ... })
around the block that checks this.initialized() and calls
this.ensureQueryInitialized() and this.initializeTheme(), leaving the existing
initialization logic and guards intact.

---

Outside diff comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 42-49: The current reactive callback uses effect() but performs
DOM writes in updateThemeMode (which mutates document.documentElement); replace
effect with afterRenderEffect so the callback runs after Angular has rendered.
Locate the effect block that checks this.initialized(), reads
this.themeSignal(), calls this.isDarkModeActive(theme) and then
this.updateThemeMode(isDarkMode, theme) and change it to use afterRenderEffect
with the same body, preserving the existing isBrowser/initialized guards.

@mikij mikij force-pushed the fix/dark-mode-ssr-friendly branch from 2d3cc72 to 7ef1e72 Compare February 22, 2026 20:52
Copy link

@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

Caution

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

⚠️ Outside diff range comments (1)
libs/zard/src/lib/shared/services/dark-mode.ts (1)

134-141: ⚠️ Potential issue | 🟡 Minor

handleSystemChanges(false) without _query guard logs a misleading warning.

When applyTheme(EDarkModes.LIGHT | EDarkModes.DARK) is called before afterNextRender fires (e.g., toggleTheme triggered programmatically before first render), _query is undefined. Line 139 calls handleSystemChanges(false), which hits the query getter, throws 'MediaQueryList not available…', and the catch at line 180 logs "Failed to manage media query event listener" — a false alarm since no listener was ever registered.

Also, line 136's this._query!.matches is safe post-ensureQueryInitialized(), but unlike the first-init path where ensureQueryInitialized() already seeds systemDark, this line only does real work when _query was already live. A brief comment explaining why both lines 135–136 coexist would aid clarity.

🛡️ Proposed fix — guard `handleSystemChanges` with an early exit
  private handleSystemChanges(addListener = true): void {
+   if (!this._query) {
+     return; // listener never registered; nothing to add or remove
+   }
    try {
      if (addListener) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/zard/src/lib/shared/services/dark-mode.ts` around lines 134 - 141,
applyTheme calls handleSystemChanges(false) when switching away from SYSTEM even
if _query is undefined, causing the query getter to throw and log a misleading
warning; fix by adding a guard in handleSystemChanges (or before calling it)
that returns early if this._query is not set (check this._query or the query
getter safely), and add a short comment near applyTheme (lines around
ensureQueryInitialized, this.systemDark.set(...)) explaining why
ensureQueryInitialized() seeds systemDark on first-init while the subsequent
this._query!.matches path is for the already-initialized case so future calls
can skip initialization.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 21-25: handleThemeChange currently sets this.systemDark and then
calls updateThemeMode directly, causing duplicate DOM updates because the
existing effect() already reacts to systemDark changes via isDarkModeActive;
remove the direct call to this.updateThemeMode(...) from the handleThemeChange
method so it only does this.systemDark.set(event.matches) and lets the effect()
(which reads isDarkModeActive and uses systemDark) perform the DOM update once.

---

Outside diff comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 134-141: applyTheme calls handleSystemChanges(false) when
switching away from SYSTEM even if _query is undefined, causing the query getter
to throw and log a misleading warning; fix by adding a guard in
handleSystemChanges (or before calling it) that returns early if this._query is
not set (check this._query or the query getter safely), and add a short comment
near applyTheme (lines around ensureQueryInitialized, this.systemDark.set(...))
explaining why ensureQueryInitialized() seeds systemDark on first-init while the
subsequent this._query!.matches path is for the already-initialized case so
future calls can skip initialization.

@mikij mikij force-pushed the fix/dark-mode-ssr-friendly branch from 7ef1e72 to f7184be Compare February 22, 2026 21:12
Copy link

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/zard/src/lib/shared/services/dark-mode.ts`:
- Around line 27-33: Add a one-line JSDoc above the readonly computed property
themeMode to clarify its contract: explain that themeMode derives from
themeSignal() and resolves SYSTEM via systemDark(), so it will only ever return
EDarkModes.LIGHT or EDarkModes.DARK (never EDarkModes.SYSTEM). Reference the
computed property name themeMode, the source signal themeSignal(), the enum
EDarkModes, and the resolver method systemDark() in the comment so callers
understand the transformation.
- Around line 129-131: Replace the redundant optional chaining when reading the
MediaQueryList after calling ensureQueryInitialized(): use this._query!.matches
instead of this._query?.matches ?? false to assert the invariant set by
ensureQueryInitialized() (refer to ensureQueryInitialized and _query); also add
a brief clarifying comment on the same line explaining that this assignment to
systemDark (systemDark.set(...)) is only necessary on re-entry when switching
back to EDarkModes.SYSTEM and may look like a duplicate when _query was just
created.

@mikij mikij force-pushed the fix/dark-mode-ssr-friendly branch from f7184be to c351656 Compare February 22, 2026 21:46
@mikij mikij marked this pull request as draft February 22, 2026 22:05
@mikij
Copy link
Contributor Author

mikij commented Feb 22, 2026

waiting for #447 to be merged first!!!

@mikij mikij marked this pull request as ready for review February 23, 2026 10:18
@Luizgomess Luizgomess merged commit ebc4458 into master Feb 23, 2026
10 checks passed
@Luizgomess Luizgomess deleted the fix/dark-mode-ssr-friendly branch February 23, 2026 10:45
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