aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-28 12:29:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-28 12:29:20 +0200
commit34576a1ccda1ee192e8efbc653cb436111a9c5e8 (patch)
tree261a6956bf139f4ea28353501a3ada84e3f32f03 /packages
parent694a3832f4b57d007b79b3b21dd47b55e6ef3c42 (diff)
downloadmeshbay-34576a1ccda1ee192e8efbc653cb436111a9c5e8.tar.gz
fix(hub): say so when a lazily loaded view cannot be fetched0.16
A tab opened before a hub deploy got 404 for every module it had not loaded yet, and lazy.js kept its spinner for good. It now shows a notice with a Reload button and logs the failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/lazy.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css8
-rw-r--r--packages/meshbay-hub/tests/harness/lazy_failure_probe.py151
-rw-r--r--packages/meshbay-hub/tests/test_lazy_load_failure.py60
15 files changed, 277 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 0827104..7c9b2f0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -16,8 +16,9 @@ import { FilePreview } from './files-app.js';
import { lazy } from './lazy.js';
// Fetched the first time a video is played / the Settings tab is opened.
+const playerFrame = (content) => html`<div class="video-overlay video-player-overlay">${content}</div>`;
const VideoPlayer = lazy(() => import('./video-player.js'), 'VideoPlayer',
- html`<div class="video-overlay video-player-overlay"><p class="page-message"><span class="spinner"></span></p></div>`);
+ playerFrame(html`<p class="page-message"><span class="spinner"></span></p>`), playerFrame);
const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSettingsPanel');
import { reportIndexPush } from './index-dock.js';
import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
index 30a564c..fda73c0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
@@ -9,17 +9,45 @@
//
// A failed fetch (a network drop on a phone) is not remembered: the next time
// the component is shown it asks again, rather than failing for the session.
+//
+// A failed fetch is also *said*, never left as the placeholder's spinner. The
+// commonest failure is not the network: the hub serves the module graph under
+// `/a/<hash>/` for the current hash only, so a tab opened before a hub deploy
+// asks for every module it has not loaded yet under a prefix that now answers
+// 404. Such a tab kept Files and Chat (already loaded) and spun for ever on
+// Search, Videos, Music, Photos and the player — with nothing in the console,
+// because the rejection was swallowed here. Asking again cannot help that tab;
+// reloading the page is the one thing that does, so that is what is offered.
+// Not done automatically: a reload that fails the same way would loop, and it
+// would throw away whatever the reader had in progress.
import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
const SPINNER = html`<p class="page-message"><span class="spinner"></span></p>`;
-function lazy(load, name, placeholder = SPINNER) {
+function LoadFailed({ onClose }) {
+ return html`
+ <div class="page-message lazy-failed" role="alert">
+ <p>${t('lazy.load_failed')}</p>
+ <button class="admin-btn" onClick=${() => location.reload()}>${t('lazy.reload')}</button>
+ ${onClose && html`
+ ${' '}<button class="admin-btn" onClick=${onClose}>${t('admin.btn_close')}</button>
+ `}
+ </div>
+ `;
+}
+
+// `frame` wraps the failure notice, so a component that is an overlay (the
+// video player) can put it where the overlay would have been rather than
+// somewhere at the bottom of the page, under nothing anyone is looking at.
+function lazy(load, name, placeholder = SPINNER, frame = (content) => content) {
let Loaded = null;
let pending = null;
function Lazy(props) {
const [, setReady] = useState(Loaded !== null);
+ const [failed, setFailed] = useState(false);
useEffect(() => {
if (Loaded) return undefined;
let alive = true;
@@ -28,11 +56,15 @@ function lazy(load, name, placeholder = SPINNER) {
(m) => { Loaded = m[name]; },
(e) => { pending = null; throw e; });
}
- pending.then(() => { if (alive) setReady(true); }, () => {});
+ pending.then(() => { if (alive) setReady(true); }, (e) => {
+ console.error(`[MeshBay] could not load ${name}:`, e);
+ if (alive) setFailed(true);
+ });
return () => { alive = false; };
}, []);
- if (!Loaded) return placeholder;
- return html`<${Loaded} ...${props} />`;
+ if (Loaded) return html`<${Loaded} ...${props} />`;
+ if (failed) return frame(html`<${LoadFailed} onClose=${props.onClose} />`);
+ return placeholder;
}
Lazy.displayName = `Lazy(${name})`;
return Lazy;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 41a56be..36a9423 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -888,6 +888,8 @@ export default {
// Group misc
'group.retry': 'Erneut versuchen',
+ 'lazy.load_failed': 'Dieser Teil der Oberfläche konnte nicht geladen werden. Der Hub wurde möglicherweise aktualisiert, seit diese Seite geöffnet wurde.',
+ 'lazy.reload': 'Seite neu laden',
// Sidebar
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 7c06815..67cf5f4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -984,6 +984,8 @@ export default {
'group.mute': 'Mute notifications',
'group.unmute': 'Unmute notifications',
'group.retry': 'Retry',
+ 'lazy.load_failed': 'This part of the interface could not be loaded. The hub may have been updated since this page was opened.',
+ 'lazy.reload': 'Reload the page',
'profile.title': 'Profile',
'usermenu.profile': 'Profile',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 939b96c..1e5f32f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -882,6 +882,8 @@ export default {
// Group
'group.retry': 'Reintentar',
+ 'lazy.load_failed': 'No se pudo cargar esta parte de la interfaz. Es posible que el hub se haya actualizado desde que se abrió esta página.',
+ 'lazy.reload': 'Recargar la página',
// Sidebar
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c546d4e..f1b3da3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -885,6 +885,8 @@ export default {
// Group
'group.retry': 'Réessayer',
+ 'lazy.load_failed': 'Cette partie de l\'interface n\'a pas pu être chargée. Le hub a peut-être été mis à jour depuis l\'ouverture de cette page.',
+ 'lazy.reload': 'Recharger la page',
// Sidebar
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 893a563..4120b1d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -884,6 +884,8 @@ export default {
// Group misc
'group.retry': 'Riprova',
+ 'lazy.load_failed': 'Impossibile caricare questa parte dell\'interfaccia. L\'hub potrebbe essere stato aggiornato da quando questa pagina è stata aperta.',
+ 'lazy.reload': 'Ricarica la pagina',
// Sidebar
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 1a03c52..b37a027 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -872,6 +872,8 @@ export default {
// Group
'group.retry': '再試行',
+ 'lazy.load_failed': 'インターフェースのこの部分を読み込めませんでした。このページを開いた後に hub が更新された可能性があります。',
+ 'lazy.reload': 'ページを再読み込み',
// Sidebar
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 8ae0ab3..eeebaf8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -886,6 +886,8 @@ export default {
// Group
'group.retry': 'Opnieuw proberen',
+ 'lazy.load_failed': 'Dit deel van de interface kon niet worden geladen. De hub is mogelijk bijgewerkt sinds deze pagina werd geopend.',
+ 'lazy.reload': 'Pagina opnieuw laden',
// Sidebar
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index a2cc5bb..9be95db 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -904,6 +904,8 @@ export default {
// Group (supplementary)
'group.retry': 'Ponów',
+ 'lazy.load_failed': 'Nie udało się wczytać tej części interfejsu. Hub mógł zostać zaktualizowany od czasu otwarcia tej strony.',
+ 'lazy.reload': 'Wczytaj stronę ponownie',
// Sidebar (supplementary)
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 2b98298..cea304e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -883,6 +883,8 @@ export default {
// Group misc
'group.retry': 'Tentar novamente',
+ 'lazy.load_failed': 'Não foi possível carregar esta parte da interface. O hub pode ter sido atualizado desde que esta página foi aberta.',
+ 'lazy.reload': 'Recarregar a página',
// Sidebar
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 4ac583a..92952f4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -860,6 +860,8 @@ export default {
// Group
'group.retry': '重试',
+ 'lazy.load_failed': '无法加载界面的这一部分。自打开此页面以来,hub 可能已更新。',
+ 'lazy.reload': '重新加载页面',
// Sidebar
'sidebar.node': 'Node',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 111c03d..29d4181 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -491,6 +491,14 @@ a:hover { text-decoration: underline; }
margin-bottom: 24px;
}
+/* lazy.js: a view whose code could not be fetched says so, in place of the
+ spinner it would otherwise have kept for good. Inside the player's overlay
+ the backdrop is near-black whatever the theme, so the text colour cannot
+ come from the theme there. */
+.lazy-failed { text-align: center; max-width: 520px; margin: 24px auto; }
+.lazy-failed p { margin-bottom: 12px; }
+.video-overlay .lazy-failed { color: #e2e8f0; }
+
/* ── Page center (login / register) ───────────────────────────────────────── */
.page-center {
diff --git a/packages/meshbay-hub/tests/harness/lazy_failure_probe.py b/packages/meshbay-hub/tests/harness/lazy_failure_probe.py
new file mode 100644
index 0000000..357529a
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/lazy_failure_probe.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""
+What `lazy()` shows when the module it asks for answers 404.
+
+The shape found live: a tab opened before a hub deploy asks for its not-yet-
+loaded modules under the previous `/a/<hash>/` prefix, which the new hub no
+longer serves. Every lazily loaded view — Search, Videos, Music, Photos, the
+video player — sat on its spinner for good, with an empty console. This
+renders the shipped `lazy.js` against a module that does not exist, and one
+that does, and reads back what is on the page.
+
+ lazy_failure_probe.py
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8763
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="plain"></div><div id="framed"></div><div id="fine"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { lazy } from '/lazy.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const errors = [];
+const origError = console.error;
+console.error = (...a) => { errors.push(a.map(String).join(' ')); origError(...a); };
+
+const read = (id) => {
+ const root = document.getElementById(id);
+ return {
+ spinner: !!root.querySelector('.spinner'),
+ notice: (root.querySelector('.lazy-failed p') || {}).textContent || null,
+ buttons: [...root.querySelectorAll('.lazy-failed button')].map((b) => b.textContent.trim()),
+ overlay: !!root.querySelector('.video-player-overlay .lazy-failed'),
+ text: root.textContent.trim(),
+ };
+};
+
+(async () => {
+ try {
+ await initLocale();
+ // The stale tab's request: a module under a prefix nobody serves.
+ const Gone = lazy(() => import('/a/0000000000/gone.js'), 'Gone');
+ const frame = (c) => html`<div class="video-overlay video-player-overlay">${c}</div>`;
+ const GoneOverlay = lazy(() => import('/a/0000000000/player.js'), 'Player',
+ frame(html`<p class="page-message"><span class="spinner"></span></p>`), frame);
+ // A module that exists still renders as itself.
+ const Fine = lazy(() => import('/icon.js'), 'Icon');
+
+ let closed = 0;
+ render(html`<${Gone} />`, document.getElementById('plain'));
+ render(html`<${GoneOverlay} onClose=${() => { closed += 1; }} />`,
+ document.getElementById('framed'));
+ render(html`<${Fine} name="close" />`, document.getElementById('fine'));
+ await sleep(1500);
+
+ const out = { plain: read('plain'), framed: read('framed'), errors,
+ fine: !!document.querySelector('#fine svg, #fine .icon') };
+ const btns = document.querySelectorAll('#framed .lazy-failed button');
+ if (btns[1]) btns[1].click();
+ out.closed = closed;
+ parent.postMessage(out, '*');
+ } catch (err) {
+ parent.postMessage({ error: String((err && err.stack) || err) }, '*');
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><iframe src="/case" style="width:900px;height:700px;border:0"></iframe>
+<script>
+addEventListener('message', (e) => fetch('/log', { method: 'POST', body: JSON.stringify(e.data) }));
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ elif path == "/case":
+ self._send(FRAME.encode(), "text/html; charset=utf-8")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=900,700",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ deadline = time.time() + 60
+ while not RECORDS and time.time() < deadline:
+ time.sleep(0.2)
+ proc.terminate()
+ proc.wait(timeout=20)
+ if not RECORDS:
+ print("the page never reported", file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0]))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_lazy_load_failure.py b/packages/meshbay-hub/tests/test_lazy_load_failure.py
new file mode 100644
index 0000000..00c2030
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_lazy_load_failure.py
@@ -0,0 +1,60 @@
+"""
+A view whose module cannot be fetched says so, instead of spinning for good.
+
+Found live after a hub deploy: the hub serves the module graph under
+`/a/<hash>/` for the current hash only, so a tab opened before the deploy asked
+for Search, Videos, Music, Photos and the player under a prefix that now
+answers 404. `lazy.js` swallowed the rejection and kept the placeholder, so
+every one of them showed a spinner for ever, with an empty console, while
+Files and Chat — loaded before the deploy — worked. A reload fixed it, and
+nothing on screen said so.
+
+Measured in a browser: a rejected dynamic import is the one thing no source
+reading can produce.
+"""
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "lazy_failure_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "lazy.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def probe():
+ run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=120)
+ assert run.returncode == 0, run.stderr.decode()[-2000:]
+ out = json.loads(run.stdout.decode())
+ assert "error" not in out, out["error"]
+ return out
+
+
+@pytest.mark.parametrize("view", ["plain", "framed"])
+def test_a_module_that_answers_404_is_not_a_spinner(probe, view):
+ assert not probe[view]["spinner"], "still spinning over a module that will never come"
+ assert probe[view]["notice"], "nothing on screen says the view failed to load"
+ assert probe[view]["buttons"], "no way offered to reload"
+
+
+def test_the_failure_reaches_the_console(probe):
+ """An empty console is what made this cost a scare instead of a glance."""
+ assert len(probe["errors"]) == 2
+ assert all("could not load" in e for e in probe["errors"])
+
+
+def test_an_overlay_fails_inside_its_overlay_and_can_be_closed(probe):
+ assert probe["framed"]["overlay"], "the player's notice landed outside its overlay"
+ assert len(probe["framed"]["buttons"]) == 2
+ assert probe["closed"] == 1, "the Close button did not reach the caller's onClose"
+ assert len(probe["plain"]["buttons"]) == 1, "no onClose, so no Close button"
+
+
+def test_a_module_that_exists_still_renders(probe):
+ assert probe["fine"]