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:
parent
52d2abd16b
commit
9f53a58701
2 changed files with 86 additions and 12 deletions
|
|
@ -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' }}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue