summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 10:47:37 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 10:47:37 +0200
commit0bdce359c0bf19fa98f1b7f9975be32704abe1f0 (patch)
treec67c24b9751c3d8063fc9806a5d6650a3b8c03fb /packages
parent950f4aa6d107e127fb6dc0579beedbff0e6f1af5 (diff)
downloadmeshbay-0bdce359c0bf19fa98f1b7f9975be32704abe1f0.tar.gz
feat(client): fetch the media apps, the player, settings and search on first use
apps.js registers Videos, Music, Photos and every settings pane through lazy.js; the group page does the same for the video player and the settings panel, and the shell for the search page. The first download goes from 49 modules / 386 KB gzip to 37 / 289 KB; opening Music now fetches music-app.js and media-tiles.js and nothing of Videos. test_first_load_is_lean holds the eager graph; test_spa_imports checks that every on-demand load names an export that exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/lazy.js41
-rw-r--r--packages/meshbay-hub/tests/test_first_load_is_lean.py54
-rw-r--r--packages/meshbay-hub/tests/test_spa_imports.py33
6 files changed, 153 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 856a774..6b20a63 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -20,7 +20,7 @@ import {
} from './hub-client.js';
import { startIdleWatch, markActive } from './idle.js';
import { GroupPage } from './group-page.js';
-import { SearchPage } from './search-page.js';
+import { lazy } from './lazy.js';
import { ConnectionPool } from './connection-pool.js';
import { MusicPlayerBar } from './music-player.js';
import { NameModal } from './playlist-menu.js';
@@ -623,6 +623,13 @@ function SetupWelcome({ onDismiss }) {
</div>`;
}
+// ── Lazy-loaded Search page ──────────────────────────────────────────────────
+//
+// It renders every app's results, so importing it here would bring every app
+// into the first download.
+const SearchPage = lazy(() => import('./search-page.js'), 'SearchPage',
+ html`<div class="page-content"><p class="page-message"><span class="spinner"></span></p></div>`);
+
// ── Lazy-loaded Create Group page ────────────────────────────────────────────
let _CreateGroupPage = null;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index eec2158..8ae4db4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -1,14 +1,19 @@
import { ChatPanel } from './chat-app.js';
import { FilesPanel } from './files-app.js';
-import { VideoApp } from './video-app.js';
-import { MusicApp } from './music-app.js';
-import { PhotosApp } from './photos-app.js';
-import { ChatSettings } from './chat-app-settings.js';
-import { VideoSettings } from './video-app-settings.js';
-import { MusicSettings } from './music-app-settings.js';
-import { PhotoSettings } from './photos-app-settings.js';
-import { HelloWorldApp } from './helloworld-app.js';
-import { HelloWorldSettings } from './helloworld-app-settings.js';
+import { lazy } from './lazy.js';
+
+// Chat and Files open with the group; every other app, and every settings
+// pane, is fetched the first time it is shown (lazy.js) — a member who only
+// chats never downloads the Videos app.
+const VideoApp = lazy(() => import('./video-app.js'), 'VideoApp');
+const MusicApp = lazy(() => import('./music-app.js'), 'MusicApp');
+const PhotosApp = lazy(() => import('./photos-app.js'), 'PhotosApp');
+const ChatSettings = lazy(() => import('./chat-app-settings.js'), 'ChatSettings');
+const VideoSettings = lazy(() => import('./video-app-settings.js'), 'VideoSettings');
+const MusicSettings = lazy(() => import('./music-app-settings.js'), 'MusicSettings');
+const PhotoSettings = lazy(() => import('./photos-app-settings.js'), 'PhotoSettings');
+const HelloWorldApp = lazy(() => import('./helloworld-app.js'), 'HelloWorldApp');
+const HelloWorldSettings = lazy(() => import('./helloworld-app-settings.js'), 'HelloWorldSettings');
/**
* Whether apps marked `dev` are shown. Opt in once with `?dev=1`, off with
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 f0a98e4..2ee6e05 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -13,8 +13,12 @@ import { APPS, visibleApps } from './apps.js';
import { GroupName } from './group-name.js';
import { useStickyBand } from './sticky.js';
import { FilePreview } from './files-app.js';
-import { VideoPlayer } from './video-player.js';
-import { GroupSettingsPanel } from './group-settings.js';
+import { lazy } from './lazy.js';
+
+// Fetched the first time a video is played / the Settings tab is opened.
+const VideoPlayer = lazy(() => import('./video-player.js'), 'VideoPlayer',
+ html`<div class="video-overlay"><p class="page-message"><span class="spinner"></span></p></div>`);
+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
new file mode 100644
index 0000000..30a564c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
@@ -0,0 +1,41 @@
+// A component whose code is fetched the first time it is shown.
+//
+// Every static `import` reachable from app.js is downloaded before anything
+// renders, whether or not the visitor ever opens it: the Videos app, the video
+// player and the search page used to cost a member who only chats. Wrapping a
+// component here moves its module out of that first download and into the
+// moment it is needed; once loaded it is the component itself, rendered with
+// the same props, and every later mount is immediate.
+//
+// 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.
+
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+
+const SPINNER = html`<p class="page-message"><span class="spinner"></span></p>`;
+
+function lazy(load, name, placeholder = SPINNER) {
+ let Loaded = null;
+ let pending = null;
+
+ function Lazy(props) {
+ const [, setReady] = useState(Loaded !== null);
+ useEffect(() => {
+ if (Loaded) return undefined;
+ let alive = true;
+ if (!pending) {
+ pending = load().then(
+ (m) => { Loaded = m[name]; },
+ (e) => { pending = null; throw e; });
+ }
+ pending.then(() => { if (alive) setReady(true); }, () => {});
+ return () => { alive = false; };
+ }, []);
+ if (!Loaded) return placeholder;
+ return html`<${Loaded} ...${props} />`;
+ }
+ Lazy.displayName = `Lazy(${name})`;
+ return Lazy;
+}
+
+export { lazy };
diff --git a/packages/meshbay-hub/tests/test_first_load_is_lean.py b/packages/meshbay-hub/tests/test_first_load_is_lean.py
new file mode 100644
index 0000000..74cd880
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py
@@ -0,0 +1,54 @@
+"""
+What a browser downloads before the first screen renders.
+
+ES modules load their static `import` graph up front, whether the visitor opens
+what it leads to or not. The Videos, Music and Photos apps, the video player,
+the group settings and the search page used to be part of it — about a quarter
+of the client, paid by a member who only opens the chat. They are now fetched
+the first time they are shown (lazy.js); one static import added back anywhere
+on the path from app.js would quietly undo that, and nothing else would fail.
+"""
+
+import re
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+STATIC_IMPORT = re.compile(
+ r"""^\s*(?:import|export)\b[^'";]*?\bfrom\s*['"]\./([^'"]+)['"]"""
+ r"""|^\s*import\s*['"]\./([^'"]+)['"]""", re.M)
+
+# Loaded on demand, never at start-up.
+ON_DEMAND = {
+ "video-app.js", "music-app.js", "photos-app.js", "helloworld-app.js",
+ "chat-app-settings.js", "video-app-settings.js", "music-app-settings.js",
+ "photos-app-settings.js", "helloworld-app-settings.js",
+ "video-player.js", "group-settings.js", "search-page.js",
+}
+
+
+def _eager() -> set[str]:
+ seen, stack = set(), ["app.js"]
+ while stack:
+ name = stack.pop()
+ if name in seen or not (STATIC / name).exists():
+ continue
+ seen.add(name)
+ text = (STATIC / name).read_text(encoding="utf-8")
+ stack += [a or b for a, b in STATIC_IMPORT.findall(text)]
+ return seen
+
+
+def test_the_first_download_leaves_the_apps_out():
+ eager = _eager()
+ assert len(eager) > 20, "the import graph was not followed — re-read this test"
+ brought_back = sorted(eager & ON_DEMAND)
+ assert not brought_back, (
+ f"loaded at start-up again: {brought_back} — a static import on the "
+ f"path from app.js; import it through lazy.js instead")
+
+
+def test_every_module_loaded_on_demand_still_exists():
+ """A rename would make the check above pass for a file nobody serves."""
+ missing = sorted(n for n in ON_DEMAND if not (STATIC / n).exists())
+ assert not missing, missing
diff --git a/packages/meshbay-hub/tests/test_spa_imports.py b/packages/meshbay-hub/tests/test_spa_imports.py
index 230f33f..8be70cb 100644
--- a/packages/meshbay-hub/tests/test_spa_imports.py
+++ b/packages/meshbay-hub/tests/test_spa_imports.py
@@ -79,9 +79,36 @@ def test_the_check_can_see_a_real_module():
finding imports would make the test above pass over an empty set, which
looks exactly like success.
"""
- source = (STATIC / "apps.js").read_text(encoding="utf-8")
+ source = (STATIC / "group-page.js").read_text(encoding="utf-8")
found = IMPORT.findall(source)
assert len(found) >= 5, (
- "the import pattern no longer matches apps.js — this test is then "
+ "the import pattern no longer matches group-page.js — this test is then "
"checking nothing")
- assert "configurableApps" in _exported(source)
+ assert "GroupPage" in _exported(source)
+
+
+# `lazy(() => import('./x.js'), 'Name')` (lazy.js), and the hand-written form
+# `import('./x.js').then(m => { _Page = m.Name; ...`.
+LAZY = re.compile(r"lazy\(\s*\(\)\s*=>\s*import\('\./([^']+)'\),\s*'(\w+)'")
+THEN = re.compile(r"import\('\./([^']+)'\)\.then\(\s*m\s*=>\s*\{\s*\w+\s*=\s*m\.(\w+)")
+
+
+def test_every_module_loaded_on_demand_exports_what_is_asked_for():
+ """
+ The same fault as above, one step later: a component fetched on demand
+ under a name its module does not export renders a spinner that never
+ ends — no error, since the fetch itself succeeded.
+ """
+ problems, seen = [], 0
+ for path in sorted(STATIC.glob("*.js")):
+ source = path.read_text(encoding="utf-8")
+ for spec, name in LAZY.findall(source) + THEN.findall(source):
+ seen += 1
+ target = STATIC / spec
+ if not target.exists():
+ problems.append(f"{path.name}: loads ./{spec} — no such file")
+ elif name not in _exported(target.read_text(encoding="utf-8")):
+ problems.append(f"{path.name}: loads {{{name}}} from ./{spec}, "
+ f"which does not export it")
+ assert seen >= 15, f"only {seen} on-demand loads found — is the pattern stale?"
+ assert not problems, "unresolved on-demand loads:\n" + "\n".join(problems)