diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 10:47:37 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 10:47:37 +0200 |
| commit | 0bdce359c0bf19fa98f1b7f9975be32704abe1f0 (patch) | |
| tree | c67c24b9751c3d8063fc9806a5d6650a3b8c03fb /packages | |
| parent | 950f4aa6d107e127fb6dc0579beedbff0e6f1af5 (diff) | |
| download | meshbay-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')
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) |