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, + ), }; }