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,67 +1,85 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
|
||||
<main class="management-screen">
|
||||
@if (Workspace.State.IsCurrentUserAdmin)
|
||||
{
|
||||
@if (!Workspace.ShowLiveContent)
|
||||
{
|
||||
<main class="management-screen">
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Database</h2>
|
||||
<h2>Admin</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 class="skeleton-stack">
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line short"></div>
|
||||
<div class="skeleton-line"></div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>User Management</h2>
|
||||
</div>
|
||||
@if (IsAdminDataLoading)
|
||||
</main>
|
||||
}
|
||||
else
|
||||
{
|
||||
<main class="management-screen">
|
||||
@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)
|
||||
{
|
||||
<p class="empty">Admin role is required to manage users.</p>
|
||||
}
|
||||
else if (Workspace.State.AdminUsers.Count == 0)
|
||||
{
|
||||
<p class="empty">No users found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var user in Workspace.State.AdminUsers)
|
||||
{
|
||||
<li>
|
||||
<div>
|
||||
<strong>@user.Username</strong>
|
||||
<p class="muted">@user.DisplayName</p>
|
||||
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
|
||||
</div>
|
||||
<div class="skill-chip-actions">
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
|
||||
@onclick="() => Workspace.Admin.ToggleAdminRoleAsync(user)">
|
||||
<span aria-hidden="true" class="emoji">🛡️</span>
|
||||
<span class="sr-only">Toggle admin role for @user.Username</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
|
||||
@onclick="() => Workspace.Admin.DeleteUserAsync(user)">
|
||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||
<span class="sr-only">Delete user @user.Username</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>User Management</h2>
|
||||
</div>
|
||||
@if (IsAdminDataLoading)
|
||||
{
|
||||
<p class="empty">Loading users...</p>
|
||||
}
|
||||
else if (!Workspace.State.IsCurrentUserAdmin)
|
||||
{
|
||||
<p class="empty">Admin role is required to manage users.</p>
|
||||
}
|
||||
else if (Workspace.State.AdminUsers.Count == 0)
|
||||
{
|
||||
<p class="empty">No users found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var user in Workspace.State.AdminUsers)
|
||||
{
|
||||
<li>
|
||||
<div>
|
||||
<strong>@user.Username</strong>
|
||||
<p class="muted">@user.DisplayName</p>
|
||||
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
|
||||
</div>
|
||||
<div class="skill-chip-actions">
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
|
||||
@onclick="() => Workspace.Admin.ToggleAdminRoleAsync(user)">
|
||||
<span aria-hidden="true" class="emoji">🛡️</span>
|
||||
<span class="sr-only">Toggle admin role for @user.Username</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
|
||||
@onclick="() => Workspace.Admin.DeleteUserAsync(user)">
|
||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||
<span class="sr-only">Delete user @user.Username</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading;
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
|
||||
<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"/>
|
||||
@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"
|
||||
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 {
|
||||
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||
|
||||
@@ -1,74 +1,103 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
|
||||
<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"
|
||||
@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"
|
||||
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"
|
||||
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"
|
||||
IsSubmitting="Workspace.State.IsSubmittingRolemasterSkillRoll"
|
||||
ConfirmRequested="Workspace.Play.SubmitRolemasterSkillRollAsync"
|
||||
CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/>
|
||||
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,27 +21,62 @@
|
||||
}
|
||||
|
||||
<div class="workspace-shell">
|
||||
<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)
|
||||
@if (PageContext.ShowLiveContent)
|
||||
{
|
||||
@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>
|
||||
|
||||
@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