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 13:44:23 +02:00
parent f214261ae5
commit afeb4d18f8
7 changed files with 186 additions and 153 deletions
+1 -7
View File
@@ -60,7 +60,6 @@ import {
resolveStartupBlocker,
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
@@ -337,7 +336,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
store,
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
}, []);
@@ -376,11 +374,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
startDaemonIfGateAllows({
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
onGateError: (message) => daemonStartService.recordError(message),
});
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
}, []);
const splashError =
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
@@ -7,115 +7,37 @@ import {
shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap,
} from "./host-runtime-bootstrap";
function createFakeStore() {
return { boot: vi.fn() };
}
function createFakeDaemonStartService() {
return {
start: vi.fn(async () => ({ ok: true as const })),
};
}
import type {
DaemonStartCondition,
StartDaemonIfEnabledInput,
} from "@/runtime/daemon-start-service";
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 shouldStartDaemon = async () => true;
const store = {
boot: vi.fn(() => {
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,
});
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;
};
let receivedCondition: DaemonStartCondition | null = null;
const daemonStartService = {
start: vi.fn(
() =>
new Promise<{ ok: true }>((resolve) => {
resolveStart = resolve;
}),
),
startIfEnabled: async (input: StartDaemonIfEnabledInput) => {
receivedCondition = input.shouldStart;
events.push("daemon-start-decision");
return { ok: true as const };
},
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
shouldStartDaemon,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
resolveStart?.({ ok: true });
expect(events).toEqual(["boot", "daemon-start-decision"]);
expect(receivedCondition).toBe(shouldStartDaemon);
});
});
@@ -1,5 +1,9 @@
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 {
buildHostRootRoute,
@@ -12,54 +16,22 @@ export interface HostRuntimeBootstrapStore {
}
export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>;
startIfEnabled: (input: StartDaemonIfEnabledInput) => Promise<DaemonStartResult>;
}
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
shouldStartDaemon: DaemonStartCondition;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot();
startDaemonIfGateAllows({
daemonStartService: input.daemonStartService,
shouldStartDaemon: input.shouldStartDaemon,
onGateError: input.onGateError,
void input.daemonStartService.startIfEnabled({
shouldStart: input.shouldStartDaemon,
});
}
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";
export type StartupBlocker =
@@ -164,6 +164,53 @@ describe("DaemonStartService", () => {
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 () => {
const fake = createFakeStore();
const startMock = vi
@@ -188,19 +235,27 @@ describe("DaemonStartService", () => {
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 service = new DaemonStartService({
store: fake.store,
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(notifications).toHaveBeenCalledTimes(1);
expect(result).toEqual({
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 () => {
@@ -3,6 +3,11 @@ import { connectionFromListen } from "@/types/host-connection";
import type { HostRuntimeStore } from "@/runtime/host-runtime";
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">;
@@ -50,8 +55,27 @@ export class DaemonStartService {
}
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();
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 result = await upsertDesktopDaemonConnection(this.store, daemon);
return result.ok ? result : this.fail(result.error);
@@ -66,10 +90,6 @@ export class DaemonStartService {
return this.lastError;
}
recordError(message: string): void {
this.setLastError(message);
}
isRunning(): boolean {
return this.inFlightCount > 0;
}