diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-17 03:40:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-17 03:40:52 +0200 |
| commit | f4fd6db8faa15bf02f38a14b652cc46936f8d6bf (patch) | |
| tree | 396cbe7fe188fb565231be937ccfa6419384814f /packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js | |
| parent | 929bfcccf8928a30d86d48a8b0137db0dd4a1ee0 (diff) | |
| download | meshbay-f4fd6db8faa15bf02f38a14b652cc46936f8d6bf.tar.gz | |
spa: a blank page can never be silent again
boot-guard.js is a classic script loaded before the module graph, so it
survives the graph failing to link. If #app is still empty after ten
seconds it names what failed and offers a reset of this origin — cache,
storage, databases and the service worker, which clearing the cache does
not touch.
Two real defects found building it: openDB never settled when an upgrade
was blocked by another tab, and a connection it gave up on stayed open
and squatted the database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js | 200 |
1 files changed, 200 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js b/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js new file mode 100644 index 0000000..f5e3aa1 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/boot-guard.js @@ -0,0 +1,200 @@ +/** + * The page can never stay silently blank. + * + * A reader once had nothing but white for an evening: the shell arrived, the + * modules were served from the browser's own store so not one request reached + * the hub, and nothing ever rendered. No message, no error on screen, nothing + * in the server's log to look at — the same account worked in a private window + * and in another browser, which is the signature of something wrong in this + * origin's stored state rather than in what was deployed. Clearing the site's + * data fixed it. Clearing the *cache*, three times, had not: a service worker + * and IndexedDB are not the cache, and nothing on screen said so. + * + * This answers the *silence*, not whatever caused it. A blank page is a bug + * report nobody can write, and a phone has no console to open. + * + * **A classic script, deliberately, and first.** The failure it guards against + * includes the module graph never linking — one bad module and no module code + * runs at all, so a guard inside `app.js` would be part of what failed. This + * one has no imports and cannot be stopped by them. + * + * It does nothing when the application mounts, which is the ordinary case: it + * looks once, late, and speaks only to a reader already staring at nothing. + */ +(function () { + 'use strict'; + + // Long enough that a cold phone on a slow network is never interrupted — + // argon2's wasm, forty modules and a catalogue — and short enough that + // nobody sits in front of white wondering. The app mounts in well under two + // seconds when it mounts at all. + var GIVE_UP_MS = 10000; + var problems = []; + + function note(what) { + if (problems.length < 5) problems.push(String(what).slice(0, 200)); + } + + // Capture phase: a `<script>` or stylesheet that fails to load fires `error` + // at the element, and that one does not bubble. + window.addEventListener('error', function (e) { + if (e && e.target && e.target !== window && e.target.src) { + note('could not load ' + String(e.target.src).replace(/^https?:\/\/[^/]+/, '')); + } else if (e && e.message) { + note(e.message); + } + }, true); + + window.addEventListener('unhandledrejection', function (e) { + var r = e && e.reason; + note((r && (r.message || r)) || 'a promise rejected'); + }); + + var FR = (navigator.language || '').toLowerCase().indexOf('fr') === 0; + var TEXT = FR ? { + title: 'MeshBay n’a pas pu démarrer', + body: 'L’application s’est arrêtée avant d’afficher quoi que ce soit. ' + + 'Cela vient presque toujours des données que ce navigateur garde pour ce site.', + retry: 'Réessayer', + reset: 'Réinitialiser les données de ce site', + warn: 'Vous devrez vous reconnecter. Vos fichiers et vos groupes ne sont pas ' + + 'touchés : ils vivent sur les nœuds, pas ici.', + doing: 'Réinitialisation…', + blocked: 'Un autre onglet de ce site garde la base ouverte. Fermez les autres ' + + 'onglets meshbay, puis réessayez.', + } : { + title: 'MeshBay could not start', + body: 'The application stopped before it drew anything. This is almost always ' + + 'the data this browser keeps for this site.', + retry: 'Try again', + reset: 'Reset this site’s data', + warn: 'You will have to sign in again. Your files and groups are untouched: ' + + 'they live on the nodes, not here.', + doing: 'Resetting…', + blocked: 'Another tab of this site is holding the database open. Close the ' + + 'other meshbay tabs, then try again.', + }; + + /** + * Deleting a database waits for every connection to it to close, and another + * tab of this site is a connection. Unwatched, `deleteDatabase` then does + * nothing at all and says nothing — so this page would reload into the state + * it had just promised to clear, which is the bug this file exists to end. + * Blocked deletions are named and handed back. + */ + function dropDatabase(name) { + return new Promise(function (res) { + var req; + try { req = indexedDB.deleteDatabase(name); } catch (e) { return res(null); } + var settled = false; + var end = function (v) { if (!settled) { settled = true; res(v); } }; + req.onsuccess = function () { end(null); }; + req.onerror = function () { end(null); }; + req.onblocked = function () { end(name); }; + setTimeout(function () { end(name); }, 2500); + return undefined; + }); + } + + /** Everything this origin holds, and the service worker with it. */ + function resetSiteData(done) { + var waiting = []; + var blocked = []; + try { localStorage.clear(); } catch (e) { /* private mode */ } + try { sessionStorage.clear(); } catch (e) { /* private mode */ } + + if (window.indexedDB) { + var names = indexedDB.databases + ? indexedDB.databases().then(function (dbs) { + return (dbs || []).map(function (d) { return d && d.name; }).filter(Boolean); + }).catch(function () { return ['meshbay', 'meshbay_keys']; }) + // Safari and older engines have no `databases()`; these are ours. + : Promise.resolve(['meshbay', 'meshbay_keys']); + waiting.push(names.then(function (list) { + return Promise.all(list.map(dropDatabase)).then(function (results) { + results.forEach(function (n) { if (n) blocked.push(n); }); + }); + }).catch(function () {})); + } + + if (window.caches && caches.keys) { + waiting.push(caches.keys().then(function (keys) { + return Promise.all((keys || []).map(function (k) { return caches.delete(k); })); + }).catch(function () {})); + } + + if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) { + waiting.push(navigator.serviceWorker.getRegistrations().then(function (regs) { + return Promise.all((regs || []).map(function (r) { return r.unregister(); })); + }).catch(function () {})); + } + + // Bounded: a hung unregister must not leave the reader on "Resetting…" for + // ever, which would be this bug wearing a different hat. + var fired = false; + var finish = function () { if (!fired) { fired = true; done(blocked); } }; + Promise.all(waiting).then(finish).catch(finish); + setTimeout(finish, 4000); + } + + function el(tag, style, text) { + var n = document.createElement(tag); + if (style) n.setAttribute('style', style); + if (text) n.textContent = text; + return n; + } + + function show(root) { + var BTN = 'display:block;width:100%;margin:8px 0;padding:12px 16px;font:inherit;' + + 'font-size:15px;border-radius:8px;border:1px solid #c9ccd1;background:#fff;' + + 'color:#111;cursor:pointer'; + var box = el('div', 'max-width:34em;margin:12vh auto;padding:0 20px;' + + 'font:15px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#111'); + + box.appendChild(el('h1', 'font-size:20px;margin:0 0 12px', TEXT.title)); + box.appendChild(el('p', 'margin:0 0 16px;color:#444', TEXT.body)); + + var retry = el('button', BTN, TEXT.retry); + retry.addEventListener('click', function () { location.reload(); }); + box.appendChild(retry); + + var reset = el('button', BTN + ';border-color:#b23;color:#b23', TEXT.reset); + reset.addEventListener('click', function () { + reset.disabled = true; + reset.textContent = TEXT.doing; + resetSiteData(function (blocked) { + if (!blocked.length) { location.reload(); return; } + // Reloading here would be a lie: the database is still there, held by + // another tab, and the page would come back exactly as broken. + reset.disabled = false; + reset.textContent = TEXT.reset; + var why = el('p', 'margin:12px 0 0;color:#b23;font-size:13px', + TEXT.blocked + ' (' + blocked.join(', ') + ')'); + why.setAttribute('data-blocked', blocked.join(',')); + box.appendChild(why); + }); + }); + box.appendChild(reset); + + box.appendChild(el('p', 'margin:12px 0 0;color:#666;font-size:13px', TEXT.warn)); + + if (problems.length) { + // Verbatim, and on screen. Nothing else reaches a reader who cannot open + // a console, and it is the first thing anybody diagnosing this will ask. + var pre = el('pre', 'margin:16px 0 0;padding:10px;background:#f3f4f6;' + + 'border-radius:6px;font-size:12px;white-space:pre-wrap;word-break:break-word;' + + 'color:#333', problems.join('\n')); + box.appendChild(pre); + } + + root.appendChild(box); + } + + setTimeout(function () { + var root = document.getElementById('app'); + // Mounted, which is the ordinary case and the end of this script's + // involvement. A later render replaces whatever is here anyway. + if (!root || root.firstChild) return; + show(root); + }, GIVE_UP_MS); +}()); |