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`. - 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`. - 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. - 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. - 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`. - 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. - Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded.

View File

@@ -1,67 +1,85 @@
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
<main class="management-screen"> @if (!Workspace.ShowLiveContent)
@if (Workspace.State.IsCurrentUserAdmin) {
{ <main class="management-screen">
<section class="card"> <section class="card">
<div class="section-head"> <div class="section-head">
<h2>Database</h2> <h2>Admin</h2>
</div> </div>
<p class="muted">Download the current SQLite file for backup or offline inspection.</p> <div class="skeleton-stack">
<div class="management-actions"> <div class="skeleton-line"></div>
<a class="action-link" href="@Workspace.AdminDatabaseDownloadUrl" download>Download SQLite database</a> <div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div> </div>
</section> </section>
} </main>
<section class="card"> }
<div class="section-head"> else
<h2>User Management</h2> {
</div> <main class="management-screen">
@if (IsAdminDataLoading) @if (Workspace.State.IsCurrentUserAdmin)
{ {
<p class="empty">Loading users...</p> <section class="card">
<div class="section-head">
<h2>Database</h2>
</div>
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
<div class="management-actions">
<a class="action-link" href="@Workspace.AdminDatabaseDownloadUrl" download>Download SQLite database</a>
</div>
</section>
} }
else if (!Workspace.State.IsCurrentUserAdmin) <section class="card">
{ <div class="section-head">
<p class="empty">Admin role is required to manage users.</p> <h2>User Management</h2>
} </div>
else if (Workspace.State.AdminUsers.Count == 0) @if (IsAdminDataLoading)
{ {
<p class="empty">No users found.</p> <p class="empty">Loading users...</p>
} }
else else if (!Workspace.State.IsCurrentUserAdmin)
{ {
<ul class="management-list"> <p class="empty">Admin role is required to manage users.</p>
@foreach (var user in Workspace.State.AdminUsers) }
{ else if (Workspace.State.AdminUsers.Count == 0)
<li> {
<div> <p class="empty">No users found.</p>
<strong>@user.Username</strong> }
<p class="muted">@user.DisplayName</p> else
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p> {
</div> <ul class="management-list">
<div class="skill-chip-actions"> @foreach (var user in Workspace.State.AdminUsers)
<button type="button" {
class="chip-button" <li>
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)" <div>
@onclick="() => Workspace.Admin.ToggleAdminRoleAsync(user)"> <strong>@user.Username</strong>
<span aria-hidden="true" class="emoji">🛡️</span> <p class="muted">@user.DisplayName</p>
<span class="sr-only">Toggle admin role for @user.Username</span> <p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
</button> </div>
<button type="button" <div class="skill-chip-actions">
class="chip-button" <button type="button"
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)" class="chip-button"
@onclick="() => Workspace.Admin.DeleteUserAsync(user)"> disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
<span aria-hidden="true" class="emoji">🗑️</span> @onclick="() => Workspace.Admin.ToggleAdminRoleAsync(user)">
<span class="sr-only">Delete user @user.Username</span> <span aria-hidden="true" class="emoji">🛡️</span>
</button> <span class="sr-only">Toggle admin role for @user.Username</span>
</div> </button>
</li> <button type="button"
} class="chip-button"
</ul> disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
} @onclick="() => Workspace.Admin.DeleteUserAsync(user)">
</section> <span aria-hidden="true" class="emoji">🗑️</span>
</main> <span class="sr-only">Delete user @user.Username</span>
</button>
</div>
</li>
}
</ul>
}
</section>
</main>
}
@code { @code {
private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading; private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading;

View File

@@ -1,24 +1,42 @@
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
<CampaignManagementPanel @if (!Workspace.ShowLiveContent)
Campaigns="Workspace.State.Campaigns" {
SelectedCampaignId="Workspace.State.SelectedCampaignId" <main class="management-screen">
SelectedCampaign="Workspace.State.SelectedCampaign" <section class="card">
Rulesets="Workspace.State.Rulesets" <div class="section-head">
IsMutating="Workspace.State.IsMutating" <h2>Campaign Management</h2>
OwnerLabel="Workspace.State.OwnerLabel" </div>
CanEditCharacter="Workspace.Campaigns.CanEditCharacter" <div class="skeleton-stack">
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter" <div class="skeleton-line"></div>
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign" <div class="skeleton-line short"></div>
CampaignSelectionChanged="OnCampaignSelectionChangedAsync" <div class="skeleton-line"></div>
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync" </div>
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync" </section>
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal" </main>
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal" }
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/> else
{
<CampaignManagementPanel
Campaigns="Workspace.State.Campaigns"
SelectedCampaignId="Workspace.State.SelectedCampaignId"
SelectedCampaign="Workspace.State.SelectedCampaign"
Rulesets="Workspace.State.Rulesets"
IsMutating="Workspace.State.IsMutating"
OwnerLabel="Workspace.State.OwnerLabel"
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/>
<CharacterManagementModals Workspace="Workspace"/> <CharacterManagementModals Workspace="Workspace"/>
}
@code { @code {
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)

View File

@@ -1,74 +1,103 @@
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
<main class="play-screen @(Workspace.State.MobilePanel == "log" ? "mobile-log" : "mobile-character")"> @if (!Workspace.ShowLiveContent)
<CharacterPanel {
IsCampaignDataLoading="@IsCampaignDataLoading" <main class="play-screen mobile-character">
SelectedCampaign="Workspace.State.PlaySelectedCampaign" <section class="card character-panel">
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId" <div class="skeleton-stack">
SelectedCharacter="Workspace.State.PlaySelectedCharacter" <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"
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
SelectedCharacter="Workspace.State.PlaySelectedCharacter"
IsMutating="Workspace.State.IsMutating"
SelectedCharacterSkills="Workspace.State.PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="Workspace.State.PlaySelectedCharacterSkillGroups"
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="Workspace.State.RollVisibility"
RollVisibilityChanged="Workspace.Session.OnRollVisibilityChangedAsync"
OwnerLabel="Workspace.State.OwnerLabel"
SkillDefinitionLabel="Workspace.State.SkillDefinitionLabel"
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
CanEditSkill="Workspace.Play.CanEditSkill"
CharacterSelected="Workspace.Play.SelectCharacterAsync"
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
SkillCreated="Workspace.Play.OnSkillCreatedAsync"
SkillUpdated="Workspace.Play.OnSkillUpdatedAsync"
SkillGroupCreated="Workspace.Play.OnSkillGroupCreatedAsync"
SkillGroupUpdated="Workspace.Play.OnSkillGroupUpdatedAsync"
SkillDeleted="Workspace.Play.OnSkillDeletedAsync"
SkillGroupDeleted="Workspace.Play.OnSkillGroupDeletedAsync"
ErrorOccurred="Workspace.Play.OnCharacterPanelErrorAsync"
RollRequested="Workspace.Play.RollSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="@IsCampaignDataLoading"
CampaignLog="Workspace.State.PlayVisibleCampaignLog"
ExpandedRollId="Workspace.State.ExpandedCampaignLogRollId"
FreshRollId="Workspace.State.FreshCampaignLogRollId"
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
SelectedCharacterName="@(Workspace.State.PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="Workspace.State.RollVisibility"
IsMutating="Workspace.State.IsMutating"
ToggleRollDetailRequested="Workspace.Play.ToggleRollDetailAsync"
ResolveRollDetail="Workspace.Play.ResolveRollDetail"
IsRollDetailLoading="Workspace.Play.IsRollDetailLoading"
GetRollDetailError="Workspace.Play.GetRollDetailError"
CustomRollCreated="Workspace.Play.OnCustomRollCreatedAsync"
ErrorOccurred="Workspace.Play.OnCampaignLogPanelErrorAsync"/>
</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
</button>
<button type="button" class="switch @(Workspace.State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Workspace.Scope.SetMobilePanelAsync("log")'>
Log
</button>
</nav>
<CharacterManagementModals Workspace="Workspace"/>
<RolemasterSkillRollModal
Visible="Workspace.State.ShowRolemasterSkillRollModal"
SkillName="@(Workspace.State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
Expression="@(Workspace.State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
ModifierText="@Workspace.State.PendingRolemasterSituationalModifier"
ModifierTextChanged="@(text => Workspace.State.PendingRolemasterSituationalModifier = text)"
ErrorMessage="@Workspace.State.PendingRolemasterSkillRollError"
IsMutating="Workspace.State.IsMutating" IsMutating="Workspace.State.IsMutating"
SelectedCharacterSkills="Workspace.State.PlaySelectedCharacterSkills" IsSubmitting="Workspace.State.IsSubmittingRolemasterSkillRoll"
SelectedCharacterSkillGroups="Workspace.State.PlaySelectedCharacterSkillGroups" ConfirmRequested="Workspace.Play.SubmitRolemasterSkillRollAsync"
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)" CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/>
RollVisibility="Workspace.State.RollVisibility" }
RollVisibilityChanged="Workspace.Session.OnRollVisibilityChangedAsync"
OwnerLabel="Workspace.State.OwnerLabel"
SkillDefinitionLabel="Workspace.State.SkillDefinitionLabel"
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
CanEditSkill="Workspace.Play.CanEditSkill"
CharacterSelected="Workspace.Play.SelectCharacterAsync"
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
SkillCreated="Workspace.Play.OnSkillCreatedAsync"
SkillUpdated="Workspace.Play.OnSkillUpdatedAsync"
SkillGroupCreated="Workspace.Play.OnSkillGroupCreatedAsync"
SkillGroupUpdated="Workspace.Play.OnSkillGroupUpdatedAsync"
SkillDeleted="Workspace.Play.OnSkillDeletedAsync"
SkillGroupDeleted="Workspace.Play.OnSkillGroupDeletedAsync"
ErrorOccurred="Workspace.Play.OnCharacterPanelErrorAsync"
RollRequested="Workspace.Play.RollSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="@IsCampaignDataLoading"
CampaignLog="Workspace.State.PlayVisibleCampaignLog"
ExpandedRollId="Workspace.State.ExpandedCampaignLogRollId"
FreshRollId="Workspace.State.FreshCampaignLogRollId"
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
SelectedCharacterName="@(Workspace.State.PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="Workspace.State.RollVisibility"
IsMutating="Workspace.State.IsMutating"
ToggleRollDetailRequested="Workspace.Play.ToggleRollDetailAsync"
ResolveRollDetail="Workspace.Play.ResolveRollDetail"
IsRollDetailLoading="Workspace.Play.IsRollDetailLoading"
GetRollDetailError="Workspace.Play.GetRollDetailError"
CustomRollCreated="Workspace.Play.OnCustomRollCreatedAsync"
ErrorOccurred="Workspace.Play.OnCampaignLogPanelErrorAsync"/>
</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
</button>
<button type="button" class="switch @(Workspace.State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Workspace.Scope.SetMobilePanelAsync("log")'>
Log
</button>
</nav>
<CharacterManagementModals Workspace="Workspace"/>
<RolemasterSkillRollModal
Visible="Workspace.State.ShowRolemasterSkillRollModal"
SkillName="@(Workspace.State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
Expression="@(Workspace.State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
ModifierText="@Workspace.State.PendingRolemasterSituationalModifier"
ModifierTextChanged="@(text => Workspace.State.PendingRolemasterSituationalModifier = text)"
ErrorMessage="@Workspace.State.PendingRolemasterSkillRollError"
IsMutating="Workspace.State.IsMutating"
IsSubmitting="Workspace.State.IsSubmittingRolemasterSkillRoll"
ConfirmRequested="Workspace.Play.SubmitRolemasterSkillRollAsync"
CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/>
@code { @code {
private bool IsCampaignDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsCampaignDataLoading; private bool IsCampaignDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsCampaignDataLoading;

View File

@@ -2,13 +2,14 @@
<div class="@AppCssClass" <div class="@AppCssClass"
data-workspace-root="true" data-workspace-root="true"
data-workspace-route="@Route" data-workspace-route="@Route"
data-workspace-phase="@PageContext.RenderPhase"
data-workspace-session-initialized="@HasSessionInitialized" data-workspace-session-initialized="@HasSessionInitialized"
data-workspace-campaign-loading="@State.IsCampaignDataLoading" data-workspace-campaign-loading="@State.IsCampaignDataLoading"
data-workspace-admin-loading="@State.IsAdminDataLoading" data-workspace-admin-loading="@State.IsAdminDataLoading"
data-workspace-user="@(State.User?.Username ?? "loading")"> data-workspace-user="@(State.User?.Username ?? "loading")">
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p> <p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
@if (State.HasHealthIssue) @if (PageContext.ShowLiveContent && State.HasHealthIssue)
{ {
<section class="health-banner" role="alert"> <section class="health-banner" role="alert">
<div> <div>
@@ -20,27 +21,62 @@
} }
<div class="workspace-shell"> <div class="workspace-shell">
<AppHeader @if (PageContext.ShowLiveContent)
User="State.User"
ShowCampaign="@ShowCampaignInHeader"
CampaignName="@State.SelectedCampaignName"
ShowConnectionState="@ShowConnectionStateInHeader"
ConnectionStateLabel="@State.ConnectionStateLabel"
ConnectionStateCssClass="@State.ConnectionStateCssClass"
IsMenuOpen="State.IsScreenMenuOpen"
MenuButtonId="workspace-screen-menu-button"
MenuId="workspace-screen-menu"
MenuItems="HeaderMenuItems"
ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="Session.LogoutAsync"/>
@if (ChildContent is not null)
{ {
@ChildContent(PageContext) <AppHeader
User="State.User"
ShowCampaign="@ShowCampaignInHeader"
CampaignName="@State.SelectedCampaignName"
ShowConnectionState="@ShowConnectionStateInHeader"
ConnectionStateLabel="@State.ConnectionStateLabel"
ConnectionStateCssClass="@State.ConnectionStateCssClass"
IsMenuOpen="State.IsScreenMenuOpen"
MenuButtonId="workspace-screen-menu-button"
MenuId="workspace-screen-menu"
MenuItems="HeaderMenuItems"
ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="Session.LogoutAsync"/>
@if (ChildContent is not null)
{
@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> </div>
@if (State.Toasts.Count > 0) @if (PageContext.ShowLiveContent && State.Toasts.Count > 0)
{ {
<div class="toast-stack" aria-live="polite" aria-atomic="false"> <div class="toast-stack" aria-live="polite" aria-atomic="false">
@foreach (var toast in State.Toasts) @foreach (var toast in State.Toasts)

View File

@@ -31,8 +31,12 @@ public partial class Workspace : IAsyncDisposable
{ {
RenderCount += 1; RenderCount += 1;
Logger.LogInformation( Logger.LogInformation(
"Workspace.OnAfterRenderAsync route={Route} renderCount={RenderCount} firstRender={FirstRender} hasSessionInitialized={HasSessionInitialized} state=[{State}]", "Workspace.OnAfterRenderAsync route={Route} renderCount={RenderCount} firstRender={FirstRender} renderPhase={RenderPhase} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
Route, RenderCount, firstRender, HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(State)); Route, RenderCount, firstRender, RenderPhase, HasSessionInitialized,
WorkspaceDiagnosticSummary.DescribeState(State));
if (RenderPhase < WorkspaceRenderPhase.RouteSkeleton)
return AdvanceRenderPhaseAsync("Workspace.OnAfterRenderAsync");
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -149,6 +153,14 @@ public partial class Workspace : IAsyncDisposable
return InitializationTask ??= InitializeRouteCoreAsync(); return InitializationTask ??= InitializeRouteCoreAsync();
} }
private Task EnsureLiveRenderPhaseAsync()
{
if (RenderPhase == WorkspaceRenderPhase.Live)
return Task.CompletedTask;
return AdvanceRenderPhaseAsync("WorkspaceRouteView.EnsureLiveRenderPhaseAsync", WorkspaceRenderPhase.Live);
}
private async Task InitializeRouteCoreAsync() private async Task InitializeRouteCoreAsync()
{ {
if (HasSessionInitialized) if (HasSessionInitialized)
@@ -199,6 +211,19 @@ public partial class Workspace : IAsyncDisposable
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); 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 IJSRuntime JS { get; set; } = null!;
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
@@ -223,8 +248,8 @@ public partial class Workspace : IAsyncDisposable
private bool ShowConnectionStateInHeader => IsPlayRoute; private bool ShowConnectionStateInHeader => IsPlayRoute;
private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session, private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session,
InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, AdminDatabaseDownloadUrl, HeaderMenuItems, InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, EnsureLiveRenderPhaseAsync,
IsPlayRoute, IsCampaignsRoute, IsAdminRoute); AdminDatabaseDownloadUrl, HeaderMenuItems, RenderPhase, IsPlayRoute, IsCampaignsRoute, IsAdminRoute);
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery, private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
() => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, () => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync,
@@ -308,4 +333,5 @@ public partial class Workspace : IAsyncDisposable
private Task? InitializationTask { get; set; } private Task? InitializationTask { get; set; }
private WorkspaceRoute? PreviousRoute { get; set; } private WorkspaceRoute? PreviousRoute { get; set; }
private int RenderCount { 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, Func<Task> initializeRouteAsync,
bool hasSessionInitialized, bool hasSessionInitialized,
Func<Task> requestRefreshAsync, Func<Task> requestRefreshAsync,
Func<Task> ensureLiveRenderPhaseAsync,
string adminDatabaseDownloadUrl, string adminDatabaseDownloadUrl,
IReadOnlyList<AppHeaderMenuItem> headerMenuItems, IReadOnlyList<AppHeaderMenuItem> headerMenuItems,
WorkspaceRenderPhase renderPhase,
bool isPlayRoute, bool isPlayRoute,
bool isCampaignsRoute, bool isCampaignsRoute,
bool isAdminRoute) bool isAdminRoute)
@@ -27,9 +29,14 @@ public sealed class WorkspacePageContext(
public Func<Task> InitializeRouteAsync { get; } = initializeRouteAsync; public Func<Task> InitializeRouteAsync { get; } = initializeRouteAsync;
public bool HasSessionInitialized { get; } = hasSessionInitialized; public bool HasSessionInitialized { get; } = hasSessionInitialized;
public Func<Task> RequestRefreshAsync { get; } = requestRefreshAsync; public Func<Task> RequestRefreshAsync { get; } = requestRefreshAsync;
public Func<Task> EnsureLiveRenderPhaseAsync { get; } = ensureLiveRenderPhaseAsync;
public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl; public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl;
public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems; public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems;
public WorkspaceRenderPhase RenderPhase { get; } = renderPhase;
public bool IsPlayRoute { get; } = isPlayRoute; public bool IsPlayRoute { get; } = isPlayRoute;
public bool IsCampaignsRoute { get; } = isCampaignsRoute; public bool IsCampaignsRoute { get; } = isCampaignsRoute;
public bool IsAdminRoute { get; } = isAdminRoute; 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) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
Logger.LogInformation( 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.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"); await TryMarkWorkspacePhaseAsync(firstRender ? "after-first-render" : "after-render");
if (!firstRender) if (!firstRender)
return; return;
await TryInstallWorkspaceDiagnosticsAsync(); await TryInstallWorkspaceDiagnosticsAsync();
await Workspace.InitializeRouteAsync(); await Workspace.InitializeRouteAsync();
await Workspace.EnsureLiveRenderPhaseAsync();
Logger.LogInformation("WorkspaceRouteView.OnAfterRenderAsync initialized state=[{State}]", Logger.LogInformation("WorkspaceRouteView.OnAfterRenderAsync initialized state=[{State}]",
WorkspaceDiagnosticSummary.DescribeState(Workspace.State)); WorkspaceDiagnosticSummary.DescribeState(Workspace.State));
await TryMarkWorkspacePhaseAsync("after-initialize-route"); 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) 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) 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) 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 ## 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. - 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. 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. - 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. 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.