Harden boot path + surface errors instead of blanking

A friend on Chrome reported the page renders briefly then drops to the
plain theme background (Brave/Chromium fine). Defensive changes so the
next blank-page report comes with a visible cause:

- main.ts installs Vue config.errorHandler, window.onerror, and
  window.unhandledrejection listeners. Any uncaught error pins a red
  banner to the top of the page with the error message, so the user
  sees what crashed instead of a blank background.
- App.vue's onMounted now wraps each data fetch in a safeLoad helper.
  Promise.allSettled (vs allSettled used previously: was a plain
  Promise.all) means one failed file can't reject the whole batch.
  Errors log to the console and show as a non-fatal "Boot warning"
  toast in the share bar.

This doesn't fix the root cause yet — needs Chrome devtools console
output to pinpoint — but ensures the next person who hits it sees an
actionable error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-05-23 08:30:14 -04:00
parent 52d2abd16b
commit 9f53a58701
2 changed files with 86 additions and 12 deletions

View file

@ -65,27 +65,46 @@ watch(
{ immediate: true }, { immediate: true },
); );
const bootError = ref<string>('');
async function safeLoad<T>(label: string, fn: () => Promise<T>): Promise<T | null> {
try {
return await fn();
} catch (e: any) {
const msg = `[${label}] ${e?.message || e}`;
// eslint-disable-next-line no-console
console.error(msg, e);
if (!bootError.value) bootError.value = msg;
return null;
}
}
onMounted(async () => { onMounted(async () => {
await Promise.all([ // Each load is isolated a single failed fetch can't bring down the rest
loadCharacterXp().then((d) => (charXp.value = d)), // of the page (or leave us looking at a blank background).
await Promise.allSettled([
safeLoad('character-xp', loadCharacterXp).then(
(d) => d && (charXp.value = d),
),
...SPECS.map((s) => ...SPECS.map((s) =>
loadSpec(s).then((d) => (specTables.value = { ...specTables.value, [s]: d })), safeLoad(`spec-${s}`, () => loadSpec(s)).then(
(d) => d && (specTables.value = { ...specTables.value, [s]: d }),
),
), ),
loadFaction('atreides').then( safeLoad('faction-atreides', () => loadFaction('atreides')).then(
(d) => (factionTables.value = { ...factionTables.value, atreides: d }), (d) => d && (factionTables.value = { ...factionTables.value, atreides: d }),
), ),
loadFaction('harkonnen').then( safeLoad('faction-harkonnen', () => loadFaction('harkonnen')).then(
(d) => (factionTables.value = { ...factionTables.value, harkonnen: d }), (d) => d && (factionTables.value = { ...factionTables.value, harkonnen: d }),
), ),
]); ]);
// Load all skill trees in background so class switching is instant
for (const c of CLASSES) { for (const c of CLASSES) {
loadSkillTree(c.id).then( safeLoad(`skills-${c.id}`, () => loadSkillTree(c.id)).then(
(d) => (skillTrees.value = { ...skillTrees.value, [c.id]: d }), (d) => d && (skillTrees.value = { ...skillTrees.value, [c.id]: d }),
); );
} }
// If URL has ?b=<code>, load that build.
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const code = params.get('b'); const code = params.get('b');
if (code) { if (code) {
@ -236,6 +255,12 @@ const specMeta: Record<SpecId, { name: string; sym: string }> = {
<div class="grow"></div> <div class="grow"></div>
<span v-if="shareLink" class="share-link">{{ shareLink }}</span> <span v-if="shareLink" class="share-link">{{ shareLink }}</span>
<span v-if="shareStatus" class="toast">{{ shareStatus }}</span> <span v-if="shareStatus" class="toast">{{ shareStatus }}</span>
<span v-if="bootError" style="
color: var(--ember);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.1em;
">Boot warning: {{ bootError }}</span>
<button @click="newBuild">New</button> <button @click="newBuild">New</button>
<button class="primary" @click="share" :disabled="saving"> <button class="primary" @click="share" :disabled="saving">
{{ saving ? 'Saving…' : 'Share Build' }} {{ saving ? 'Saving…' : 'Share Build' }}

View file

@ -2,4 +2,53 @@ import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
import './styles.css'; import './styles.css';
createApp(App).mount('#app'); const app = createApp(App);
// Surface unhandled component errors visibly. The default is to swallow
// them, which on some browsers (we've seen this on certain Chrome builds)
// can leave the user staring at a blank background.
app.config.errorHandler = (err, _instance, info) => {
// eslint-disable-next-line no-console
console.error('[vue] component error:', err, info);
showBootBanner(`Vue error: ${(err as Error)?.message || err}`);
};
// Unhandled promise rejections fall through Vue's handler; catch them
// at the window level so the user can see them.
window.addEventListener('unhandledrejection', (e) => {
const msg = (e.reason && (e.reason.message || e.reason.toString())) || 'unknown';
// eslint-disable-next-line no-console
console.error('[window] unhandledrejection:', e.reason);
showBootBanner(`Unhandled error: ${msg}`);
});
window.addEventListener('error', (e) => {
// eslint-disable-next-line no-console
console.error('[window] error:', e.error || e.message);
showBootBanner(`Script error: ${e.message || 'unknown'}`);
});
function showBootBanner(text: string) {
if (document.getElementById('boot-banner')) return; // only show the first
const el = document.createElement('div');
el.id = 'boot-banner';
el.textContent = text;
el.style.cssText = [
'position:fixed',
'top:0',
'left:0',
'right:0',
'z-index:99999',
'background:#3a1a14',
'color:#f4e9d8',
'border-bottom:1px solid #c8643a',
'padding:10px 16px',
'font-family:monospace',
'font-size:12px',
'white-space:pre-wrap',
'word-break:break-word',
].join(';');
document.body.appendChild(el);
}
app.mount('#app');