fix: phase authenticated startup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
|
||||
<main class="management-screen">
|
||||
@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)
|
||||
{
|
||||
<section class="card">
|
||||
@@ -61,7 +78,8 @@
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
</main>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading;
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
|
||||
<CampaignManagementPanel
|
||||
@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"
|
||||
SelectedCampaign="Workspace.State.SelectedCampaign"
|
||||
@@ -18,7 +35,8 @@
|
||||
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
|
||||
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/>
|
||||
|
||||
<CharacterManagementModals Workspace="Workspace"/>
|
||||
<CharacterManagementModals Workspace="Workspace"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
|
||||
<main class="play-screen @(Workspace.State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
@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"
|
||||
SelectedCampaign="Workspace.State.PlaySelectedCampaign"
|
||||
@@ -44,8 +72,8 @@
|
||||
GetRollDetailError="Workspace.Play.GetRollDetailError"
|
||||
CustomRollCreated="Workspace.Play.OnCustomRollCreatedAsync"
|
||||
ErrorOccurred="Workspace.Play.OnCampaignLogPanelErrorAsync"/>
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
<button type="button" class="switch @(Workspace.State.MobilePanel == "character" ? "active" : string.Empty)"
|
||||
@onclick='() => Workspace.Scope.SetMobilePanelAsync("character")'>
|
||||
Character
|
||||
@@ -54,11 +82,11 @@
|
||||
@onclick='() => Workspace.Scope.SetMobilePanelAsync("log")'>
|
||||
Log
|
||||
</button>
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
<CharacterManagementModals Workspace="Workspace"/>
|
||||
<CharacterManagementModals Workspace="Workspace"/>
|
||||
|
||||
<RolemasterSkillRollModal
|
||||
<RolemasterSkillRollModal
|
||||
Visible="Workspace.State.ShowRolemasterSkillRollModal"
|
||||
SkillName="@(Workspace.State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
|
||||
Expression="@(Workspace.State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
9
RpgRoller/Components/Pages/WorkspaceRenderPhase.cs
Normal file
9
RpgRoller/Components/Pages/WorkspaceRenderPhase.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public enum WorkspaceRenderPhase
|
||||
{
|
||||
Minimal = 0,
|
||||
HeaderPlaceholder = 1,
|
||||
RouteSkeleton = 2,
|
||||
Live = 3
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
4
TASKS.md
4
TASKS.md
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user