fix: phase authenticated startup

This commit is contained in:
2026-05-04 23:02:39 +02:00
parent f86ac43153
commit 56e0ec1e79
10 changed files with 312 additions and 163 deletions

View File

@@ -196,6 +196,7 @@ SQLite migration rule:
- 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 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`.
- Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded.

View File

@@ -1,5 +1,22 @@
@using Microsoft.AspNetCore.Components
@if (!Workspace.ShowLiveContent)
{
<main class="management-screen">
<section class="card">
<div class="section-head">
<h2>Admin</h2>
</div>
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
</section>
</main>
}
else
{
<main class="management-screen">
@if (Workspace.State.IsCurrentUserAdmin)
{
@@ -62,6 +79,7 @@
}
</section>
</main>
}
@code {
private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading;

View File

@@ -1,6 +1,23 @@
@using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls
@if (!Workspace.ShowLiveContent)
{
<main class="management-screen">
<section class="card">
<div class="section-head">
<h2>Campaign Management</h2>
</div>
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
</section>
</main>
}
else
{
<CampaignManagementPanel
Campaigns="Workspace.State.Campaigns"
SelectedCampaignId="Workspace.State.SelectedCampaignId"
@@ -19,6 +36,7 @@
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/>
<CharacterManagementModals Workspace="Workspace"/>
}
@code {
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)

View File

@@ -1,6 +1,34 @@
@using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls
@if (!Workspace.ShowLiveContent)
{
<main class="play-screen mobile-character">
<section class="card character-panel">
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
</section>
<aside class="card log-panel">
<div class="section-head">
<h2>Campaign Log</h2>
</div>
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
</aside>
</main>
<div class="mobile-bottom-nav" aria-hidden="true">
<span class="switch active">Character</span>
<span class="switch">Log</span>
</div>
}
else
{
<main class="play-screen @(Workspace.State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<CharacterPanel
IsCampaignDataLoading="@IsCampaignDataLoading"
@@ -69,6 +97,7 @@
IsSubmitting="Workspace.State.IsSubmittingRolemasterSkillRoll"
ConfirmRequested="Workspace.Play.SubmitRolemasterSkillRollAsync"
CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/>
}
@code {
private bool IsCampaignDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsCampaignDataLoading;

View File

@@ -2,13 +2,14 @@
<div class="@AppCssClass"
data-workspace-root="true"
data-workspace-route="@Route"
data-workspace-phase="@PageContext.RenderPhase"
data-workspace-session-initialized="@HasSessionInitialized"
data-workspace-campaign-loading="@State.IsCampaignDataLoading"
data-workspace-admin-loading="@State.IsAdminDataLoading"
data-workspace-user="@(State.User?.Username ?? "loading")">
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
@if (State.HasHealthIssue)
@if (PageContext.ShowLiveContent && State.HasHealthIssue)
{
<section class="health-banner" role="alert">
<div>
@@ -20,6 +21,8 @@
}
<div class="workspace-shell">
@if (PageContext.ShowLiveContent)
{
<AppHeader
User="State.User"
ShowCampaign="@ShowCampaignInHeader"
@@ -38,9 +41,42 @@
{
@ChildContent(PageContext)
}
}
else if (PageContext.ShowHeaderPlaceholder)
{
<header class="workspace-header">
<div class="header-row">
<h1>RpgRoller</h1>
<p class="header-identity">
<strong>Loading workspace...</strong>
</p>
@if (ShowCampaignInHeader)
{
<p class="header-campaign">Campaign: <strong>Loading...</strong></p>
}
@if (ShowConnectionStateInHeader)
{
<div class="header-connection-cell">
<p class="connection offline">Offline fallback</p>
</div>
}
</div>
</header>
@if (PageContext.ShowRouteSkeleton && ChildContent is not null)
{
@ChildContent(PageContext)
}
}
else
{
<section class="card" aria-live="polite">
<p>Loading workspace...</p>
</section>
}
</div>
@if (State.Toasts.Count > 0)
@if (PageContext.ShowLiveContent && State.Toasts.Count > 0)
{
<div class="toast-stack" aria-live="polite" aria-atomic="false">
@foreach (var toast in State.Toasts)

View File

@@ -31,8 +31,12 @@ public partial class Workspace : IAsyncDisposable
{
RenderCount += 1;
Logger.LogInformation(
"Workspace.OnAfterRenderAsync route={Route} renderCount={RenderCount} firstRender={FirstRender} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
Route, RenderCount, firstRender, HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(State));
"Workspace.OnAfterRenderAsync route={Route} renderCount={RenderCount} firstRender={FirstRender} renderPhase={RenderPhase} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
Route, RenderCount, firstRender, RenderPhase, HasSessionInitialized,
WorkspaceDiagnosticSummary.DescribeState(State));
if (RenderPhase < WorkspaceRenderPhase.RouteSkeleton)
return AdvanceRenderPhaseAsync("Workspace.OnAfterRenderAsync");
return Task.CompletedTask;
}
@@ -149,6 +153,14 @@ public partial class Workspace : IAsyncDisposable
return InitializationTask ??= InitializeRouteCoreAsync();
}
private Task EnsureLiveRenderPhaseAsync()
{
if (RenderPhase == WorkspaceRenderPhase.Live)
return Task.CompletedTask;
return AdvanceRenderPhaseAsync("WorkspaceRouteView.EnsureLiveRenderPhaseAsync", WorkspaceRenderPhase.Live);
}
private async Task InitializeRouteCoreAsync()
{
if (HasSessionInitialized)
@@ -199,6 +211,19 @@ public partial class Workspace : IAsyncDisposable
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
}
private Task AdvanceRenderPhaseAsync(string source, WorkspaceRenderPhase? targetPhase = null)
{
var nextPhase = targetPhase ?? (WorkspaceRenderPhase)((int)RenderPhase + 1);
if (nextPhase <= RenderPhase)
return Task.CompletedTask;
Logger.LogInformation(
"Workspace.AdvanceRenderPhaseAsync source={Source} route={Route} from={FromPhase} to={ToPhase}",
source, Route, RenderPhase, nextPhase);
RenderPhase = nextPhase;
return RequestRefreshAsync(source);
}
[Inject] private IJSRuntime JS { get; set; } = null!;
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
@@ -223,8 +248,8 @@ public partial class Workspace : IAsyncDisposable
private bool ShowConnectionStateInHeader => IsPlayRoute;
private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session,
InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, AdminDatabaseDownloadUrl, HeaderMenuItems,
IsPlayRoute, IsCampaignsRoute, IsAdminRoute);
InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, EnsureLiveRenderPhaseAsync,
AdminDatabaseDownloadUrl, HeaderMenuItems, RenderPhase, IsPlayRoute, IsCampaignsRoute, IsAdminRoute);
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
() => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync,
@@ -308,4 +333,5 @@ public partial class Workspace : IAsyncDisposable
private Task? InitializationTask { get; set; }
private WorkspaceRoute? PreviousRoute { get; set; }
private int RenderCount { get; set; }
private WorkspaceRenderPhase RenderPhase { get; set; }
}

View File

@@ -12,8 +12,10 @@ public sealed class WorkspacePageContext(
Func<Task> initializeRouteAsync,
bool hasSessionInitialized,
Func<Task> requestRefreshAsync,
Func<Task> ensureLiveRenderPhaseAsync,
string adminDatabaseDownloadUrl,
IReadOnlyList<AppHeaderMenuItem> headerMenuItems,
WorkspaceRenderPhase renderPhase,
bool isPlayRoute,
bool isCampaignsRoute,
bool isAdminRoute)
@@ -27,9 +29,14 @@ public sealed class WorkspacePageContext(
public Func<Task> InitializeRouteAsync { get; } = initializeRouteAsync;
public bool HasSessionInitialized { get; } = hasSessionInitialized;
public Func<Task> RequestRefreshAsync { get; } = requestRefreshAsync;
public Func<Task> EnsureLiveRenderPhaseAsync { get; } = ensureLiveRenderPhaseAsync;
public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl;
public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems;
public WorkspaceRenderPhase RenderPhase { get; } = renderPhase;
public bool IsPlayRoute { get; } = isPlayRoute;
public bool IsCampaignsRoute { get; } = isCampaignsRoute;
public bool IsAdminRoute { get; } = isAdminRoute;
public bool ShowHeaderPlaceholder => RenderPhase >= WorkspaceRenderPhase.HeaderPlaceholder;
public bool ShowRouteSkeleton => RenderPhase >= WorkspaceRenderPhase.RouteSkeleton;
public bool ShowLiveContent => RenderPhase == WorkspaceRenderPhase.Live;
}

View File

@@ -0,0 +1,9 @@
namespace RpgRoller.Components.Pages;
public enum WorkspaceRenderPhase
{
Minimal = 0,
HeaderPlaceholder = 1,
RouteSkeleton = 2,
Live = 3
}

View File

@@ -7,15 +7,16 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
Logger.LogInformation(
"WorkspaceRouteView.OnAfterRenderAsync routeFlags=play:{IsPlayRoute} campaigns:{IsCampaignsRoute} admin:{IsAdminRoute} firstRender={FirstRender} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
"WorkspaceRouteView.OnAfterRenderAsync routeFlags=play:{IsPlayRoute} campaigns:{IsCampaignsRoute} admin:{IsAdminRoute} firstRender={FirstRender} renderPhase={RenderPhase} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
Workspace.IsPlayRoute, Workspace.IsCampaignsRoute, Workspace.IsAdminRoute, firstRender,
Workspace.HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(Workspace.State));
Workspace.RenderPhase, Workspace.HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(Workspace.State));
await TryMarkWorkspacePhaseAsync(firstRender ? "after-first-render" : "after-render");
if (!firstRender)
return;
await TryInstallWorkspaceDiagnosticsAsync();
await Workspace.InitializeRouteAsync();
await Workspace.EnsureLiveRenderPhaseAsync();
Logger.LogInformation("WorkspaceRouteView.OnAfterRenderAsync initialized state=[{State}]",
WorkspaceDiagnosticSummary.DescribeState(Workspace.State));
await TryMarkWorkspacePhaseAsync("after-initialize-route");

View File

@@ -25,6 +25,7 @@ The change is complete when a human can run the app, open `/`, observe the corre
- [x] (2026-05-04) Updated host tests, Selenium smoke tests, and docs so the real-route model is the documented and verified Milestone 2 behavior.
- [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.
## Surprises & Discoveries
@@ -52,6 +53,9 @@ The change is complete when a human can run the app, open `/`, observe the corre
- Observation: the remaining Firefox failure still happens during Blazor batch application, so server-side coordinator logs alone are not enough to localize it.
Evidence: after route-scoping startup, Firefox still reported `There was an error applying batch 2` with `TypeError: can't access property "insertBefore", n.parentNode is null`, which motivated adding route render lifecycle logs plus browser-side workspace mutation snapshots.
- 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 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.