diff --git a/packages/app-core/src/state/startup-phase-hydrate-nonblocking.test.ts b/packages/app-core/src/state/startup-phase-hydrate-nonblocking.test.ts new file mode 100644 index 0000000000..a5395a21d8 --- /dev/null +++ b/packages/app-core/src/state/startup-phase-hydrate-nonblocking.test.ts @@ -0,0 +1,133 @@ +/** + * Regression: runHydrating() must reach HYDRATION_COMPLETE without blocking on + * slow/hanging non-critical work. + * + * LANE F2 sol-f5-firstload (2026-07-22): post-login "takes forever" was caused + * by runHydrating() serially AWAITING work the landing view never needs: + * - getWalletAddresses() (measured 1.3-12s on cloud containers) + * - a VRM + gaussian-splat world prefetch race capped at 15s + * - fetchAutonomyReplay() + * These now run in the background. This test proves the dashboard becomes + * interactive (HYDRATION_COMPLETE dispatched) even when every one of those + * hangs indefinitely. + */ +import { describe, expect, it, vi } from "vitest"; + +// ── Mocks for module singletons runHydrating touches ────────────────── +const hangForever = () => new Promise(() => {}); + +vi.mock("../api", () => ({ + client: { + // The hangers — must NOT block hydration: + getWalletAddresses: vi.fn(() => hangForever()), + // Fast, awaited config reads (parallelized): + getConfig: vi.fn(async () => ({ ui: {} })), + getStreamSettings: vi.fn(async () => ({ settings: {} })), + hasCustomVrm: vi.fn(async () => false), + hasCustomBackground: vi.fn(async () => false), + }, +})); + +vi.mock("../components/avatar/VrmEngine", () => ({ + // VRM prefetch hangs forever — must NOT block hydration. + prefetchVrmToCache: vi.fn(() => hangForever()), +})); + +vi.mock("./vrm", () => ({ + getVrmUrl: (i: number) => `vrm://${i}`, + getVrmCount: () => 2, + VRM_COUNT: 2, +})); + +vi.mock("./persistence", () => ({ loadUiTheme: () => "dark" })); + +vi.mock("../utils", () => ({ + resolveApiUrl: (p: string) => p, + resolveAppAssetUrl: (p: string) => `asset://${p}`, +})); + +// Keep the rest of the module's imports cheap/no-op. +vi.mock("./internal", () => ({ + loadAvatarIndex: () => 1, + normalizeAvatarIndex: (n: number) => n, +})); +vi.mock("./shell-routing", () => ({ + shouldStartAtCharacterSelectOnLaunch: () => false, +})); +vi.mock("../navigation", () => ({ + COMPANION_ENABLED: true, + tabFromPath: () => null, + isRouteRootPath: () => true, +})); + +// world prefetch uses global fetch — make it hang too. +vi.stubGlobal("fetch", vi.fn(() => hangForever())); + +import { runHydrating } from "./startup-phase-hydrate"; + +function makeDeps() { + const noop = () => {}; + const anoop = async () => {}; + return { + setStartupError: vi.fn(), + setOnboardingLoading: vi.fn(), + hydrateInitialConversationState: vi.fn(async () => null), + requestGreetingWhenRunningRef: { current: async () => {} }, + loadWorkbench: vi.fn(anoop), + loadPlugins: vi.fn(anoop), + loadSkills: vi.fn(anoop), + loadCharacter: vi.fn(anoop), + loadWalletConfig: vi.fn(anoop), + loadInventory: vi.fn(anoop), + loadUpdateStatus: vi.fn(anoop), + checkExtensionStatus: vi.fn(anoop), + pollCloudCredits: vi.fn(noop), + fetchAutonomyReplay: vi.fn(() => hangForever()), + setSelectedVrmIndex: vi.fn(), + setCustomVrmUrl: vi.fn(), + setCustomBackgroundUrl: vi.fn(), + setWalletAddresses: vi.fn(), + setTab: vi.fn(), + setTabRaw: vi.fn(), + onboardingCompletionCommittedRef: { current: false }, + initialTabSetRef: { current: false }, + onboardingMode: "cloud" as unknown as never, + }; +} + +describe("runHydrating — non-blocking first-load (F2)", () => { + it("dispatches HYDRATION_COMPLETE even when wallet, VRM prefetch, world prefetch, and autonomy replay all hang", async () => { + const deps = makeDeps(); + const dispatch = vi.fn(); + const cancelled = { current: false }; + + // Must resolve quickly. If any hang is awaited, this rejects on timeout. + await Promise.race([ + // biome-ignore lint/suspicious/noExplicitAny: test deps shape + runHydrating(deps as any, dispatch, cancelled), + new Promise((_r, reject) => + setTimeout( + () => reject(new Error("runHydrating blocked on non-critical work")), + 3000, + ), + ), + ]); + + expect(dispatch).toHaveBeenCalledWith({ type: "HYDRATION_COMPLETE" }); + }); + + it("does not block hydration on the slow wallet fetch, but still kicks it off", async () => { + const deps = makeDeps(); + const dispatch = vi.fn(); + await runHydrating( + // biome-ignore lint/suspicious/noExplicitAny: test deps shape + deps as any, + dispatch, + { current: false }, + ); + // Wallet setter never resolves (fetch hangs) so it should not have been + // called by the time hydration completes — proving it was deferred. + expect(deps.setWalletAddresses).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ type: "HYDRATION_COMPLETE" }); + }); +}); diff --git a/packages/app-core/src/state/startup-phase-hydrate.ts b/packages/app-core/src/state/startup-phase-hydrate.ts index 2b7553cea2..0e9c79265d 100644 --- a/packages/app-core/src/state/startup-phase-hydrate.ts +++ b/packages/app-core/src/state/startup-phase-hydrate.ts @@ -153,21 +153,36 @@ export async function runHydrating( void deps.loadPlugins(); void deps.loadCharacter(); - // Wallet addresses - try { - deps.setWalletAddresses(await client.getWalletAddresses()); - } catch (e) { - warn("wallet addresses", e); - } + // ── Wallet addresses — DEFERRED, not awaited ─────────────────────── + // Wallet addresses are only consumed by the Eliza Cloud dashboard and + // the wallets/inventory tab — never by the landing (chat/companion) + // view. getWalletAddresses() has been measured at 1.3–12 s on cloud + // containers (steward round-trip), so awaiting it here needlessly + // stalled time-to-interactive by multiple seconds. Fire-and-forget: + // the state populates when it resolves; the dashboard reads it lazily. + void (async () => { + try { + deps.setWalletAddresses(await client.getWalletAddresses()); + } catch (e) { + warn("wallet addresses", e); + } + })(); - // Avatar / VRM selection — resolve from server config, then stream - // settings, then localStorage. Cloud containers that skip onboarding - // have their character defaults written server-side, so we must read - // the config to pick up the correct avatarIndex. + // ── Avatar / VRM selection ───────────────────────────────────────── + // Resolve from server config, then stream settings, then localStorage. + // Cloud containers that skip onboarding have their character defaults + // written server-side, so we must read the config to pick up the + // correct avatarIndex. config + streamSettings are INDEPENDENT reads — + // run them in parallel instead of serially (was ~2× the round-trip). let resolvedIdx = loadAvatarIndex(); - try { - const cfg = await client.getConfig(); - const cfgUi = cfg?.ui as Record | undefined; + const [cfgResult, streamResult] = await Promise.allSettled([ + client.getConfig(), + typeof client.getStreamSettings === "function" + ? client.getStreamSettings() + : Promise.resolve(null), + ]); + if (cfgResult.status === "fulfilled") { + const cfgUi = cfgResult.value?.ui as Record | undefined; const cfgAvatarIdx = cfgUi?.avatarIndex; if (typeof cfgAvatarIdx === "number" && Number.isFinite(cfgAvatarIdx)) { const normalized = normalizeAvatarIndex(cfgAvatarIdx); @@ -176,26 +191,29 @@ export async function runHydrating( deps.setSelectedVrmIndex(resolvedIdx); } } - } catch (e) { - warn("config avatar index", e); + } else { + warn("config avatar index", cfgResult.reason); } - try { - if (typeof client.getStreamSettings === "function") { - const stream = await client.getStreamSettings(); - const si = stream.settings?.avatarIndex; - if (typeof si === "number" && Number.isFinite(si)) { - resolvedIdx = normalizeAvatarIndex(si); - deps.setSelectedVrmIndex(resolvedIdx); - } + // stream settings win over config when present + if (streamResult.status === "fulfilled" && streamResult.value) { + const si = streamResult.value.settings?.avatarIndex; + if (typeof si === "number" && Number.isFinite(si)) { + resolvedIdx = normalizeAvatarIndex(si); + deps.setSelectedVrmIndex(resolvedIdx); } - } catch (e) { - warn("stream settings avatar", e); + } else if (streamResult.status === "rejected") { + warn("stream settings avatar", streamResult.reason); } if (resolvedIdx === 0) { - if (await client.hasCustomVrm()) + // custom vrm + custom background probes are independent — parallelize. + const [hasVrm, hasBg] = await Promise.all([ + client.hasCustomVrm().catch(() => false), + client.hasCustomBackground().catch(() => false), + ]); + if (hasVrm) deps.setCustomVrmUrl(resolveApiUrl(`/api/avatar/vrm?t=${Date.now()}`)); else deps.setSelectedVrmIndex(1); - if (await client.hasCustomBackground()) + if (hasBg) deps.setCustomBackgroundUrl( resolveApiUrl(`/api/avatar/background?t=${Date.now()}`), ); @@ -209,33 +227,26 @@ export async function runHydrating( // noticeable in cloud containers where the CDN round-trip is the // bottleneck. // - // We await the active VRM prefetch (with a 15s timeout) rather than - // firing and forgetting. This ensures the in-memory buffer cache is - // populated *before* HYDRATION_COMPLETE, so the companion scene gets - // an instant cache hit instead of starting a duplicate network download. - // - // Additionally, fire-and-forget prefetches for ALL other VRM assets so - // navigating to the customize/character page doesn't trigger a full - // re-download of every character model. + // Previously this AWAITED the active VRM prefetch (with a 15 s timeout) + // before dispatching HYDRATION_COMPLETE — blocking the dashboard from + // becoming interactive for up to 15 s while 3D assets downloaded. That + // is the single largest post-login stall on cold cache. We now warm the + // cache in the BACKGROUND (fire-and-forget) and let hydration complete + // immediately: the companion scene renders as soon as its assets arrive, + // and because loadGltfAsset joins in-flight downloads via the inflight + // dedup map, the scene still gets the warmed buffer with no duplicate + // fetch — we simply no longer make the ENTIRE dashboard wait on it. if (COMPANION_ENABLED) { const vrmIdx = resolvedIdx > 0 ? resolvedIdx : 1; - const vrmPrefetch = prefetchVrmToCache(getVrmUrl(vrmIdx)); + // Active companion VRM — warm in-memory buffer cache (background). + void prefetchVrmToCache(getVrmUrl(vrmIdx)); + // Gaussian-splat world — warm the browser HTTP cache (background). const theme = loadUiTheme(); const worldUrl = theme === "dark" ? resolveAppAssetUrl("worlds/companion-night.spz") : resolveAppAssetUrl("worlds/companion-day.spz"); - const worldPrefetch = fetch(worldUrl, { cache: "force-cache" }).catch( - () => {}, - ); - // Wait for both but cap at 15s so hydration isn't blocked forever on - // slow networks. Even if the timeout fires, the in-flight prefetch - // continues in the background and loadGltfAsset will join it via the - // inflight dedup map. - await Promise.race([ - Promise.all([vrmPrefetch, worldPrefetch]), - new Promise((resolve) => setTimeout(resolve, 15_000)), - ]); + void fetch(worldUrl, { cache: "force-cache" }).catch(() => {}); // Fire-and-forget: warm the cache for all other VRM assets so the // customize page does not need to re-download them on first visit. @@ -248,7 +259,8 @@ export async function runHydrating( } void deps.pollCloudCredits(); - await deps.fetchAutonomyReplay(); + // Autonomy replay is not needed for first paint — don't block on it. + void deps.fetchAutonomyReplay(); // Tab routing const navPath = getNavigationPathFromWindow();