From 4a14d1bacc807e1a8a2133dfd84b30d77db4fcb5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 5 Sep 2026 14:18:36 +0200 Subject: [PATCH] fix(app): initialize reconnect policy from current app state Initialize visibility before host bootstrap so a background mount cannot leave reconnect enabled. Seed sidebar fixtures through the workspace owner so cache hydration preserves their rows. --- packages/app/src/app/_layout.tsx | 6 ++-- .../sidebar-workspace-list.test.tsx | 4 +-- ...space-shortcut-targets-subscriber.test.tsx | 10 +++--- .../navigation/host-runtime-bootstrap.test.ts | 32 +++++++++++++++++++ .../src/navigation/host-runtime-bootstrap.ts | 12 +++++++ packages/app/src/test/seed-session.ts | 14 ++++++++ 6 files changed, 67 insertions(+), 11 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 1446a32d28..148d431921 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -69,6 +69,7 @@ import { resolveStartupNavigationReady, shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, + bindHostRuntimeAppState, type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; @@ -374,10 +375,7 @@ async function shouldStartBuiltInDaemon(): Promise { function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { useEffect(() => { const store = getHostRuntimeStore(); - const subscription = AppState.addEventListener("change", (nextState) => { - store.setAppVisible(nextState === "active"); - }); - return () => subscription.remove(); + return bindHostRuntimeAppState(store, AppState); }, []); useEffect(() => { diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 94e6f36466..73c008dd22 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -38,7 +38,7 @@ import { } from "@/runtime/host-runtime"; import type { HostProfile } from "@/types/host-connection"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -167,7 +167,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); + seedRuntimeWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 2cceeae961..7124fd2f0a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; @@ -96,7 +96,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { act(() => { setHostProfiles([hostProfile()]); useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "srv", new Map([ ["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })], @@ -142,7 +142,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { it("publishes status-mode shortcut targets in visual status order", async () => { act(() => { useSidebarViewStore.getState().setGroupMode("status"); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "srv", new Map([ [ @@ -214,11 +214,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]); useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient); useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "host-a", new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]), ); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "host-b", new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]), ); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index ed6a8ccbe9..603532086d 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -6,6 +6,7 @@ import { resolveStartupRoute, shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, + bindHostRuntimeAppState, } from "./host-runtime-bootstrap"; import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service"; @@ -393,3 +394,34 @@ describe("resolveHostIndexRoute", () => { ).toEqual("/open-project"); }); }); + +describe("host runtime app lifecycle", () => { + it.each(["inactive", "background"] as const)( + "starts with reconnect disabled when mounted %s", + (currentState) => { + const visibility: boolean[] = []; + let listener: ((state: "active" | "background") => void) | undefined; + let subscribed = true; + const dispose = bindHostRuntimeAppState( + { setAppVisible: (visible) => visibility.push(visible) }, + { + currentState, + addEventListener: (_event, handler) => { + listener = handler; + return { + remove: () => { + subscribed = false; + }, + }; + }, + }, + ); + expect(visibility).toEqual([false]); + listener?.("active"); + listener?.("background"); + expect(visibility).toEqual([false, true, false]); + dispose(); + expect(subscribed).toBe(false); + }, + ); +}); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 6fbceb8a21..8ee1f7bfb2 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -1,3 +1,4 @@ +import type { AppState } from "react-native"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { DaemonStartCondition, @@ -250,3 +251,14 @@ export function resolveStartupRoute(input: ResolveStartupRouteInput): StartupRou return resolveReadyIndexStartupRoute(input); } + +export function bindHostRuntimeAppState( + store: { setAppVisible: (visible: boolean) => void }, + appState: Pick, +): () => void { + const subscription = appState.addEventListener("change", (state) => { + store.setAppVisible(state === "active"); + }); + store.setAppVisible(appState.currentState === "active"); + return () => subscription.remove(); +} diff --git a/packages/app/src/test/seed-session.ts b/packages/app/src/test/seed-session.ts index c2221a144f..d483e01c5d 100644 --- a/packages/app/src/test/seed-session.ts +++ b/packages/app/src/test/seed-session.ts @@ -56,3 +56,17 @@ export function seedSessionHosts(serverIds: readonly string[]): void { })), ); } + +export function seedRuntimeWorkspaces( + serverId: string, + workspaces: Map, +): void { + const runtime = getHostRuntimeStore(); + for (const id of useSessionStore.getState().sessions[serverId]?.workspaces.keys() ?? []) { + if (!workspaces.has(id)) runtime.removeWorkspaceSnapshot(serverId, id); + } + for (const workspace of workspaces.values()) { + runtime.acceptProjectSnapshot(serverId, projectFromWorkspace(workspace)); + } + runtime.acceptWorkspaceSnapshots(serverId, [...workspaces.values()]); +}