eleven / ocho Public

web: parallelize the embed boot's round-trips (#1404)

bootEmbed ran its network phases as a strict waterfall — custom apps, then
roster, then member apps, then /api/threads — four serialized round-trips
(and the custom-app + member-app phases each race a 4 s timeout, so a slow
instance could stack 8 s of pure waiting) before the native loading veil
could lift.

The phases are independent of each other, so they now run together: the
instance key imports first (local, no network — applyRoster can then decrypt
labels in its first pass), custom apps / roster / threads fetch in parallel,
and member apps start as soon as the roster lands, overlapping the
custom-app phase so the two timeouts share one window. Member-app blob
decrypts inside sandbox.js also run concurrently now, registering in listing
order so registration stays deterministic.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam pushed by codex agent, operated by paul@keymail.dev 35c77d3b6c77d7325a3e822dd0a2f861159ab087 parent c3c1c31
2 files changed, +47 −29
  • web/static/sandbox.js +23 −14
  • web/static/shell.js +24 −15
diff --git a/web/static/sandbox.js b/web/static/sandbox.js
index 9a59571..3409057 100644
--- a/web/static/sandbox.js
+++ b/web/static/sandbox.js
@@ -907,20 +907,29 @@ export async function loadMemberApps({ token, instanceKey, authorName }) {
const r = await fetch(`/api/member-apps?token=${encodeURIComponent(token)}`);
if (!r.ok) return;
const { apps } = await r.json();
- for (const entry of apps || []) {
- if (!entry || typeof entry.id !== "string" || registeredIds.has(entry.id)) continue;
- try {
- const doc = await decrypt(instanceKey, entry.blob);
- const res = registerMemberApp({
- id: entry.id,
- manifest: doc && doc.manifest,
- source: doc && doc.source,
- author: (authorName && authorName(entry.author_id)) || "",
- });
- if (!res.ok) console.warn(`member app ${entry.id} skipped: ${res.reason}`);
- } catch (e) {
- console.warn(`member app ${entry.id} failed to load:`, e);
- }
+ // Decrypt every blob concurrently (they're independent), then register
+ // in listing order so registration stays deterministic.
+ const opened = await Promise.all(
+ (apps || []).map(async (entry) => {
+ if (!entry || typeof entry.id !== "string" || registeredIds.has(entry.id)) return null;
+ try {
+ return { entry, doc: await decrypt(instanceKey, entry.blob) };
+ } catch (e) {
+ console.warn(`member app ${entry.id} failed to load:`, e);
+ return null;
+ }
+ }),
+ );
+ for (const item of opened) {
+ if (!item) continue;
+ const { entry, doc } = item;
+ const res = registerMemberApp({
+ id: entry.id,
+ manifest: doc && doc.manifest,
+ source: doc && doc.source,
+ author: (authorName && authorName(entry.author_id)) || "",
+ });
+ if (!res.ok) console.warn(`member app ${entry.id} skipped: ${res.reason}`);
}
} catch (e) {
console.error("member apps skipped:", e);
diff --git a/web/static/shell.js b/web/static/shell.js
index 4b4540d..19018fc 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1417,17 +1417,28 @@ async function bootEmbed() {
// rules) — classed before the first render so the feed never flashes.
if (embed.mode === "command") document.body.classList.add("command-host");
try {
- // The native shell can land straight in a custom app's thread, so the
- // embed path needs registration complete before its render too.
- await loadCustomApps();
setMyUserId(embed.myUserId || "");
setCurrentUser(myUserId, session.name || "New member");
- await loadRoster(); // token-only; gives sender labels + profile photos
+ // The instance key first (a local unwrap, no network): applyRoster can
+ // then decrypt labels in its first pass, and member apps below never hit
+ // their no-key no-op.
if (embed.instanceKey) {
try {
setInstanceKey(await importKey(embed.instanceKey), embed.instanceKey);
} catch {}
}
+ // The boot round-trips are independent of each other, so they run
+ // together rather than as a waterfall — this pane is behind the native
+ // loading veil, and every serialized fetch here holds the veil longer.
+ // The native shell can land straight in a custom app's thread, so
+ // registration must still be complete before the render below.
+ const customAppsLoad = loadCustomApps();
+ const rosterLoad = loadRoster(); // token-only; gives sender labels + profile photos
+ // Thread objects for kind/members/name_blob, but only the active thread
+ // has a key — the others can't be unwrapped here without the private key.
+ const threadsLoad = fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)
+ .then((r) => r.json())
+ .catch(() => ({}));
// Per-viewer names (#577): the embed never holds userPriv, so it can't
// open the nicknames blob — the native shell (which can) hands the
// RESOLVED uid → name map over the bridge instead. Fold it in as
@@ -1442,20 +1453,18 @@ async function bootEmbed() {
// The seed is baked at webview creation and parked webviews outlive a
// rename — the native shell pushes fresh maps here after each fold.
window.__lchatSetNames = (map) => applyEmbedNames(map);
- // The native shell can land straight in a member app's thread too — the
- // sandboxed proxies must be registered before the render below.
- await loadMemberAppsNow();
const tid = embed.threadId;
threadKeyB64[tid] = embed.threadKey;
threadKeys[tid] = await importKey(embed.threadKey);
- // Fetch thread objects for kind/members/name_blob, but only the active thread
- // has a key — the others can't be unwrapped here without the private key.
- try {
- const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json();
- setThreads(r.threads || []);
- } catch {
- setThreads([]);
- }
+ // Member apps read roster labels for their bylines, so they start once
+ // the roster lands — still overlapping the custom-app phase, so the two
+ // load timeouts run in the same window instead of back to back. The
+ // native shell can land straight in a member app's thread too — the
+ // sandboxed proxies must be registered before the render below.
+ await rosterLoad;
+ await Promise.all([customAppsLoad, loadMemberAppsNow()]);
+ const r = await threadsLoad;
+ setThreads(r.threads || []);
if (!threads.find((t) => t.id === tid)) threads.push({ id: tid, kind: "dm", members: [] });
threadName[tid] = await displayName(threads.find((t) => t.id === tid));
// 🤖 The native shell can ask for a specific view — its companion split