fix: use per-page blazor startup

This commit is contained in:
2026-05-04 23:58:26 +02:00
parent 56e0ec1e79
commit c628957163
8 changed files with 39 additions and 8 deletions

View File

@@ -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`.

View File

@@ -54,6 +54,8 @@ public sealed class FrontendHostTests(WebApplicationFactory<Program> 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);
}
}

View File

@@ -15,7 +15,7 @@
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap" rel="stylesheet">
@if (UseInteractiveApp)
{
<HeadOutlet @rendermode="@(new InteractiveServerRenderMode(prerender: false))"/>
<HeadOutlet/>
}
</head>
<body>
@@ -26,7 +26,7 @@
else
{
<div id="rr-interactive-host" data-request-path="@RequestPath">
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))"/>
<Routes/>
</div>
}
<script src="js/rpgroller-api.js"></script>
@@ -35,7 +35,14 @@ else
<script>
window.rpgRollerApi.bootstrapPreBlazorDiagnostics("@RequestPath");
</script>
<script src="_framework/blazor.web.js"></script>
<script src="_framework/blazor.web.js" autostart="false"></script>
<script>
Blazor.start({
ssr: {
disableDomPreservation: true
}
});
</script>
}
</body>
</html>

View File

@@ -1,4 +1,5 @@
@page "/admin"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Admin" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">

View File

@@ -1,4 +1,5 @@
@page "/campaigns"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Campaigns" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">

View File

@@ -1,4 +1,5 @@
@page "/play"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Play" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">

View File

@@ -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;
}

View File

@@ -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 `<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />` 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