chore: add pre-blazor crash diagnostics

This commit is contained in:
2026-05-04 22:53:14 +02:00
parent e60b4b5867
commit f86ac43153
4 changed files with 141 additions and 2 deletions

View File

@@ -195,6 +195,7 @@ SQLite migration rule:
- Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers. - Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers.
- Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`. - 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.
- 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

@@ -25,11 +25,16 @@
} }
else else
{ {
<div id="rr-interactive-host" data-request-path="@RequestPath">
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))"/> <Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))"/>
</div>
} }
<script src="js/rpgroller-api.js"></script> <script src="js/rpgroller-api.js"></script>
@if (UseInteractiveApp) @if (UseInteractiveApp)
{ {
<script>
window.rpgRollerApi.bootstrapPreBlazorDiagnostics("@RequestPath");
</script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
} }
</body> </body>
@@ -55,6 +60,8 @@ else
private bool AuthStatusIsError => string.Equals(ReadAuthQueryValue("kind"), "error", StringComparison.OrdinalIgnoreCase); private bool AuthStatusIsError => string.Equals(ReadAuthQueryValue("kind"), "error", StringComparison.OrdinalIgnoreCase);
private string RequestPath => HttpContext?.Request.Path.Value ?? "/";
private string BaseHref private string BaseHref
{ {
get get

View File

@@ -13,6 +13,7 @@ window.rpgRollerApi = (() => {
observer: null, observer: null,
route: null, route: null,
globalHandlersInstalled: false, globalHandlersInstalled: false,
domOperationDiagnosticsInstalled: false,
mutationBatchCount: 0 mutationBatchCount: 0
}; };
@@ -24,6 +25,14 @@ window.rpgRollerApi = (() => {
console.warn(debugPrefix, new Date().toISOString(), ...args); console.warn(debugPrefix, new Date().toISOString(), ...args);
} }
function summarizeChildren(element) {
if (!element || !element.childNodes) {
return [];
}
return Array.from(element.childNodes).slice(0, 20).map(summarizeNode);
}
function summarizeNode(node) { function summarizeNode(node) {
if (!node) { if (!node) {
return "<null>"; return "<null>";
@@ -117,6 +126,53 @@ window.rpgRollerApi = (() => {
}); });
} }
function installDomOperationDiagnostics() {
if (workspaceDiagnostics.domOperationDiagnosticsInstalled) {
return;
}
workspaceDiagnostics.domOperationDiagnosticsInstalled = true;
const originalInsertBefore = Node.prototype.insertBefore;
const originalAppendChild = Node.prototype.appendChild;
const originalRemoveChild = Node.prototype.removeChild;
const originalReplaceChild = Node.prototype.replaceChild;
Node.prototype.insertBefore = function (newNode, referenceNode) {
debug("dom insertBefore", {
parent: summarizeNode(this),
newNode: summarizeNode(newNode),
referenceNode: summarizeNode(referenceNode),
referenceParent: referenceNode ? summarizeNode(referenceNode.parentNode) : null
});
return originalInsertBefore.call(this, newNode, referenceNode);
};
Node.prototype.appendChild = function (child) {
debug("dom appendChild", {
parent: summarizeNode(this),
child: summarizeNode(child)
});
return originalAppendChild.call(this, child);
};
Node.prototype.removeChild = function (child) {
debug("dom removeChild", {
parent: summarizeNode(this),
child: summarizeNode(child)
});
return originalRemoveChild.call(this, child);
};
Node.prototype.replaceChild = function (newChild, oldChild) {
debug("dom replaceChild", {
parent: summarizeNode(this),
newChild: summarizeNode(newChild),
oldChild: summarizeNode(oldChild)
});
return originalReplaceChild.call(this, newChild, oldChild);
};
}
function summarizeMutation(mutation) { function summarizeMutation(mutation) {
return { return {
type: mutation.type, type: mutation.type,
@@ -181,6 +237,79 @@ window.rpgRollerApi = (() => {
logWorkspaceSnapshot(`phase:${label}`); logWorkspaceSnapshot(`phase:${label}`);
} }
function bootstrapPreBlazorDiagnostics(requestPath) {
installGlobalDiagnostics();
installDomOperationDiagnostics();
const host = document.getElementById("rr-interactive-host");
workspaceDiagnostics.route = requestPath;
debug("bootstrapPreBlazorDiagnostics", {
requestPath,
readyState: document.readyState,
bodyChildren: summarizeChildren(document.body),
host: summarizeNode(host),
hostChildren: summarizeChildren(host)
});
if (!host) {
warn("bootstrapPreBlazorDiagnostics missing host", { requestPath });
return;
}
const preconnectObserver = new MutationObserver((mutations) => {
debug("preblazor host mutations", {
requestPath,
mutations: mutations.slice(0, 20).map(summarizeMutation),
bodyChildren: summarizeChildren(document.body),
hostChildren: summarizeChildren(host)
});
});
preconnectObserver.observe(host, {
subtree: true,
childList: true,
attributes: true,
characterData: false
});
const bodyObserver = new MutationObserver((mutations) => {
debug("preblazor body mutations", {
requestPath,
mutations: mutations.slice(0, 20).map(summarizeMutation),
bodyChildren: summarizeChildren(document.body),
hostChildren: summarizeChildren(host)
});
});
bodyObserver.observe(document.body, {
subtree: false,
childList: true,
attributes: false
});
queueMicrotask(() => {
debug("preblazor microtask snapshot", {
requestPath,
bodyChildren: summarizeChildren(document.body),
hostChildren: summarizeChildren(host)
});
});
requestAnimationFrame(() => {
debug("preblazor raf snapshot", {
requestPath,
bodyChildren: summarizeChildren(document.body),
hostChildren: summarizeChildren(host)
});
});
setTimeout(() => {
debug("preblazor timeout50 snapshot", {
requestPath,
bodyChildren: summarizeChildren(document.body),
hostChildren: summarizeChildren(host)
});
}, 50);
}
function toAppUrl(url) { function toAppUrl(url) {
if (!url || typeof url !== "string") { if (!url || typeof url !== "string") {
return url; return url;
@@ -588,6 +717,7 @@ window.rpgRollerApi = (() => {
scrollElementToBottom, scrollElementToBottom,
clearInputValue, clearInputValue,
installWorkspaceDiagnostics, installWorkspaceDiagnostics,
markWorkspacePhase markWorkspacePhase,
bootstrapPreBlazorDiagnostics
}; };
})(); })();

View File

@@ -24,6 +24,7 @@ The change is complete when a human can run the app, open `/`, observe the corre
- [x] (2026-05-04 21:58Z) Removed shell-level `OnAfterRenderAsync` bootstrapping, moved the JS-dependent authenticated startup into a route-owned `WorkspaceRouteView`, removed shell-owned staged control renders, restored the missing development database fixture, and updated README to describe the completed route-first architecture. - [x] (2026-05-04 21:58Z) Removed shell-level `OnAfterRenderAsync` bootstrapping, moved the JS-dependent authenticated startup into a route-owned `WorkspaceRouteView`, removed shell-owned staged control renders, restored the missing development database fixture, and updated README to describe the completed route-first architecture.
- [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.
## Surprises & Discoveries ## Surprises & Discoveries