Skip to content

feat(238): fix light/dark theme toggle with ThemeProvider cascade - #242

Merged
mpaulosky merged 8 commits into
devfrom
squad/238-fix-light-dark-theme-toggle
May 7, 2026
Merged

feat(238): fix light/dark theme toggle with ThemeProvider cascade#242
mpaulosky merged 8 commits into
devfrom
squad/238-fix-light-dark-theme-toggle

Conversation

@mpaulosky

Copy link
Copy Markdown
Owner

Summary

Fixes the inert light/dark theme toggle by correcting the Blazor render-boundary placement of ThemeProvider.

Working as Legolas (Blazor/Frontend agent)

Root Cause

ThemeProvider was placed in or near App.razor, which runs in the static pre-render boundary. The ThemeSelector inside NavMenu could not receive the cascade across the render-mode fence, making the toggle inert.

Changes

Core fix

  • Routes.razor — Wraps <Router> inside <ThemeProvider> so the cascade is available to the entire interactive subtree including NavMenu → ThemeSelector
  • NavMenu.razor — Contains <ThemeSelector /> in both desktop and mobile nav; no @rendermode declaration (which would re-create an isolating boundary)
  • ThemeProvider.razor.cs — Proper JSException/JSDisconnectedException handling; calls themeManager.markInitialized() to set data-theme-ready readiness marker
  • App.razor — Anti-FOUC IIFE in <head> reads split keys theme-color/theme-mode, applies .dark + color-{name} classes, includes migration shim for old unified key

Tests

  • ThemeRenderBoundaryTests.cs (new) — Architecture tests enforcing ThemeProvider placement, App.razor exclusion, NavMenu rendermode absence
  • ThemeToggleInteractionTests.cs (new) — Playwright E2E test for dark mode persistence across navigation
  • LayoutThemeToggleTests.cs (new) — Playwright E2E test (intentionally skipped due to reload race condition; runtime coverage in interaction tests)

Test Results

Suite Passed
Architecture.Tests 15/15 ✅
Web.Tests.Bunit 65/65 ✅
Domain.Tests 42/42 ✅
Web.Tests 127/127 ✅
Web.Tests.Integration 12/12 ✅

Closes #238

Boromir and others added 2 commits May 7, 2026 15:19
- Anti-FOUC IIFE in App.razor head reads theme-color/theme-mode split keys
- themeManager.setBrightness() toggles .dark class on <html> for Tailwind dark variants
- ThemeProvider cascades in Routes.razor wrapping Router (not App.razor)
- ThemeSelector wired via ThemeProvider cascade to ThemeBrightnessToggleComponent
- NavMenu contains ThemeSelector without render mode isolation
- ThemeProvider.OnAfterRenderAsync handles JSException/JSDisconnectedException cleanly
- Architecture tests enforce structural requirements (ThemeRenderBoundaryTests)
- Playwright E2E coverage for toggle click and dark mode navigation persistence
- Squad agent history files updated

Working as Legolas (Blazor/Frontend agent)
Closes #238

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- ThemeProvider: register themeManager service, fix JSInterop wiring, add
  markInitialized readiness marker (data-theme-ready attribute)
- Routes.razor: wrap Router in <ThemeProvider> to establish cascading context
- App.razor: remove inline theme script, rely on ThemeProvider cascade
- NavMenu.razor: use ThemeSelector in both desktop and mobile nav slots
- Program.cs: register IThemeManager DI dependency

Test coverage (Gimli):
- ThemeProviderTests: add SetColor->CurrentColor update test + markInitialized test
- ThemeSelectorTests: add full cascade integration tests (brightness + color)
- ThemeRenderBoundaryTests: add NavMenu ThemeSelector presence guard
- Remove stale 'scaffolded ahead' notes from ThemeProvider/ThemeSelectorTests
- AppHost: add LayoutThemeToggleTests + ThemeToggleInteractionTests

Final: 65 bUnit tests (+4), 15 arch tests (+1), all green

Closes #238

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 7, 2026 22:20
@github-actions github-actions Bot added the squad Squad triage inbox — Lead will assign to a member label May 7, 2026
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

🏗️ PR Added to Squad Triage Queue

This PR has been labeled with squad and added to the triage queue.

Next steps:

  • The squad Lead will review and assign to an appropriate team member
  • A squad:member label will be added after triage

If you know which squad member should handle this, you can add the appropriate squad:member label yourself.

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Test Results Summary

283 tests  +10   282 ✅ +9   18s ⏱️ +2s
  6 suites ± 0     1 💤 +1 
  6 files   ± 0     0 ❌ ±0 

Results for commit 2939a51. ± Comparison against base commit 7e95ff8.

This pull request removes 6 and adds 16 tests. Note that renamed tests count towards both.
AppHost.Tests.LayoutAnonymousTests ‑ Layout ColorSchemeButton IsVisible
AppHost.Tests.LayoutAnonymousTests ‑ Layout Footer ShowsCopyrightText
AppHost.Tests.LayoutAnonymousTests ‑ Layout Header ShowsBrandLink
AppHost.Tests.LayoutAnonymousTests ‑ Layout Header ShowsLoginLinkWhenNotAuthenticated
AppHost.Tests.LayoutAnonymousTests ‑ Layout NavMenu AuthLinksAreHiddenWhenNotAuthenticated
AppHost.Tests.LayoutAnonymousTests ‑ Layout ThemeToggleButton IsVisible
AppHost.Tests.Tests.Layout.LayoutAnonymousTests ‑ Layout ColorSchemeButton IsVisible
AppHost.Tests.Tests.Layout.LayoutAnonymousTests ‑ Layout Footer ShowsCopyrightText
AppHost.Tests.Tests.Layout.LayoutAnonymousTests ‑ Layout Header ShowsBrandLink
AppHost.Tests.Tests.Layout.LayoutAnonymousTests ‑ Layout Header ShowsLoginLinkWhenNotAuthenticated
AppHost.Tests.Tests.Layout.LayoutAnonymousTests ‑ Layout NavMenu AuthLinksAreHiddenWhenNotAuthenticated
AppHost.Tests.Tests.Layout.LayoutAnonymousTests ‑ Layout ThemeToggleButton IsVisible
AppHost.Tests.Tests.Layout.LayoutThemeToggleTests ‑ ThemeToggle ClickingSwitchesBrightnessAndHtmlDarkClass
AppHost.Tests.Tests.Layout.ThemeToggleInteractionTests ‑ ThemeToggle DarkMode PersistsAfterNavigatingToBlogPosts
MyBlog.Architecture.Tests.ThemeRenderBoundaryTests ‑ AppShouldRenderInteractiveRoutesWithoutWrappingThemeProvider
MyBlog.Architecture.Tests.ThemeRenderBoundaryTests ‑ NavMenuShouldContainThemeSelectorElement
…

♻️ This comment has been updated with latest results.

…plete

Captured work completed by Legolas (frontend implementation) and Gimli (test
finalization) on issue #238 — light/dark theme toggle fix.

- Added orchestration logs for both agents (2 files)
- Added session log: issue-238-theme-toggle
- Merged 3 decision inbox files into decisions.md as decisions 16-18:
  - Legolas: ThemeProvider placement decision
  - Boromir: AppHost static web assets
  - Gimli: Dynamic skip runtime probe pattern
- Deleted merged inbox files
- Updated agent history files with session session entries

**Legolas Work:** Moved ThemeProvider from App.razor to Routes.razor,
restoring cascading context across render-mode boundary. Theme toggle now
interactive in live app.

**Gimli Work:** Added cascade integration tests + markInitialized readiness
marker. Designed dynamic-skip runtime probe for AppHost Testing environment.
Architecture tests lock down structural regressions.

**Decisions Captured:** Placement rules, AppHost infrastructure, runtime testing
strategy for harness evolution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes the inert light/dark theme toggle by ensuring ThemeProvider is inside the interactive render subtree (wrapping the Router in Routes.razor) so cascaded theme state reaches NavMenu → ThemeSelector, and adds regression coverage across architecture, bUnit, and AppHost/Playwright tests.

Changes:

  • Move ThemeProvider to wrap <Router> in Routes.razor and remove @rendermode from NavMenu.razor to avoid cascade isolation.
  • Improve theme bootstrap + testing reliability (anti-FOUC head script adjustments, themeManager.markInitialized() readiness marker, static web assets enabled for Testing environment).
  • Add/extend architecture + bUnit + AppHost tests to guard render-boundary placement and theme toggle persistence.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/Web.Tests.Bunit/Components/Theme/ThemeSelectorTests.cs Adds bUnit integration assertions that ThemeSelector interactions propagate back to ThemeProvider state.
tests/Web.Tests.Bunit/Components/Theme/ThemeProviderTests.cs Adds bUnit coverage for ThemeProvider state updates and readiness marker invocation.
tests/Architecture.Tests/ThemeRenderBoundaryTests.cs Adds architecture-level guards to prevent render-boundary regressions (ThemeProvider placement, App.razor exclusion, NavMenu render-mode absence).
tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs Adds Playwright E2E coverage for dark mode persistence across navigation with strong runtime diagnostics + dynamic skips.
tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs Adds (skipped) Playwright test documenting the reload/bootstrap race in the AppHost harness.
tests/AppHost.Tests/Tests/Layout/LayoutAnonymousTests.cs Aligns namespace/organization and adds visibility assertions for theme controls.
tests/AppHost.Tests/Infrastructure/AspireManager.cs Makes fixed HTTPS port opt-in (env var) and improves AppHost startup logging/behavior.
tests/AppHost.Tests/Infrastructure/AppHostTestCollection.cs Updates collection documentation to reflect stable shared-host execution model.
tests/AppHost.Tests/BasePlaywrightTests.cs Minor formatting update.
src/Web/Program.cs Enables UseStaticWebAssets() when running under Testing to ensure Blazor/static assets load in AppHost runs.
src/Web/Components/Theme/ThemeProvider.razor.cs Refines JS exception handling and adds themeManager.markInitialized() readiness call.
src/Web/Components/Routes.razor Wraps the Router with ThemeProvider to keep cascades within the interactive subtree.
src/Web/Components/Layout/NavMenu.razor Removes @rendermode boundary and ensures ThemeSelector is present in desktop/mobile nav.
src/Web/Components/App.razor Updates anti-FOUC bootstrap script and fixes scoped CSS asset reference.
.squad/agents/legolas/history.md Captures learnings and decisions from the render-boundary fix.
.squad/agents/gimli/history.md Captures test-coverage learnings and AppHost runtime behavior notes.
.squad/agents/frodo/history.md Adds unrelated PR #241 follow-up notes.
.squad/agents/boromir/history.md Documents AppHost harness root cause/fix details (static web assets + CSS bundle name).
.squad/agents/aragorn/history.md Adds unrelated PR #241 notes and follow-up history.

Comment on lines 24 to 36
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;

try
{
CurrentColor = await Js.InvokeAsync<string>("themeManager.getColor");
}
catch
if (!firstRender)
{
// Keep default if localStorage is unavailable
return;
}

try
{
CurrentBrightness = await Js.InvokeAsync<string>("themeManager.getBrightness");
}
catch
{
// Keep default if localStorage is unavailable
}
CurrentColor = await GetStoredThemeValueAsync("themeManager.getColor", CurrentColor).ConfigureAwait(false);
CurrentBrightness = await GetStoredThemeValueAsync("themeManager.getBrightness", CurrentBrightness).ConfigureAwait(false);

StateHasChanged();
await TryMarkInitializedAsync().ConfigureAwait(false);
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
Comment on lines +143 to +158
[Fact]
public void ThemeProviderSetColorUpdatesCurrentColorAfterJsCall()
{
// Arrange
JSInterop.Setup<string>("themeManager.getColor").SetResult("blue");
JSInterop.Setup<string>("themeManager.getBrightness").SetResult("light");
JSInterop.SetupVoid("themeManager.setColor", "red");

var cut = Render<ThemeProvider>();

// Act
cut.InvokeAsync(() => cut.Instance.SetColor("red"));

// Assert
cut.WaitForAssertion(() => cut.Instance.CurrentColor.Should().Be("red"));
}
themeProviderStart.Should().BeGreaterThanOrEqualTo(0,
because: "ThemeProvider must exist in Routes.razor so it shares the interactive subtree with ThemeSelector");
routerStart.Should().BeGreaterThan(themeProviderStart,
because: "ThemeProvider should wrap the router, not sit outside the interactive routes boundary");
Comment on lines +41 to +46
// Act / Assert
appMarkup.Should().Contain("<Routes @rendermode=\"InteractiveServer\" />",
because: "the interactive routes boundary must remain in App.razor");
appMarkup.Should().NotContain("<ThemeProvider>",
because: "ThemeProvider belongs in Routes.razor, not outside the interactive routes boundary in App.razor");
}
Comment on lines +82 to +96
// MutationObserver: if Blazor's DOM reconciliation strips the theme class
// from <html>, re-apply immediately from localStorage.
if (window._themeObserver) {
window._themeObserver.disconnect();
}
var _reapplying = false;
window._themeObserver = new MutationObserver(function () {
if (_reapplying) return;
var html = document.documentElement;
var hasColor = VALID_COLORS.some(function (c) { return html.classList.contains("color-" + c); });
if (!hasColor) {
_reapplying = true;
applyThemeFromStorage();
_reapplying = false;
}
Comment on lines +15 to +57
function applyThemeFromStorage() {
var color = localStorage.getItem(COLOR_KEY) || "blue";
if (VALID_COLORS.indexOf(color) === -1) color = "blue";

var brightness = localStorage.getItem(BRIGHTNESS_KEY);
if (!brightness) {
// Default to system preference
brightness = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (VALID_BRIGHTNESS.indexOf(brightness) === -1) brightness = "light";
var brightness = localStorage.getItem(BRIGHTNESS_KEY);
if (!brightness) {
// Default to system preference
brightness = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (VALID_BRIGHTNESS.indexOf(brightness) === -1) brightness = "light";

var html = document.documentElement;
var html = document.documentElement;

// Remove all color classes
VALID_COLORS.forEach(function (c) { html.classList.remove("color-" + c); });
// Remove all color classes
VALID_COLORS.forEach(function (c) { html.classList.remove("color-" + c); });

// Add selected color class
html.classList.add("color-" + color);
// Add selected color class
html.classList.add("color-" + color);

// Toggle dark class for Tailwind dark: variant
if (brightness === "dark") {
html.classList.add("dark");
} else {
html.classList.remove("dark");
}
}
// Toggle dark class for Tailwind dark: variant
if (brightness === "dark") {
html.classList.add("dark");
} else {
html.classList.remove("dark");
}
}

// Migrate old unified key (theme-blue-dark) → new split keys
var oldKey = "tailwind-color-theme";
if (localStorage.getItem(oldKey) && !localStorage.getItem(COLOR_KEY)) {
var oldTheme = localStorage.getItem(oldKey);
var parts = oldTheme.replace("theme-", "").split("-");
if (parts.length === 2) {
var oldColor = parts[0];
var oldBrightness = parts[1];
if (VALID_COLORS.indexOf(oldColor) !== -1) {
localStorage.setItem(COLOR_KEY, oldColor);
}
if (VALID_BRIGHTNESS.indexOf(oldBrightness) !== -1) {
localStorage.setItem(BRIGHTNESS_KEY, oldBrightness);
}
}
}
// Migrate old unified key (theme-blue-dark) → new split keys
var oldKey = "tailwind-color-theme";
if (localStorage.getItem(oldKey) && !localStorage.getItem(COLOR_KEY)) {
var oldTheme = localStorage.getItem(oldKey);
var parts = oldTheme.replace("theme-", "").split("-");
if (parts.length === 2) {
var oldColor = parts[0];
var oldBrightness = parts[1];
if (VALID_COLORS.indexOf(oldColor) !== -1) {
localStorage.setItem(COLOR_KEY, oldColor);
}
if (VALID_BRIGHTNESS.indexOf(oldBrightness) !== -1) {
localStorage.setItem(BRIGHTNESS_KEY, oldBrightness);
}
}
}
@codecov

codecov Bot commented May 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.00000% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.35%. Comparing base (7e95ff8) to head (2939a51).

Files with missing lines Patch % Lines
src/Web/Components/Theme/ThemeProvider.razor.cs 68.42% 6 Missing ⚠️
src/Web/Components/Routes.razor 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #242      +/-   ##
==========================================
- Coverage   78.64%   78.35%   -0.29%     
==========================================
  Files          43       43              
  Lines         721      730       +9     
  Branches      112      112              
==========================================
+ Hits          567      572       +5     
- Misses        108      112       +4     
  Partials       46       46              
Files with missing lines Coverage Δ
src/Web/Components/Layout/NavMenu.razor 75.00% <ø> (ø)
src/Web/Components/Routes.razor 0.00% <0.00%> (ø)
src/Web/Components/Theme/ThemeProvider.razor.cs 81.25% <68.42%> (-10.06%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mpaulosky

Copy link
Copy Markdown
Owner Author

PR gate result: not approved for merge.

I found a real blocker in src/Web/Components/Theme/ThemeProvider.razor.cs: the new ConfigureAwait(false) usage in the Blazor component lifecycle path moves cascaded state mutation off the renderer sync context. That is not safe for this interactive server component.

Follow-up artifacts to fix in the same cycle:

  • tests/Architecture.Tests/ThemeRenderBoundaryTests.cs must assert both closing tags exist before comparing order so the regression guard cannot false-pass.
  • tests/Web.Tests.Bunit/Components/Theme/ThemeProviderTests.cs should await the new InvokeAsync(...) calls so async failures surface reliably.

Per lockout, the PR author is out for this revision cycle. Routing fix follow-up to Sam for the C# lifecycle/test hardening pass on squad/238-fix-light-dark-theme-toggle.

Boromir and others added 3 commits May 7, 2026 15:50
Merge decision inbox (aragorn-precommit-gate) into decisions.md.
Update Legolas and Gimli history with session work on #239.

- Legolas: Fixed theme color selector persistence by moving ThemeProvider into interactive render boundary (Routes.razor)
- Gimli: Added comprehensive test coverage for persistence (264 lines of tests across 2 files)
- Fix: Improved ThemeColorDropdownComponent binding and error handling
- Bonus: Pinned Snappier 1.3.1 for security (NU1903 / GHSA-pggp-6c3x-2xmx)

Test results: Architecture 15/15 ✅ | bUnit 87/87 ✅ | Domain 42/42 ✅
PR #243 opened and ready for review.

Closes #239 (pending PR merge)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…component lifecycle, harden arch-test false-pass guard, await InvokeAsync in bUnit tests

1. ThemeProvider.razor.cs — strip all ConfigureAwait(false) so state mutations
   and StateHasChanged remain on the Blazor renderer sync context (Blazor Server safe).

2. ThemeRenderBoundaryTests.cs — RoutesShouldWrapRouterInsideThemeProvider now
   explicitly asserts routerStart, routerEnd, and themeProviderEnd are all >= 0
   before comparing order; prevents a false-pass when either closing tag is absent.

3. ThemeProviderTests.cs — SetColor/SetBrightness test methods converted to
   async Task and their cut.InvokeAsync(...) calls are now properly awaited so
   async failures surface instead of being silently discarded. SetupVoid handlers
   gain .SetVoidResult() to resolve the handlers under strict-enough bUnit rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mpaulosky
mpaulosky merged commit 945d65a into dev May 7, 2026
18 checks passed
@mpaulosky
mpaulosky deleted the squad/238-fix-light-dark-theme-toggle branch May 7, 2026 23:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

squad Squad triage inbox — Lead will assign to a member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Sprint 14] Fix light/dark theme toggle

2 participants