diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.test.tsx b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.test.tsx new file mode 100644 index 0000000000000..6e6f736e0c4d4 --- /dev/null +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { act, useEffect, type ComponentProps, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +// React Navigation's core runs in jsdom; the native package also loads React Native. +const navigation = await vi.hoisted(async () => { + const NodeModule = await import("node:module"); + const require = NodeModule.createRequire(import.meta.url); + const nativePackage = require.resolve("@react-navigation/native/package.json"); + const core = NodeModule.createRequire(nativePackage).resolve("@react-navigation/core"); + return (await import(core)) as typeof import("@react-navigation/native"); +}); +vi.mock("@react-navigation/native", () => navigation); +vi.mock("react-native", () => ({ Keyboard: { dismiss: vi.fn() } })); + +const { useAndroidWorkspaceSelection } = await import("./useAndroidWorkspaceSelection"); + +function TestStackNavigator( + props: { + readonly children: ReactNode; + readonly initialRouteName?: string; + } & Pick[1], "layout">, +) { + const { state, descriptors, NavigationContent } = navigation.useNavigationBuilder( + navigation.StackRouter, + props, + ); + const focused = state.routes[state.index]; + return ( + {focused ? descriptors[focused.key]?.render() : null} + ); +} + +const Stack = navigation.createNavigatorFactory(TestStackNavigator)(); + +function SettingsStack() { + return ( + + null} /> + null} /> + + ); +} + +type SelectWorkspace = ReturnType; + +function Sidebar(props: { readonly onReady: (select: SelectWorkspace) => void }) { + const select = useAndroidWorkspaceSelection(); + const { onReady } = props; + useEffect(() => onReady(select), [onReady, select]); + return null; +} + +let root: Root | null = null; + +afterEach(() => { + act(() => root?.unmount()); + root = null; +}); + +describe("useAndroidWorkspaceSelection", () => { + it("opens a thread from the workspace layout while nested Settings has focus", () => { + const container = navigation.createNavigationContainerRef(); + let select: SelectWorkspace | undefined; + const onReady = (next: SelectWorkspace) => { + select = next; + }; + // The real sidebar renders in the root navigator's layout, outside any screen. + const layout = ({ children }: { readonly children: ReactNode }) => ( + <> + + {children} + + ); + const initialState = { + index: 2, + routes: [ + { name: "Home" }, + { name: "Thread", params: { environmentId: "environment-a", threadId: "thread-a" } }, + { + name: "SettingsSheet", + state: { index: 1, routes: [{ name: "Settings" }, { name: "SettingsFollowUps" }] }, + }, + ], + } satisfies ComponentProps["initialState"]; + root = createRoot(document.createElement("div")); + act(() => + root?.render( + + + null} /> + null} /> + + + , + ), + ); + + act(() => + select?.({ + name: "Thread", + params: { environmentId: "environment-a", threadId: "thread-b" }, + }), + ); + + const state = container.getRootState(); + expect(state?.routes.map((route) => route.name)).toEqual(["Home", "Thread"]); + expect(state?.routes[state.index]?.params).toEqual({ + environmentId: "environment-a", + threadId: "thread-b", + }); + }); +}); diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts index 46f49e9c071e5..187e1bf88c63f 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts @@ -1,5 +1,5 @@ -import { CommonActions, useNavigation } from "@react-navigation/native"; -import { useCallback } from "react"; +import { CommonActions, NavigationContainerRefContext } from "@react-navigation/native"; +import { use, useCallback } from "react"; import { Keyboard } from "react-native"; import { androidWorkspaceNavigationState, @@ -8,22 +8,28 @@ import { import { useWorkspaceTabs } from "./WorkspaceTabsContext"; export function useAndroidWorkspaceSelection() { - const navigation = useNavigation(); + // Resets the root stack even while a nested navigator such as Settings has + // focus; a plain dispatch from the workspace layout reaches that navigator, + // which has no Home or Thread routes. + const root = use(NavigationContainerRefContext); const dispatch = useWorkspaceTabs()?.dispatch; return useCallback( ( destination: AndroidWorkspaceDestination, reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], ) => { + const state = root?.getRootState(); + if (!root || !state) return; Keyboard.dismiss(); dispatch?.({ type: "select", tab: destination.name === "Thread" ? "threads" : "pullRequests", }); - navigation.dispatch((state) => - CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), - ); + root.dispatch({ + ...CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), + target: state.key, + }); }, - [navigation, dispatch], + [root, dispatch], ); }