fix(app): keep reload splash continuous

Treat desktop settings evaluation as part of daemon startup so restored app chrome cannot render between bootstrap phases.
This commit is contained in:
Mohamed Boudra
2026-07-21 10:38:30 +02:00
parent f214261ae5
commit afeb4d18f8
7 changed files with 186 additions and 153 deletions

View File

@@ -65,6 +65,8 @@ export interface DesktopBridgeConfig {
daemonListen?: string; daemonListen?: string;
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */ /** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
hangDaemonStart?: boolean; hangDaemonStart?: boolean;
/** Delay the desktop settings IPC response to exercise startup ordering. */
desktopSettingsDelayMs?: number;
/** /**
* Controls what dialog.ask returns when the daemon management confirm dialog * Controls what dialog.ask returns when the daemon management confirm dialog
* fires. True = confirm (proceed with the action), false = cancel. Defaults to * fires. True = confirm (proceed with the action), false = cancel. Defaults to
@@ -101,6 +103,7 @@ declare global {
__capturedDialogCall: ConfirmDialogCall | undefined; __capturedDialogCall: ConfirmDialogCall | undefined;
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>; __capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>; __recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
__desktopDaemonStartRequested?: boolean;
} }
} }
@@ -129,6 +132,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
let daemonRunning = true; let daemonRunning = true;
let currentPid: number | null = cfg.daemonPid ?? null; let currentPid: number | null = cfg.daemonPid ?? null;
let startCount = 0; let startCount = 0;
window.__desktopDaemonStartRequested = false;
function buildDaemonStatus() { function buildDaemonStatus() {
return { return {
@@ -145,6 +149,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
} }
function startDesktopDaemon() { function startDesktopDaemon() {
window.__desktopDaemonStartRequested = true;
if (cfg.hangDaemonStart) { if (cfg.hangDaemonStart) {
return new Promise(() => undefined); return new Promise(() => undefined);
} }
@@ -156,6 +161,13 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return buildDaemonStatus(); return buildDaemonStatus();
} }
async function waitForDesktopSettingsResponse() {
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
if (delayMs > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
}
const desktopBridge: { const desktopBridge: {
platform: string; platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>; invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
@@ -212,6 +224,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
} }
if (command === "get_desktop_settings") { if (command === "get_desktop_settings") {
await waitForDesktopSettingsResponse();
return { return {
releaseChannel: "stable", releaseChannel: "stable",
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true }, daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
@@ -277,6 +290,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}, config); }, config);
} }
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
// Give the startup state two paints to expose any app → splash regression.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
export async function waitForDirectoryDialog( export async function waitForDirectoryDialog(
page: Page, page: Page,
): Promise<Record<string, unknown> | undefined> { ): Promise<Record<string, unknown> | undefined> {

View File

@@ -31,11 +31,52 @@ import {
} from "./helpers/workspace-ui"; } from "./helpers/workspace-ui";
import { clickSettingsBackToWorkspace } from "./helpers/settings"; import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge } from "./helpers/desktop-updates"; import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions"; import { expectAppRoute } from "./helpers/route-assertions";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
type StartupPresentation = "splash" | "app";
declare global {
interface Window {
__paseoStartupPresentationTrace?: StartupPresentation[];
}
}
async function observeStartupPresentation(page: Page): Promise<void> {
await page.addInitScript(() => {
const trace: StartupPresentation[] = [];
window.__paseoStartupPresentationTrace = trace;
document.addEventListener("DOMContentLoaded", () => {
const recordPresentation = () => {
let presentation: StartupPresentation | null = null;
if (document.querySelector('[data-testid="startup-splash"]')) {
presentation = "splash";
} else if (
document.querySelector(
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
)
) {
presentation = "app";
}
if (presentation && trace.at(-1) !== presentation) {
trace.push(presentation);
}
};
new MutationObserver(recordPresentation).observe(document.documentElement, {
childList: true,
subtree: true,
});
recordPresentation();
});
});
}
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
}
async function expectNoLoadingWorkspacePane( async function expectNoLoadingWorkspacePane(
page: Page, page: Page,
@@ -239,7 +280,9 @@ test.describe("Workspace navigation regression", () => {
} }
}); });
test("refresh keeps the user on the same workspace route", async ({ page }) => { test("refresh keeps one continuous splash before restoring the workspace route", async ({
page,
}) => {
const serverId = getServerId(); const serverId = getServerId();
const daemonGate = await installDaemonWebSocketGate(page); const daemonGate = await installDaemonWebSocketGate(page);
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" }); const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
@@ -254,6 +297,7 @@ test.describe("Workspace navigation regression", () => {
serverId, serverId,
manageBuiltInDaemon: true, manageBuiltInDaemon: true,
hangDaemonStart: true, hangDaemonStart: true,
desktopSettingsDelayMs: 250,
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`, daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
}); });
await openWorkspaceThroughApp(page, { serverId, workspace }); await openWorkspaceThroughApp(page, { serverId, workspace });
@@ -261,14 +305,16 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceTabVisible(page, agent.id); await expectWorkspaceTabVisible(page, agent.id);
await expectWorkspaceLocation(page, { serverId, workspace }); await expectWorkspaceLocation(page, { serverId, workspace });
await observeStartupPresentation(page);
await daemonGate.drop(); await daemonGate.drop();
await page.reload(); await page.reload();
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 }); await waitForDesktopDaemonStartRequest(page);
daemonGate.restore(); daemonGate.restore();
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await expectWorkspaceLocation(page, { serverId, workspace }); await expectWorkspaceLocation(page, { serverId, workspace });
await waitForWorkspaceTabsVisible(page); await waitForWorkspaceTabsVisible(page);
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
} finally { } finally {
daemonGate.restore(); daemonGate.restore();
await workspace.cleanup(); await workspace.cleanup();

View File

@@ -60,7 +60,6 @@ import {
resolveStartupBlocker, resolveStartupBlocker,
resolveStartupNavigationReady, resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer, shouldRunStartupGiveUpTimer,
startDaemonIfGateAllows,
startHostRuntimeBootstrap, startHostRuntimeBootstrap,
type StartupBlocker, type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap"; } from "@/navigation/host-runtime-bootstrap";
@@ -337,7 +336,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
store, store,
daemonStartService, daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon, shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
}); });
}, []); }, []);
@@ -376,11 +374,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const retry = useCallback(() => { const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
startDaemonIfGateAllows({ void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []); }, []);
const splashError = const splashError =

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
resolveStartupBlocker, resolveStartupBlocker,
resolveStartupNavigationReady, resolveStartupNavigationReady,
@@ -7,115 +7,37 @@ import {
shouldRunStartupGiveUpTimer, shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap, startHostRuntimeBootstrap,
} from "./host-runtime-bootstrap"; } from "./host-runtime-bootstrap";
import type {
function createFakeStore() { DaemonStartCondition,
return { boot: vi.fn() }; StartDaemonIfEnabledInput,
} } from "@/runtime/daemon-start-service";
function createFakeDaemonStartService() {
return {
start: vi.fn(async () => ({ ok: true as const })),
};
}
describe("startHostRuntimeBootstrap", () => { describe("startHostRuntimeBootstrap", () => {
it("fires boot and daemon-start without awaiting the daemon-start promise", () => { it("boots the host registry and starts the managed-daemon decision as one operation", () => {
const events: string[] = []; const events: string[] = [];
const shouldStartDaemon = async () => true;
const store = { const store = {
boot: vi.fn(() => { boot: () => {
events.push("boot"); events.push("boot");
}),
};
const daemonStartService = {
start: vi.fn(async () => {
events.push("daemon-start");
return { ok: true as const };
}),
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
expect(events).toEqual(["boot", "daemon-start"]);
});
it("skips daemon-start when shouldStartDaemon is false", () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: false,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("skips daemon-start when the startup gate resolves false", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => false,
});
await Promise.resolve();
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
const onGateError = vi.fn();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: async () => {
throw new Error("settings file unreadable");
}, },
onGateError, };
}); let receivedCondition: DaemonStartCondition | null = null;
await vi.waitFor(() => {
expect(onGateError).toHaveBeenCalledTimes(1);
});
expect(daemonStartService.start).not.toHaveBeenCalled();
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
});
it("does not await the daemon-start promise", () => {
const store = createFakeStore();
let resolveStart: ((value: { ok: true }) => void) | undefined;
const daemonStartService = { const daemonStartService = {
start: vi.fn( startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
() => receivedCondition = input.shouldStart;
new Promise<{ ok: true }>((resolve) => { events.push("daemon-start-decision");
resolveStart = resolve; return { ok: true as const };
}), },
),
}; };
startHostRuntimeBootstrap({ startHostRuntimeBootstrap({
store, store,
daemonStartService, daemonStartService,
shouldStartDaemon: true, shouldStartDaemon,
}); });
expect(store.boot).toHaveBeenCalledTimes(1); expect(events).toEqual(["boot", "daemon-start-decision"]);
expect(daemonStartService.start).toHaveBeenCalledTimes(1); expect(receivedCondition).toBe(shouldStartDaemon);
resolveStart?.({ ok: true });
}); });
}); });

View File

@@ -1,5 +1,9 @@
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { DaemonStartResult } from "@/runtime/daemon-start-service"; import type {
DaemonStartCondition,
DaemonStartResult,
StartDaemonIfEnabledInput,
} from "@/runtime/daemon-start-service";
import type { Href } from "expo-router"; import type { Href } from "expo-router";
import { import {
buildHostRootRoute, buildHostRootRoute,
@@ -12,54 +16,22 @@ export interface HostRuntimeBootstrapStore {
} }
export interface HostRuntimeBootstrapDaemonStartService { export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>; startIfEnabled: (input: StartDaemonIfEnabledInput) => Promise<DaemonStartResult>;
} }
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput { export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore; store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService; daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate; shouldStartDaemon: DaemonStartCondition;
onGateError?: (message: string) => void;
} }
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void { export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot(); input.store.boot();
startDaemonIfGateAllows({ void input.daemonStartService.startIfEnabled({
daemonStartService: input.daemonStartService, shouldStart: input.shouldStartDaemon,
shouldStartDaemon: input.shouldStartDaemon,
onGateError: input.onGateError,
}); });
} }
export function startDaemonIfGateAllows(input: {
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}): void {
const gate = input.shouldStartDaemon;
if (typeof gate === "boolean") {
if (gate) {
void input.daemonStartService.start();
}
return;
}
void Promise.resolve()
.then(() => gate())
.then((shouldStartDaemon) => {
if (shouldStartDaemon) {
void input.daemonStartService.start();
}
return null;
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
});
}
const WELCOME_ROUTE: Href = "/welcome"; const WELCOME_ROUTE: Href = "/welcome";
export type StartupBlocker = export type StartupBlocker =

View File

@@ -164,6 +164,53 @@ describe("DaemonStartService", () => {
expect(runningSnapshots).toEqual([true, false]); expect(runningSnapshots).toEqual([true, false]);
}); });
it("stays running while deciding whether the managed daemon should start", async () => {
const fake = createFakeStore();
let resolveCondition: ((value: boolean) => void) | undefined;
let daemonStartCount = 0;
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => {
daemonStartCount += 1;
return makeStatus();
},
});
const startPromise = service.startIfEnabled({
shouldStart: () =>
new Promise<boolean>((resolve) => {
resolveCondition = resolve;
}),
});
expect(service.isRunning()).toBe(true);
expect(daemonStartCount).toBe(0);
resolveCondition?.(true);
await startPromise;
expect(service.isRunning()).toBe(false);
expect(daemonStartCount).toBe(1);
});
it("finishes without starting the daemon when management is disabled", async () => {
const fake = createFakeStore();
let daemonStartCount = 0;
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => {
daemonStartCount += 1;
return makeStatus();
},
});
const result = await service.startIfEnabled({ shouldStart: false });
expect(result).toEqual({ ok: true });
expect(service.isRunning()).toBe(false);
expect(daemonStartCount).toBe(0);
});
it("clears the error and notifies subscribers when retry begins", async () => { it("clears the error and notifies subscribers when retry begins", async () => {
const fake = createFakeStore(); const fake = createFakeStore();
const startMock = vi const startMock = vi
@@ -188,19 +235,27 @@ describe("DaemonStartService", () => {
expect(service.getLastError()).toBeNull(); expect(service.getLastError()).toBeNull();
}); });
it("recordError surfaces an external error and notifies subscribers", () => { it("surfaces settings errors through the daemon startup state", async () => {
const fake = createFakeStore(); const fake = createFakeStore();
const service = new DaemonStartService({ const service = new DaemonStartService({
store: fake.store, store: fake.store,
startDesktopDaemon: async () => makeStatus(), startDesktopDaemon: async () => makeStatus(),
}); });
const notifications = vi.fn();
service.subscribe(notifications);
service.recordError("settings file unreadable"); const result = await service.startIfEnabled({
shouldStart: async () => {
throw new Error("settings file unreadable");
},
});
expect(service.getLastError()).toBe("settings file unreadable"); expect(result).toEqual({
expect(notifications).toHaveBeenCalledTimes(1); ok: false,
error: "Failed to evaluate desktop daemon settings: settings file unreadable",
});
expect(service.getLastError()).toBe(
"Failed to evaluate desktop daemon settings: settings file unreadable",
);
expect(service.isRunning()).toBe(false);
}); });
it("stops notifying after a subscriber unsubscribes", async () => { it("stops notifying after a subscriber unsubscribes", async () => {

View File

@@ -3,6 +3,11 @@ import { connectionFromListen } from "@/types/host-connection";
import type { HostRuntimeStore } from "@/runtime/host-runtime"; import type { HostRuntimeStore } from "@/runtime/host-runtime";
export type DaemonStartResult = { ok: true } | { ok: false; error: string }; export type DaemonStartResult = { ok: true } | { ok: false; error: string };
export type DaemonStartCondition = boolean | (() => boolean | Promise<boolean>);
export interface StartDaemonIfEnabledInput {
shouldStart: DaemonStartCondition;
}
type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">; type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">;
@@ -50,8 +55,27 @@ export class DaemonStartService {
} }
async start(): Promise<DaemonStartResult> { async start(): Promise<DaemonStartResult> {
return this.startIfEnabled({ shouldStart: true });
}
async startIfEnabled(input: StartDaemonIfEnabledInput): Promise<DaemonStartResult> {
// Settings evaluation is part of startup. Publish the running state before
// its first await so restored app chrome cannot appear between these phases.
this.beginRequest(); this.beginRequest();
try { try {
let shouldStart: boolean;
try {
shouldStart =
typeof input.shouldStart === "boolean" ? input.shouldStart : await input.shouldStart();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return this.fail(`Failed to evaluate desktop daemon settings: ${message}`);
}
if (!shouldStart) {
return { ok: true };
}
const daemon = await this.invokeStartDesktopDaemon(); const daemon = await this.invokeStartDesktopDaemon();
const result = await upsertDesktopDaemonConnection(this.store, daemon); const result = await upsertDesktopDaemonConnection(this.store, daemon);
return result.ok ? result : this.fail(result.error); return result.ok ? result : this.fail(result.error);
@@ -66,10 +90,6 @@ export class DaemonStartService {
return this.lastError; return this.lastError;
} }
recordError(message: string): void {
this.setLastError(message);
}
isRunning(): boolean { isRunning(): boolean {
return this.inFlightCount > 0; return this.inFlightCount > 0;
} }