Boot-failure seatbelt: no future crash becomes a silent white screen
Per Codex. A branded recovery card in app.html shows if the app hasn't mounted in 7s, or on a pre-mount JS error/unhandledrejection — with a "Refresh Upbeat Bytes" button. A chunk/preload failure (vite:preloadError) reloads once (sessionStorage-guarded). +layout calls window.__ubBooted() on mount to clear the card + timer. A pre-mount failure also fires a tiny anonymous client_error beacon; the admin Overview now shows "Load errors today" (red if >0) so we can see if blank-risk is happening in the wild. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,66 @@
|
||||
<meta name="twitter:title" content="Upbeat Bytes — calm, constructive news" />
|
||||
<meta name="twitter:description" content="Calm, constructive news, summarized — get the gist, go deeper only if you want." />
|
||||
%sveltekit.head%
|
||||
<style>
|
||||
#boot-fallback {
|
||||
display: none; position: fixed; inset: 0; z-index: 9999;
|
||||
align-items: center; justify-content: center; background: #f7f4ec;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; padding: 24px;
|
||||
}
|
||||
#boot-fallback .bf { text-align: center; max-width: 360px; }
|
||||
#boot-fallback img { height: 46px; width: auto; margin-bottom: 18px; }
|
||||
#boot-fallback p { color: #4a5560; font-size: 1rem; line-height: 1.55; margin: 0 0 20px; }
|
||||
#boot-fallback button {
|
||||
background: #0083ad; color: #fff; border: none; border-radius: 999px;
|
||||
padding: 12px 26px; font: inherit; font-weight: 600; font-size: 0.95rem; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Reliability seatbelt: never let a slow/failed boot become a silent white
|
||||
// screen. Show a calm recovery card if the app hasn't mounted, and reload
|
||||
// once on a chunk/preload failure (e.g. a just-deployed hashed chunk).
|
||||
(function () {
|
||||
function showBoot() {
|
||||
if (window.__ubMounted) return; // app is running; in-app handles it
|
||||
var el = document.getElementById('boot-fallback');
|
||||
if (el) el.style.display = 'flex';
|
||||
try {
|
||||
var b = new Blob([JSON.stringify({ kind: 'client_error', visitor: 'e' + Math.random().toString(36).slice(2) })],
|
||||
{ type: 'application/json' });
|
||||
navigator.sendBeacon && navigator.sendBeacon('/api/events', b);
|
||||
} catch (e) { /* best-effort telemetry */ }
|
||||
}
|
||||
var timer = setTimeout(showBoot, 7000);
|
||||
// Svelte calls this once it has mounted (see +layout.svelte).
|
||||
window.__ubBooted = function () {
|
||||
window.__ubMounted = true;
|
||||
clearTimeout(timer);
|
||||
var el = document.getElementById('boot-fallback');
|
||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||
try { sessionStorage.removeItem('ub_reloaded'); } catch (e) {}
|
||||
};
|
||||
addEventListener('vite:preloadError', function (e) {
|
||||
try {
|
||||
if (!sessionStorage.getItem('ub_reloaded')) {
|
||||
sessionStorage.setItem('ub_reloaded', '1');
|
||||
e.preventDefault();
|
||||
location.reload();
|
||||
}
|
||||
} catch (err) { /* ignore */ }
|
||||
});
|
||||
addEventListener('error', showBoot);
|
||||
addEventListener('unhandledrejection', showBoot);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
<div id="boot-fallback" role="alert" aria-live="polite">
|
||||
<div class="bf">
|
||||
<img src="%sveltekit.assets%/logo.svg" alt="Upbeat Bytes" />
|
||||
<p>We had a little trouble loading. A quick refresh usually sorts it out.</p>
|
||||
<button type="button" onclick="location.reload()">Refresh Upbeat Bytes</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
||||
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
||||
let { children } = $props();
|
||||
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
||||
// recovery card + timeout as soon as the shell hydrates.
|
||||
onMount(() => window.__ubBooted?.());
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -362,6 +362,11 @@
|
||||
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
||||
<div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
|
||||
{#if stats.client_errors}
|
||||
<div class="stat" class:alert={stats.client_errors.today > 0}>
|
||||
<span class="n">{stats.client_errors.today}</span><span class="l">Load errors today</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if section === 'content'}
|
||||
@@ -1002,6 +1007,9 @@
|
||||
.factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
|
||||
.factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; }
|
||||
|
||||
.stat.alert { background: #f3e0e0; }
|
||||
.stat.alert .n { color: #9a3b3b; }
|
||||
|
||||
/* Games — Daily Word pool */
|
||||
.wp-lookup { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 14px 0 6px; }
|
||||
.wp-lookup input {
|
||||
|
||||
@@ -444,6 +444,7 @@ _EVENT_KINDS = {
|
||||
"share_ub", "copy_source", "native_share",
|
||||
"not_today", "less_like_this", "hide_topic",
|
||||
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
||||
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -565,6 +565,11 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"top_topics": top_topics,
|
||||
"shares": shares,
|
||||
"daily": daily,
|
||||
# Boot-failure seatbelt signal — blank-screen risk surfacing.
|
||||
"client_errors": {
|
||||
"today": scalar("SELECT COUNT(*) FROM events WHERE kind='client_error' AND day=date('now')"),
|
||||
"window": kc.get("client_error", 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user