Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions packages/app-core/src/state/startup-phase-hydrate-nonblocking.test.ts
Original file line number Diff line number Diff line change
@@ -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<never>(() => {});

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" });
});
});
108 changes: 60 additions & 48 deletions packages/app-core/src/state/startup-phase-hydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | undefined;
const cfgAvatarIdx = cfgUi?.avatarIndex;
if (typeof cfgAvatarIdx === "number" && Number.isFinite(cfgAvatarIdx)) {
const normalized = normalizeAvatarIndex(cfgAvatarIdx);
Expand All @@ -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()}`),
);
Expand All @@ -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.
Expand All @@ -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();
Expand Down
Loading