From c6289571633d55c7d87dff60a97aa61353c33277 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 23:58:26 +0200 Subject: [PATCH] fix: use per-page blazor startup --- README.md | 12 ++++++++++-- RpgRoller.Tests/Api/FrontendHostTests.cs | 2 ++ RpgRoller/Components/App.razor | 13 ++++++++++--- RpgRoller/Components/Pages/AdminPage.razor | 1 + RpgRoller/Components/Pages/CampaignsPage.razor | 1 + RpgRoller/Components/Pages/PlayPage.razor | 1 + RpgRoller/Components/Pages/Workspace.razor.cs | 6 +++--- TASKS.md | 11 +++++++++++ 8 files changed, 39 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 20f7cca..cab8527 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Backend: Frontend: -- `RpgRoller/Components/App.razor`: HTML shell that serves the static `/login` auth document or the interactive route set based on request path +- `RpgRoller/Components/App.razor`: HTML shell that serves the static `/login` auth document or the per-page interactive authenticated route set based on request path - `RpgRoller/Components/Routes.razor`: Blazor router and layout hookup - `RpgRoller/Components/Layout/MainLayout.razor`: default layout - `RpgRoller/Components/Pages/LoginPage.razor`: route marker for the static `/login` auth document @@ -108,6 +108,13 @@ The frontend now uses a route-first authenticated shell that keeps the anonymous Inside the authenticated app, `/play`, `/campaigns`, and `/admin` are real Blazor routes, and the hamburger menu navigates between those URLs. `Workspace.razor` is now a shared shell only. Each authenticated route owns its own main content subtree through a route-specific component. +Authenticated interactivity is route-local instead of global: + +- `App.razor` no longer applies `@rendermode` to `Routes` or `HeadOutlet` +- `PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor` each opt into `InteractiveServerRenderMode(prerender: false)` directly +- Blazor startup is manual with `Blazor.start({ ssr: { disableDomPreservation: true } })` so the app can disable enhanced SSR DOM preservation during interactive attach +- Header route changes now use full document navigation so moving between authenticated routes remounts the target per-page interactive root instead of trying to reuse the previous page circuit + Interactive bootstrap is now route-local: - `WorkspaceRouteView.razor` performs the first-render JS-dependent session initialization for the authenticated route that mounted @@ -190,12 +197,13 @@ SQLite migration rule: ## Frontend Runtime -- The UI runs as Blazor Server for authenticated routes and as plain HTML plus JavaScript for the anonymous `/login` document. +- The UI runs as route-local Blazor Server components for authenticated routes and as plain HTML plus JavaScript for the anonymous `/login` document. - Static assets are linked through Blazor's `@Assets[...]` pipeline for fingerprinted cache-busting URLs. - Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers. - Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`. - Workspace startup diagnostics now log route initialization, route-content render phases, and browser-side workspace mutation snapshots to help isolate the remaining Firefox startup crash documented in `POSTMORTEM.md`. - Pre-Blazor diagnostics also watch the static `#rr-interactive-host` container before `_framework/blazor.web.js` connects, so extension-driven DOM mutations can be compared against the first failing interactive batch. +- Authenticated routes now avoid global `Routes @rendermode` because upstream issue `dotnet/aspnetcore#58824` reports Firefox-specific failures with global interactivity and explicitly calls out per-page mode as the safer path. - Authenticated routes now mount through phased interactive batches: minimal shell first, then a simple header placeholder, then route skeletons, and only then the real header and control-heavy route content after route initialization succeeds. - Live workspace refresh compares separate roster, per-character sheet, and log versions so unrelated changes do not trigger full reloads. - Campaign log data is loaded in bounded slices: campaign summaries, one selected roster, one selected character sheet, and a 25-row incremental log window from `/api/campaigns/{campaignId}/log/page`. diff --git a/RpgRoller.Tests/Api/FrontendHostTests.cs b/RpgRoller.Tests/Api/FrontendHostTests.cs index 1935e1a..7270adf 100644 --- a/RpgRoller.Tests/Api/FrontendHostTests.cs +++ b/RpgRoller.Tests/Api/FrontendHostTests.cs @@ -54,6 +54,8 @@ public sealed class FrontendHostTests(WebApplicationFactory factory) : Assert.Equal(HttpStatusCode.OK, response.StatusCode); var html = await response.Content.ReadAsStringAsync(); Assert.Contains("_framework/blazor.web.js", html); + Assert.Contains("autostart=\"false\"", html); + Assert.Contains("disableDomPreservation", html); Assert.DoesNotContain("data-auth-page", html); } } \ No newline at end of file diff --git a/RpgRoller/Components/App.razor b/RpgRoller/Components/App.razor index 9c1bffa..460090d 100644 --- a/RpgRoller/Components/App.razor +++ b/RpgRoller/Components/App.razor @@ -15,7 +15,7 @@ @if (UseInteractiveApp) { - + } @@ -26,7 +26,7 @@ else {
- +
} @@ -35,7 +35,14 @@ else - + + } diff --git a/RpgRoller/Components/Pages/AdminPage.razor b/RpgRoller/Components/Pages/AdminPage.razor index 77905f7..a0df468 100644 --- a/RpgRoller/Components/Pages/AdminPage.razor +++ b/RpgRoller/Components/Pages/AdminPage.razor @@ -1,4 +1,5 @@ @page "/admin" +@rendermode @(new InteractiveServerRenderMode(prerender: false)) @inherits AuthenticatedPageBase diff --git a/RpgRoller/Components/Pages/CampaignsPage.razor b/RpgRoller/Components/Pages/CampaignsPage.razor index 4fc0200..3b1e58b 100644 --- a/RpgRoller/Components/Pages/CampaignsPage.razor +++ b/RpgRoller/Components/Pages/CampaignsPage.razor @@ -1,4 +1,5 @@ @page "/campaigns" +@rendermode @(new InteractiveServerRenderMode(prerender: false)) @inherits AuthenticatedPageBase diff --git a/RpgRoller/Components/Pages/PlayPage.razor b/RpgRoller/Components/Pages/PlayPage.razor index 3ce76cf..8994482 100644 --- a/RpgRoller/Components/Pages/PlayPage.razor +++ b/RpgRoller/Components/Pages/PlayPage.razor @@ -1,4 +1,5 @@ @page "/play" +@rendermode @(new InteractiveServerRenderMode(prerender: false)) @inherits AuthenticatedPageBase diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index f2c61dc..3d178fb 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -116,8 +116,8 @@ public partial class Workspace : IAsyncDisposable Logger.LogInformation("Workspace.NavigateToRouteAsync fromRoute={Route} toRoute={TargetRoute} state=[{State}]", Route, route, WorkspaceDiagnosticSummary.DescribeState(State)); State.IsScreenMenuOpen = false; - Navigation.NavigateTo(route); - return InvokeAsync(StateHasChanged); + Navigation.NavigateTo(route, forceLoad: true); + return Task.CompletedTask; } private Task RedirectToPlayAsync() @@ -127,7 +127,7 @@ public partial class Workspace : IAsyncDisposable Logger.LogWarning("Workspace.RedirectToPlayAsync fromRoute={Route} state=[{State}]", Route, WorkspaceDiagnosticSummary.DescribeState(State)); - Navigation.NavigateTo("/play"); + Navigation.NavigateTo("/play", forceLoad: true); return Task.CompletedTask; } diff --git a/TASKS.md b/TASKS.md index c7df620..17f6b6c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -26,6 +26,7 @@ The change is complete when a human can run the app, open `/`, observe the corre - [x] (2026-05-04) Added expanded workspace startup diagnostics across Blazor lifecycle logging, route-content render logging, and browser-side DOM mutation snapshots to narrow the remaining Firefox batch-2 crash. - [x] (2026-05-04) Extended the diagnostics to page-load time by wrapping the interactive host in a stable container and logging pre-Blazor body and host mutations before the first interactive batch applies. - [x] (2026-05-04) Reworked authenticated route startup into phased interactive batches so the first render mounts only a tiny shell, the second render mounts a simple header placeholder, the third render mounts route skeletons, and real control-heavy content appears only after route initialization completes. +- [x] (2026-05-04 22:17Z) Removed global authenticated `Routes` interactivity, moved `InteractiveServerRenderMode(prerender: false)` onto the real authenticated pages, and switched to manual `Blazor.start({ ssr: { disableDomPreservation: true } })` startup based on the upstream Firefox guidance in `dotnet/aspnetcore#58824`. ## Surprises & Discoveries @@ -56,6 +57,12 @@ The change is complete when a human can run the app, open `/`, observe the corre - Observation: the RoboForm-triggered crash happens before any component `OnAfterRenderAsync` callback in the authenticated route tree. Evidence: in the failing `/play` and `/admin` reproductions, the last server-side logs were only `OnInitialized` and `OnParametersSet` entries for `Workspace` and its immediate child components; there were no `WorkspaceRouteView.OnAfterRenderAsync` or `Workspace.InitializeRouteCoreAsync` entries before the circuit terminated. +- Observation: the current app still matched the upstream "global interactivity" failure shape even after the route-first rewrite, because `App.razor` continued to apply `@rendermode` to the root `Routes` component. + Evidence: `RpgRoller/Components/App.razor` still rendered `` until the final follow-up pass, while `dotnet/aspnetcore#58824` explicitly reports Firefox crashes for Global mode and says PerPage mode does not reproduce. + +- Observation: once the authenticated pages moved to per-page interactivity, header route navigation needed full document reloads instead of in-circuit `NavigationManager.NavigateTo` transitions. + Evidence: the first Selenium run after the per-page render-mode change reached `/play` in the URL but never mounted `#skill-filter-input` after `/campaigns -> /play` until `Workspace.NavigateToRouteAsync` switched to `forceLoad: true`. + - Observation: the locally installed Snap Firefox build on this machine is viable for Selenium through `geckodriver`, but not for Playwright protocol control. Evidence: Playwright stalled during the `-juggler-pipe` handshake, while a `geckodriver` plus Selenium session against `/snap/firefox/current/usr/lib/firefox/firefox` completed the same Milestone 1 verification successfully. @@ -77,6 +84,10 @@ The change is complete when a human can run the app, open `/`, observe the corre Rationale: the current `screen` preference in `sessionStorage` causes the app state and the browser URL to disagree. Real routes remove that mismatch and make refresh, deep-linking, and testing simpler. Date/Author: 2026-05-04 / Codex +- Decision: stop using global authenticated interactivity and move the authenticated pages to per-page `InteractiveServerRenderMode(prerender: false)` with manual startup that disables SSR DOM preservation. + Rationale: upstream issue `dotnet/aspnetcore#58824` identifies Firefox failures tied to Global interactivity and explicitly notes that PerPage mode does not share the problem. The Blazor startup guidance also documents manual `Blazor.start` configuration for SSR options such as `disableDomPreservation`. + Date/Author: 2026-05-04 / Codex + - Decision: stage the rewrite in two layers: first introduce real routes while preserving existing feature behavior, then split the large workspace tree into route-owned subtrees. Rationale: the current workspace is dense and risk-prone. A staged rewrite keeps the app working while the route model changes, and it gives the test suite meaningful checkpoints. Date/Author: 2026-05-04 / Codex