feat(238): fix light/dark theme toggle with ThemeProvider cascade - #242
Conversation
- 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>
🏗️ PR Added to Squad Triage QueueThis PR has been labeled with Next steps:
|
Test Results Summary283 tests +10 282 ✅ +9 18s ⏱️ +2s 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.♻️ 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>
There was a problem hiding this comment.
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
ThemeProviderto wrap<Router>inRoutes.razorand remove@rendermodefromNavMenu.razorto avoid cascade isolation. - Improve theme bootstrap + testing reliability (anti-FOUC head script adjustments,
themeManager.markInitialized()readiness marker, static web assets enabled forTestingenvironment). - 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. |
| 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); | ||
| } |
| [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"); |
| // 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"); | ||
| } |
| // 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; | ||
| } |
| 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 Report❌ Patch coverage is
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
🚀 New features to boost your workflow:
|
|
⛔ PR gate result: not approved for merge. I found a real blocker in Follow-up artifacts to fix in the same cycle:
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 |
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>
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
ThemeProviderwas placed in or nearApp.razor, which runs in the static pre-render boundary. TheThemeSelectorinsideNavMenucould 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 includingNavMenu → ThemeSelectorNavMenu.razor— Contains<ThemeSelector />in both desktop and mobile nav; no@rendermodedeclaration (which would re-create an isolating boundary)ThemeProvider.razor.cs— ProperJSException/JSDisconnectedExceptionhandling; callsthemeManager.markInitialized()to setdata-theme-readyreadiness markerApp.razor— Anti-FOUC IIFE in<head>reads split keystheme-color/theme-mode, applies.dark+color-{name}classes, includes migration shim for old unified keyTests
ThemeRenderBoundaryTests.cs(new) — Architecture tests enforcing ThemeProvider placement, App.razor exclusion, NavMenu rendermode absenceThemeToggleInteractionTests.cs(new) — Playwright E2E test for dark mode persistence across navigationLayoutThemeToggleTests.cs(new) — Playwright E2E test (intentionally skipped due to reload race condition; runtime coverage in interaction tests)Test Results
Closes #238