Keep composer autocomplete visible after route hops (#1851)

* fix(app): keep composer autocomplete visible after route hops

Workspace callers now express only the target workspace. The navigation helper chooses whether to pop to a mounted host route or fall back, and active-workspace tracking ignores stale hidden route params while app-wide routes are foregrounded.

* test(app): address workspace navigation review

* fix(app): parse decoded legacy workspace routes

* fix(app): ignore decoded legacy tab routes

* test(app): use canonical offline workspace route
This commit is contained in:
Mohamed Boudra
2026-07-01 21:55:17 +02:00
committed by GitHub
parent 48d07dedb5
commit d3e8f77914
19 changed files with 196 additions and 103 deletions
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NavigationContainerRefWithCurrent } from "@react-navigation/native";
import {
navigateToHostWorkspaceRoute,
registerWorkspaceRouteNavigationRef,
} from "./workspace-route-navigation";
function createNavigationRef(rootState: unknown, options: { ready?: boolean } = {}) {
const dispatch = vi.fn();
const navigationRef = {
current: {
isReady: () => options.ready ?? true,
getRootState: () => rootState,
dispatch,
},
} as unknown as NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>;
return { navigationRef, dispatch };
}
describe("navigateToHostWorkspaceRoute", () => {
beforeEach(() => {
vi.clearAllMocks();
registerWorkspaceRouteNavigationRef({
current: null,
} as unknown as NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>)();
});
it("falls back to route navigation when no host route is mounted yet", () => {
const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack",
routeNames: ["index", "settings/[section]", "h/[serverId]"],
routes: [{ key: "settings-general", name: "settings/[section]" }],
});
registerWorkspaceRouteNavigationRef(navigationRef);
const dismissTo = vi.fn();
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo });
expect(dispatch).not.toHaveBeenCalled();
expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("pops to the mounted host route and targets the requested workspace", () => {
const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack",
routeNames: ["index", "settings/[section]", "h/[serverId]"],
routes: [
{
key: "host-server-1",
name: "h/[serverId]",
params: { serverId: "server-1" },
},
{ key: "settings-general", name: "settings/[section]" },
],
});
registerWorkspaceRouteNavigationRef(navigationRef);
const dismissTo = vi.fn();
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo });
expect(dismissTo).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
type: "POP_TO",
target: "root-stack",
payload: {
name: "h/[serverId]",
params: {
serverId: "server-1",
screen: "workspace/[workspaceId]/index",
params: {
serverId: "server-1",
workspaceId: "workspace-a",
},
pop: true,
},
},
});
});
});
@@ -8,13 +8,17 @@ import {
const ROOT_HOST_ROUTE_NAME = "h/[serverId]";
const HOST_WORKSPACE_ROUTE_NAME = "workspace/[workspaceId]/index";
interface NavigateToHostWorkspaceRouteDeps {
dismissTo(route: string): void;
}
const defaultNavigateToHostWorkspaceRouteDeps: NavigateToHostWorkspaceRouteDeps = {
dismissTo: (route) => router.dismissTo(route as Href),
};
let rootNavigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList> | null =
null;
interface NavigateToHostWorkspaceRouteOptions {
popToExistingHostRoute?: boolean;
}
export function registerWorkspaceRouteNavigationRef(
ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
): () => void {
@@ -26,33 +30,38 @@ export function registerWorkspaceRouteNavigationRef(
};
}
function findStackKeyWithRouteName(state: unknown, routeName: string): string | null {
function findStackKeyWithMountedRouteName(state: unknown, routeName: string): string | null {
if (!state || typeof state !== "object") {
return null;
}
const candidate = state as {
key?: unknown;
routeNames?: unknown;
routes?: unknown;
};
if (
typeof candidate.key === "string" &&
Array.isArray(candidate.routeNames) &&
candidate.routeNames.includes(routeName)
) {
return candidate.key;
}
if (!Array.isArray(candidate.routes)) {
return null;
}
if (
typeof candidate.key === "string" &&
candidate.routes.some(
(route) =>
!!route && typeof route === "object" && (route as { name?: unknown }).name === routeName,
)
) {
return candidate.key;
}
for (const route of candidate.routes) {
if (!route || typeof route !== "object") {
continue;
}
const childKey = findStackKeyWithRouteName((route as { state?: unknown }).state, routeName);
const childKey = findStackKeyWithMountedRouteName(
(route as { state?: unknown }).state,
routeName,
);
if (childKey) {
return childKey;
}
@@ -69,7 +78,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
}
const rootState = navigation.getRootState();
const target = findStackKeyWithRouteName(rootState, ROOT_HOST_ROUTE_NAME);
const target = findStackKeyWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME);
if (!target) {
return false;
}
@@ -99,11 +108,11 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
export function navigateToHostWorkspaceRoute(
route: string,
options: NavigateToHostWorkspaceRouteOptions = {},
deps: NavigateToHostWorkspaceRouteDeps = defaultNavigateToHostWorkspaceRouteDeps,
): void {
if (options.popToExistingHostRoute && dispatchHostWorkspacePopTo(route)) {
if (dispatchHostWorkspacePopTo(route)) {
return;
}
router.dismissTo(route as Href);
deps.dismissTo(route);
}