From 78d46a8a828de0d0e3e5850311e697b921b438db Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 23 Jun 2026 21:09:35 +0700 Subject: [PATCH] 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. --- docs/development.md | 32 +++++++++++ packages/app/src/app/_layout.tsx | 18 +----- packages/app/src/app/h/[serverId]/_layout.tsx | 33 +++++++++-- packages/app/src/app/h/[serverId]/index.tsx | 42 ++++++++++++-- packages/app/src/app/index.tsx | 17 +++++- .../app/src/navigation/host-route-context.tsx | 21 +++++++ .../host-runtime-bootstrap.test.ts | 56 +++++++++++++++++++ .../host-runtime-bootstrap.ts | 48 +++++++++++++++- 8 files changed, 235 insertions(+), 32 deletions(-) create mode 100644 packages/app/src/navigation/host-route-context.tsx rename packages/app/src/{app => navigation}/host-runtime-bootstrap.test.ts (83%) rename packages/app/src/{app => navigation}/host-runtime-bootstrap.ts (81%) diff --git a/docs/development.md b/docs/development.md index 03d638f78..fba9b95a3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,6 +49,38 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports. +### Expo Router layout ownership + +Each layout owns only the routes directly inside its directory. In the root +layout, register `h/[serverId]`; do not register host leaf routes such as +`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or +`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf +routes with its own nested stack and relative screen names: +`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router +warns with `[Layout children]: No route named ...` when a layout registers +grandchildren. Treat that warning as a route-tree bug: on native, this shape can +leave a nested index route mounted without its local dynamic params and render a +blank screen. + +Do not paper over missing required route params by reading global params in the +leaf. Required dynamic params belong to the matched route. If +`useLocalSearchParams()` misses one, fix the layout ownership. + +Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and +`.tsx` files there as routes, which produces `missing the required default +export` warnings and pollutes the route tree. Put shared route policy in +`src/navigation`, `src/utils`, or another non-route directory. + +Treat `/h/[serverId]` as the host home route. It resolves to the last remembered +workspace for that host after the workspace-selection store hydrates unless the +host's hydrated workspace list proves that workspace is gone; hosts without a +remembered workspace go to `open-project`. + +Keep workspace identity and retention outside native-stack `getId`/ +`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React +Navigation `getId`, and `getId` has broken Android native-stack/Fabric by +reordering an already-mounted workspace screen. + ### iOS simulator preview service Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`. diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5b7e3165a..94061e25a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -56,7 +56,7 @@ import { startDaemonIfGateAllows, startHostRuntimeBootstrap, type StartupBlocker, -} from "@/app/host-runtime-bootstrap"; +} from "@/navigation/host-runtime-bootstrap"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; @@ -877,8 +877,6 @@ function FaviconStatusSync() { return null; } -const AGENT_SCREEN_OPTIONS = { gestureEnabled: false }; - function RootStack() { const storeReady = useStoreReady(); const { theme } = useUnistyles(); @@ -903,19 +901,7 @@ function RootStack() { - {/* - Do not add getId or dangerouslySingular back to the workspace route. - Expo Router maps dangerouslySingular to React Navigation getId, and - getId repeatedly breaks Android native-stack/Fabric by reordering an - already-mounted workspace screen. Keep workspace identity/retention - outside this route-level native-stack API. - */} - - - - - - + diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index c84e1d397..0fb551f30 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -1,8 +1,16 @@ -import { Redirect, Slot, useLocalSearchParams } from "expo-router"; +import { Redirect, Stack, useLocalSearchParams } from "expo-router"; import { useHostRuntimeBootstrapState } from "@/app/_layout"; -import { resolveStartupRoute } from "@/app/host-runtime-bootstrap"; +import { HostRouteProvider } from "@/navigation/host-route-context"; +import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap"; import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime"; +const HOST_STACK_SCREEN_OPTIONS = { + headerShown: false, + animation: "none" as const, +}; + +const AGENT_SCREEN_OPTIONS = { gestureEnabled: false }; + export default function HostRouteLayout() { return ; } @@ -24,8 +32,21 @@ function KnownHostRoute() { return ; } - // Keep the host Slot mounted while startup gates are active. React Navigation - // web can reserialize a shallower tree and drop nested workspace URL segments - // if the layout swaps Slot for a splash; leaf routes own the splash boundary. - return ; + const stack = ( + + + + + + + + + + ); + + if (!routeServerId) { + return stack; + } + + return {stack}; } diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index fa80724e5..46ba5d8fb 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,9 +1,39 @@ -import { Redirect, useLocalSearchParams } from "expo-router"; -import { buildHostOpenProjectRoute } from "@/utils/host-routes"; +import { Redirect } from "expo-router"; +import { useHostRouteServerId } from "@/navigation/host-route-context"; +import { + resolveHostIndexRoute, + resolveWorkspaceSelectionStatus, +} from "@/navigation/host-runtime-bootstrap"; +import { StartupSplashScreen } from "@/screens/startup-splash-screen"; +import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; +import { + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; export default function HostIndexRoute() { - const params = useLocalSearchParams<{ serverId?: string }>(); - const serverId = typeof params.serverId === "string" ? params.serverId : ""; - if (!serverId) return null; - return ; + const serverId = useHostRouteServerId(); + const workspaceSelection = useLastWorkspaceSelection(); + const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated(); + const workspaceSelectionWorkspaceId = + workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null; + const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); + const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId); + + if (!serverId || !isWorkspaceSelectionLoaded) { + return ; + } + + return ( + + ); } diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index ea69ffee1..e0649f65a 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -2,8 +2,12 @@ import React from "react"; import { Redirect, usePathname } from "expo-router"; import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout"; -import { resolveStartupRoute } from "@/app/host-runtime-bootstrap"; +import { + resolveStartupRoute, + resolveWorkspaceSelectionStatus, +} from "@/navigation/host-runtime-bootstrap"; import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime"; +import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; import { useIsLastWorkspaceSelectionHydrated, useLastWorkspaceSelection, @@ -20,6 +24,13 @@ export default function Index() { const hostRegistryStatus = useHostRegistryStatus(); const workspaceSelection = useLastWorkspaceSelection(); const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated(); + const workspaceSelectionServerId = workspaceSelection?.serverId ?? null; + const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null; + const hasHydratedWorkspaceSelectionHost = useHasHydratedWorkspaces(workspaceSelectionServerId); + const workspaceSelectionExists = useWorkspaceExists( + workspaceSelectionServerId, + workspaceSelectionWorkspaceId, + ); const startupRoute = resolveStartupRoute({ route: { kind: "index", pathname }, @@ -28,6 +39,10 @@ export default function Index() { hosts, anyOnlineHostServerId, workspaceSelection, + workspaceSelectionStatus: resolveWorkspaceSelectionStatus({ + hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost, + workspaceExists: workspaceSelectionExists, + }), isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); diff --git a/packages/app/src/navigation/host-route-context.tsx b/packages/app/src/navigation/host-route-context.tsx new file mode 100644 index 000000000..27106554f --- /dev/null +++ b/packages/app/src/navigation/host-route-context.tsx @@ -0,0 +1,21 @@ +import { createContext, type ReactNode, useContext } from "react"; + +const HostRouteServerIdContext = createContext(null); + +export function HostRouteProvider({ + children, + serverId, +}: { + children: ReactNode; + serverId: string; +}) { + return ( + + {children} + + ); +} + +export function useHostRouteServerId(): string | null { + return useContext(HostRouteServerIdContext); +} diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts similarity index 83% rename from packages/app/src/app/host-runtime-bootstrap.test.ts rename to packages/app/src/navigation/host-runtime-bootstrap.test.ts index 64d0f013d..5d16ed09a 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + resolveHostIndexRoute, resolveStartupBlocker, resolveStartupNavigationReady, resolveStartupRoute, @@ -200,6 +201,7 @@ describe("resolveStartupRoute", () => { hosts: [], anyOnlineHostServerId: null, workspaceSelection: null, + workspaceSelectionStatus: "unknown" as const, isWorkspaceSelectionLoaded: true, hasGivenUpWaitingForHost: false, }; @@ -255,10 +257,22 @@ describe("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({ @@ -344,3 +358,45 @@ describe("resolveStartupRoute", () => { ).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"); + }); +}); diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts similarity index 81% rename from packages/app/src/app/host-runtime-bootstrap.ts rename to packages/app/src/navigation/host-runtime-bootstrap.ts index 8db0043fc..77a8cfda6 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -136,6 +136,7 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn route: IndexStartupRouteTarget; anyOnlineHostServerId: string | null; workspaceSelection: ActiveWorkspaceSelection | null; + workspaceSelectionStatus: WorkspaceSelectionStatus; isWorkspaceSelectionLoaded: boolean; hasGivenUpWaitingForHost: boolean; } @@ -151,6 +152,42 @@ export type StartupRouteDecision = | { 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; +} { + 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 === ""; } @@ -171,11 +208,16 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St return { kind: "splash" }; } - const workspaceSelection = input.workspaceSelection; - if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) { + if ( + shouldRestoreWorkspaceSelection(input) && + hostExists(input.hosts, input.workspaceSelection.serverId) + ) { return { kind: "redirect", - href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId), + href: buildHostWorkspaceRoute( + input.workspaceSelection.serverId, + input.workspaceSelection.workspaceId, + ), }; }