fix: prevent bootstrap phase regression when an existing host comes online first
When a saved host reconnects before bootstrapDesktop finishes, the phase
was regressed from "online" back to "connecting". Track the race settlement
and skip the setPhase("connecting") call if anyOnline already won. Also
extract getEarliestOnlineHostServerId into HostRuntimeStore and use it to
initialize the bootstrap phase, avoiding a flash of the starting screen.
This commit is contained in:
@@ -267,7 +267,9 @@ function HostSessionManager() {
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
|
||||
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>(() =>
|
||||
getHostRuntimeStore().getEarliestOnlineHostServerId() !== null ? "online" : "starting-daemon",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -42,4 +42,62 @@ describe("initializeHostRuntime", () => {
|
||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
||||
expect(setError).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not regress the phase to connecting when anyOnline wins the race before bootstrapDesktop returns", async () => {
|
||||
let resolveBootstrapDesktop!: () => void;
|
||||
const bootstrapDesktopGate = new Promise<void>((resolve) => {
|
||||
resolveBootstrapDesktop = resolve;
|
||||
});
|
||||
|
||||
let resolveAddConnection!: () => void;
|
||||
const addConnectionCompleted = new Promise<void>((resolve) => {
|
||||
resolveAddConnection = resolve;
|
||||
});
|
||||
|
||||
const setPhase = vi.fn();
|
||||
const setError = vi.fn();
|
||||
|
||||
const initPromise = initializeHostRuntime({
|
||||
shouldManageDesktop: true,
|
||||
loadSettings: async () => ({
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
}),
|
||||
store: {
|
||||
loadFromStorage: async () => {},
|
||||
bootstrap: async () => {},
|
||||
bootstrapDesktop: async () => {
|
||||
await bootstrapDesktopGate;
|
||||
return {
|
||||
ok: true as const,
|
||||
listenAddress: "127.0.0.1:6767",
|
||||
serverId: "srv_test",
|
||||
hostname: "test",
|
||||
};
|
||||
},
|
||||
addConnectionFromListenAndWaitForOnline: async () => {
|
||||
resolveAddConnection();
|
||||
},
|
||||
waitForAnyConnectionOnline: () => ({
|
||||
promise: Promise.resolve(),
|
||||
cancel: vi.fn(),
|
||||
}),
|
||||
},
|
||||
setPhase,
|
||||
setError,
|
||||
isCancelled: () => false,
|
||||
});
|
||||
|
||||
await initPromise;
|
||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
||||
|
||||
resolveBootstrapDesktop();
|
||||
await addConnectionCompleted;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(setPhase).not.toHaveBeenCalledWith("connecting");
|
||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function initializeHostRuntime(args: {
|
||||
setPhase("starting-daemon");
|
||||
setError(null);
|
||||
|
||||
let raceSettled = false;
|
||||
const anyOnline = store.waitForAnyConnectionOnline();
|
||||
|
||||
const bootstrapPromise = (async (): Promise<BootstrapOutcome> => {
|
||||
@@ -54,7 +55,7 @@ export async function initializeHostRuntime(args: {
|
||||
if (!bootstrapResult.ok) {
|
||||
return { type: "error", error: bootstrapResult.error };
|
||||
}
|
||||
if (!isCancelled()) {
|
||||
if (!isCancelled() && !raceSettled) {
|
||||
setPhase("connecting");
|
||||
}
|
||||
await store.addConnectionFromListenAndWaitForOnline({
|
||||
@@ -76,6 +77,7 @@ export async function initializeHostRuntime(args: {
|
||||
}));
|
||||
const result = await Promise.race([onlineFromAny, bootstrapPromise]);
|
||||
|
||||
raceSettled = true;
|
||||
anyOnline.cancel();
|
||||
|
||||
if (!isCancelled()) {
|
||||
|
||||
@@ -2,33 +2,17 @@ import { useEffect, useSyncExternalStore } from "react";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||
function useEarliestOnlineHostServerId(): string | null {
|
||||
const runtime = getHostRuntimeStore();
|
||||
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
() => runtime.getEarliestOnlineHostServerId(),
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
@@ -40,8 +24,7 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const storeReady = useStoreReady();
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
|
||||
const anyOnlineServerId = useEarliestOnlineHostServerId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeReady) {
|
||||
|
||||
@@ -1739,25 +1739,31 @@ export class HostRuntimeStore {
|
||||
};
|
||||
}
|
||||
|
||||
getEarliestOnlineHostServerId(): string | null {
|
||||
let earliestServerId: string | null = null;
|
||||
let earliestOnlineAt: string | null = null;
|
||||
for (const host of this.hosts) {
|
||||
const snapshot = this.getSnapshot(host.serverId);
|
||||
if (!isHostRuntimeConnected(snapshot) || !snapshot?.lastOnlineAt) continue;
|
||||
if (!earliestOnlineAt || snapshot.lastOnlineAt < earliestOnlineAt) {
|
||||
earliestOnlineAt = snapshot.lastOnlineAt;
|
||||
earliestServerId = host.serverId;
|
||||
}
|
||||
}
|
||||
return earliestServerId;
|
||||
}
|
||||
|
||||
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const isAnyOnline = (): boolean => {
|
||||
for (const host of this.hosts) {
|
||||
const snapshot = this.getSnapshot(host.serverId);
|
||||
if (snapshot?.connectionStatus === "online") return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
if (isAnyOnline()) {
|
||||
if (this.getEarliestOnlineHostServerId() !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = this.subscribeAll(() => {
|
||||
if (isAnyOnline()) {
|
||||
if (this.getEarliestOnlineHostServerId() !== null) {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
resolve();
|
||||
|
||||
Reference in New Issue
Block a user