Restore workspace after reopening app

Native state restore can resume at the host route instead of the root index, so host home now resolves the remembered workspace itself. Keep shared route policy outside src/app so Expo Router does not register it as a page.
This commit is contained in:
Mohamed Boudra
2026-06-23 21:09:35 +07:00
parent 1970a14349
commit 78d46a8a82
8 changed files with 235 additions and 32 deletions
@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
const HostRouteServerIdContext = createContext<string | null>(null);
export function HostRouteProvider({
children,
serverId,
}: {
children: ReactNode;
serverId: string;
}) {
return (
<HostRouteServerIdContext.Provider value={serverId}>
{children}
</HostRouteServerIdContext.Provider>
);
}
export function useHostRouteServerId(): string | null {
return useContext(HostRouteServerIdContext);
}
@@ -0,0 +1,402 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveHostIndexRoute,
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRoute,
shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap,
} from "./host-runtime-bootstrap";
function createFakeStore() {
return { boot: vi.fn() };
}
function createFakeDaemonStartService() {
return {
start: vi.fn(async () => ({ ok: true as const })),
};
}
describe("startHostRuntimeBootstrap", () => {
it("fires boot and daemon-start without awaiting the daemon-start promise", () => {
const events: string[] = [];
const store = {
boot: vi.fn(() => {
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;
const daemonStartService = {
start: vi.fn(
() =>
new Promise<{ ok: true }>((resolve) => {
resolveStart = resolve;
}),
),
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
resolveStart?.({ ok: true });
});
});
describe("startup blocking policy", () => {
const noBlockerInput = {
isDesktopRuntime: false,
anyOnlineHostServerId: null,
daemonStartIsRunning: false,
daemonStartError: null,
};
it("runs the give-up timer when no startup blocker is active", () => {
const blocker = resolveStartupBlocker(noBlockerInput);
expect(blocker).toEqual({ kind: "none" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(true);
});
it("blocks navigation while desktop is starting the managed daemon", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
daemonStartIsRunning: true,
});
expect(blocker).toEqual({ kind: "managed-daemon-starting" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(false);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(false);
});
it("unblocks navigation when any host is online", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
anyOnlineHostServerId: "srv_desktop",
daemonStartIsRunning: true,
});
expect(blocker).toEqual({ kind: "none" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
});
it("keeps desktop daemon startup errors on the startup error surface", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
daemonStartError: "daemon failed to start",
});
expect(blocker).toEqual({
kind: "managed-daemon-error",
message: "daemon failed to start",
});
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(false);
});
});
describe("resolveStartupRoute", () => {
const baseIndexInput = {
route: { kind: "index" as const, pathname: "/" },
startupBlocker: { kind: "none" as const },
hostRegistryStatus: "ready" as const,
hosts: [],
anyOnlineHostServerId: null,
workspaceSelection: null,
workspaceSelectionStatus: "unknown" as const,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
};
const baseHostInput = {
route: { kind: "host" as const, serverId: "server-saved" },
startupBlocker: { kind: "none" as const },
hostRegistryStatus: "ready" as const,
hosts: [],
};
it("renders non-index routes instead of making an index startup decision", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
route: { kind: "index", pathname: "/settings" },
}),
).toEqual({ kind: "render" });
});
it("keeps startup on the splash while the persisted workspace selection is loading", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
anyOnlineHostServerId: "server-1",
isWorkspaceSelectionLoaded: false,
}),
).toEqual({ kind: "splash" });
});
it("keeps startup on the splash while the host registry is loading", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hostRegistryStatus: "loading",
}),
).toEqual({ kind: "splash" });
});
it("does not treat loading hosts as an empty registry when a workspace is already restored", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hostRegistryStatus: "loading",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
hasGivenUpWaitingForHost: true,
}),
).toEqual({ kind: "splash" });
});
it("restores the saved workspace only after the host registry proves the host exists", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hosts: [{ serverId: "server-1" }],
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
});
it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hosts: [{ serverId: "server-1" }],
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
workspaceSelectionStatus: "missing",
}),
).toEqual({ kind: "redirect", href: "/h/server-1" });
});
it("falls back to a saved host when the restored workspace host is no longer saved", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
hosts: [{ serverId: "server-next" }],
hasGivenUpWaitingForHost: true,
}),
).toEqual({ kind: "redirect", href: "/h/server-next" });
});
it("redirects to the online host when no saved workspace is selected", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
anyOnlineHostServerId: "srv-desktop",
}),
).toEqual({ kind: "redirect", href: "/h/srv-desktop" });
});
it("keeps a known connecting host in app-owned routing instead of showing welcome", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hosts: [{ serverId: "server-saved" }],
hasGivenUpWaitingForHost: true,
}),
).toEqual({ kind: "redirect", href: "/h/server-saved" });
});
it("shows welcome after root startup gives up and no host exists", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hasGivenUpWaitingForHost: true,
}),
).toEqual({ kind: "redirect", href: "/welcome" });
});
it("keeps host routes mounted while the host registry is loading", () => {
expect(
resolveStartupRoute({
...baseHostInput,
hostRegistryStatus: "loading",
}),
).toEqual({ kind: "render" });
});
it("keeps host routes mounted while the managed daemon is starting", () => {
expect(
resolveStartupRoute({
...baseHostInput,
startupBlocker: { kind: "managed-daemon-starting" },
}),
).toEqual({ kind: "render" });
});
it("renders a host route once the route host is known", () => {
expect(
resolveStartupRoute({
...baseHostInput,
hosts: [{ serverId: "server-saved" }],
}),
).toEqual({ kind: "render" });
});
it("sends removed host routes to a saved host instead of welcome", () => {
expect(
resolveStartupRoute({
...baseHostInput,
route: { kind: "host", serverId: "server-removed" },
hosts: [{ serverId: "server-next" }],
}),
).toEqual({ kind: "redirect", href: "/h/server-next/open-project" });
});
it("shows welcome from a host route only after the registry proves no hosts exist", () => {
expect(
resolveStartupRoute({
...baseHostInput,
route: { kind: "host", serverId: "server-removed" },
}),
).toEqual({ kind: "redirect", href: "/welcome" });
});
});
describe("resolveHostIndexRoute", () => {
it("restores the remembered workspace when the host index is reopened for the same host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "unknown",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("opens project selection when the remembered workspace is proven missing", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "missing",
}),
).toEqual("/h/server-saved/open-project");
});
it("opens project selection when the remembered workspace belongs to another host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/open-project");
});
});
@@ -0,0 +1,276 @@
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
import type { Href } from "expo-router";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
boot: () => void;
}
export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>;
}
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: HostRuntimeBootstrapStartGate;
onGateError?: (message: string) => void;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot();
startDaemonIfGateAllows({
daemonStartService: input.daemonStartService,
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";
export type StartupBlocker =
| { kind: "none" }
| { kind: "managed-daemon-starting" }
| { kind: "managed-daemon-error"; message: string };
export interface ResolveStartupBlockerInput {
isDesktopRuntime: boolean;
anyOnlineHostServerId: string | null;
daemonStartIsRunning: boolean;
daemonStartError: string | null;
}
export function resolveStartupBlocker(input: ResolveStartupBlockerInput): StartupBlocker {
if (!input.isDesktopRuntime) {
return { kind: "none" };
}
if (input.anyOnlineHostServerId) {
return { kind: "none" };
}
if (input.daemonStartError) {
return { kind: "managed-daemon-error", message: input.daemonStartError };
}
if (input.daemonStartIsRunning) {
return { kind: "managed-daemon-starting" };
}
return { kind: "none" };
}
export function resolveStartupNavigationReady(input: { startupBlocker: StartupBlocker }): boolean {
return input.startupBlocker.kind !== "managed-daemon-starting";
}
export function shouldRunStartupGiveUpTimer(input: {
startupBlocker: StartupBlocker;
anyOnlineHostServerId: string | null;
hasGivenUpWaitingForHost: boolean;
}): boolean {
if (input.anyOnlineHostServerId) {
return false;
}
if (input.hasGivenUpWaitingForHost) {
return false;
}
return input.startupBlocker.kind === "none";
}
export type StartupRegistryStatus = "loading" | "ready";
export interface IndexStartupRouteTarget {
kind: "index";
pathname: string;
}
export interface HostStartupRouteTarget {
kind: "host";
serverId: string | null;
}
export type StartupRouteTarget = IndexStartupRouteTarget | HostStartupRouteTarget;
interface ResolveStartupRouteBaseInput {
startupBlocker: StartupBlocker;
hostRegistryStatus: StartupRegistryStatus;
hosts: readonly { serverId: string }[];
}
export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseInput {
route: IndexStartupRouteTarget;
anyOnlineHostServerId: string | null;
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: boolean;
}
export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput {
route: HostStartupRouteTarget;
}
export type ResolveStartupRouteInput = ResolveIndexStartupRouteInput | ResolveHostStartupRouteInput;
export type StartupRouteDecision =
| { kind: "render" }
| { kind: "splash" }
| { kind: "redirect"; href: Href };
export type WorkspaceSelectionStatus = "unknown" | "exists" | "missing";
function shouldRestoreWorkspaceSelection(input: {
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
}): input is {
workspaceSelection: ActiveWorkspaceSelection;
workspaceSelectionStatus: Exclude<WorkspaceSelectionStatus, "missing">;
} {
return input.workspaceSelection !== null && input.workspaceSelectionStatus !== "missing";
}
export function resolveWorkspaceSelectionStatus(input: {
hasHydratedWorkspaces: boolean;
workspaceExists: boolean;
}): WorkspaceSelectionStatus {
if (input.workspaceExists) {
return "exists";
}
return input.hasHydratedWorkspaces ? "missing" : "unknown";
}
export function resolveHostIndexRoute(input: {
serverId: string;
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
}): Href {
if (
input.workspaceSelection?.serverId === input.serverId &&
shouldRestoreWorkspaceSelection(input)
) {
return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
}
return buildHostOpenProjectRoute(input.serverId);
}
function isIndexPathname(pathname: string) {
return pathname === "/" || pathname === "";
}
function hostExists(hosts: readonly { serverId: string }[], serverId: string | null): boolean {
if (!serverId) {
return false;
}
return hosts.some((host) => host.serverId === serverId);
}
function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): StartupRouteDecision {
if (!isIndexPathname(input.route.pathname)) {
return { kind: "render" };
}
if (!input.isWorkspaceSelectionLoaded) {
return { kind: "splash" };
}
if (
shouldRestoreWorkspaceSelection(input) &&
hostExists(input.hosts, input.workspaceSelection.serverId)
) {
return {
kind: "redirect",
href: buildHostWorkspaceRoute(
input.workspaceSelection.serverId,
input.workspaceSelection.workspaceId,
),
};
}
if (input.anyOnlineHostServerId) {
return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) };
}
const savedHostServerId = input.hosts[0]?.serverId ?? null;
if (savedHostServerId) {
return { kind: "redirect", href: buildHostRootRoute(savedHostServerId) };
}
if (input.hasGivenUpWaitingForHost) {
return { kind: "redirect", href: WELCOME_ROUTE };
}
return { kind: "splash" };
}
function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): StartupRouteDecision {
if (hostExists(input.hosts, input.route.serverId)) {
return { kind: "render" };
}
const fallbackServerId = input.hosts[0]?.serverId ?? null;
if (fallbackServerId) {
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
}
return { kind: "redirect", href: WELCOME_ROUTE };
}
function isHostStartupRouteInput(
input: ResolveStartupRouteInput,
): input is ResolveHostStartupRouteInput {
return input.route.kind === "host";
}
export function resolveStartupRoute(input: ResolveStartupRouteInput): StartupRouteDecision {
if (isHostStartupRouteInput(input)) {
if (input.startupBlocker.kind !== "none" || input.hostRegistryStatus === "loading") {
return { kind: "render" };
}
return resolveReadyHostStartupRoute(input);
}
if (input.startupBlocker.kind !== "none") {
return { kind: "splash" };
}
if (input.hostRegistryStatus === "loading") {
return { kind: "splash" };
}
return resolveReadyIndexStartupRoute(input);
}