Fix workspace navigation regression on web (#772)

Centralize workspace navigation behind a small route-derived API so the workspace shell reads active selection from the current Expo Router route instead of a separately synchronized store.

Remove the stale layout-driven active-workspace write that regressed web after 02e5b564.
This commit is contained in:
Mohamed Boudra
2026-05-06 13:22:31 +08:00
committed by GitHub
parent 0eacc98e17
commit 868479c493
26 changed files with 221 additions and 956 deletions
@@ -1,376 +1,72 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMemo, useSyncExternalStore } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
export interface ActiveWorkspaceSelection {
serverId: string;
workspaceId: string;
}
interface ActivateWorkspaceSelectionOptions {
updateBrowserHistory?: boolean;
historyMode?: "push" | "replace";
interface NavigateToWorkspaceOptions {
currentPathname?: string | null;
}
interface NavigationRouteParams {
let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null;
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return "";
}
function parseWorkspaceSelectionFromRouteParams(params: {
serverId?: string | string[];
workspaceId?: string | string[];
}
interface NavigationRouteLike {
params?: NavigationRouteParams | null;
path?: string | null;
}
type NavigationWorkspaceRouteState =
| { kind: "workspace"; selection: ActiveWorkspaceSelection }
| { kind: "nonWorkspace" }
| { kind: "unknown" };
interface NavigationObserverRef {
current: {
getCurrentRoute(): NavigationRouteLike | null | undefined;
} | null;
}
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
let snapshot: ActiveWorkspaceSelection | null = null;
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
let isLastWorkspaceRouteSelectionLoaded = false;
let lastWorkspaceRouteSelectionRevision = 0;
let lastWorkspaceRouteSelectionHydrationPromise: Promise<void> | null = null;
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): ActiveWorkspaceSelection | null {
return snapshot;
}
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
return;
}
snapshot = next;
notifyListeners();
}
function parseStoredLastWorkspaceRouteSelection(
stored: string | null,
): ActiveWorkspaceSelection | null {
if (!stored) {
return null;
}
try {
return normalizeActiveWorkspaceSelection(JSON.parse(stored));
} catch {
return null;
}
}
function normalizeActiveWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
const selection = input as Record<string, unknown>;
const serverId = typeof selection.serverId === "string" ? selection.serverId.trim() : "";
const workspaceId = typeof selection.workspaceId === "string" ? selection.workspaceId.trim() : "";
}): ActiveWorkspaceSelection | null {
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null;
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
}
function persistLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
void AsyncStorage.setItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY, JSON.stringify(next)).catch(
() => {},
);
}
function setLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
const normalized = normalizeActiveWorkspaceSelection(next);
if (!normalized) {
return;
}
if (
lastWorkspaceRouteSelection?.serverId === normalized.serverId &&
lastWorkspaceRouteSelection.workspaceId === normalized.workspaceId
) {
return;
}
lastWorkspaceRouteSelectionRevision += 1;
lastWorkspaceRouteSelection = normalized;
persistLastWorkspaceRouteSelection(normalized);
}
async function readLastWorkspaceRouteSelectionFromStorage(hydrationRevision: number) {
try {
const stored = await AsyncStorage.getItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY);
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = parseStoredLastWorkspaceRouteSelection(stored);
}
} catch {
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = null;
}
} finally {
isLastWorkspaceRouteSelectionLoaded = true;
notifyListeners();
}
}
function getBrowserLocationWorkspace(): ActiveWorkspaceSelection | null {
if (!isWeb || typeof window === "undefined") {
return null;
}
return parseHostWorkspaceRouteFromPathname(window.location.pathname);
}
function writeBrowserWorkspaceUrl(
next: ActiveWorkspaceSelection,
options: ActivateWorkspaceSelectionOptions,
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
_options: NavigateToWorkspaceOptions = {},
) {
if (!options.updateBrowserHistory || !isWeb || typeof window === "undefined") {
return;
}
const nextPath = buildHostWorkspaceRoute(next.serverId, next.workspaceId);
const currentUrl = new URL(window.location.href);
if (currentUrl.pathname === nextPath && !currentUrl.search && !currentUrl.hash) {
return;
}
const nextUrl = new URL(nextPath, window.location.origin);
const mode = options.historyMode ?? "push";
if (mode === "replace") {
window.history.replaceState(null, "", nextUrl.toString());
return;
}
window.history.pushState(null, "", nextUrl.toString());
lastWorkspaceSelection = { serverId, workspaceId };
router.dismissTo(buildHostWorkspaceRoute(serverId, workspaceId) as Href);
}
function extractActiveWorkspaceFromRoute(
route: NavigationRouteLike | null | undefined,
): ActiveWorkspaceSelection | null {
if (!route) {
return null;
export function navigateToLastWorkspace(): boolean {
if (!lastWorkspaceSelection) {
return false;
}
if (typeof route.path === "string") {
const parsed = parseHostWorkspaceRouteFromPathname(route.path);
if (parsed) {
return parsed;
}
}
const serverValue = Array.isArray(route.params?.serverId)
? route.params.serverId[0]
: route.params?.serverId;
const workspaceValue = Array.isArray(route.params?.workspaceId)
? route.params.workspaceId[0]
: route.params?.workspaceId;
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
const workspaceId =
typeof workspaceValue === "string"
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
return true;
}
function classifyNavigationWorkspaceRoute(
route: NavigationRouteLike | null | undefined,
): NavigationWorkspaceRouteState {
if (!route) {
return { kind: "unknown" };
}
if (typeof route.path === "string") {
const selection = parseHostWorkspaceRouteFromPathname(route.path);
if (selection) {
return { kind: "workspace", selection };
}
return { kind: "nonWorkspace" };
}
const selection = extractActiveWorkspaceFromRoute(route);
export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const selection =
parseHostWorkspaceRouteFromPathname(usePathname()) ??
parseWorkspaceSelectionFromRouteParams(params);
if (selection) {
return { kind: "workspace", selection };
lastWorkspaceSelection = selection;
}
return { kind: "unknown" };
}
function getActiveWorkspaceForNavigationSync(
route: NavigationRouteLike | null | undefined,
): ActiveWorkspaceSelection | null {
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
const browserWorkspace = getBrowserLocationWorkspace();
return browserWorkspace ?? routeState.selection;
}
if (routeState.kind === "nonWorkspace") {
return null;
}
return getBrowserLocationWorkspace();
}
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
const route = navigationRef.current?.getCurrentRoute();
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
setLastWorkspaceRouteSelection(routeState.selection);
if (nextWorkspaceRouteSelectionOverride) {
const overrideSelection = nextWorkspaceRouteSelectionOverride;
nextWorkspaceRouteSelectionOverride = null;
emitIfChanged(overrideSelection);
return;
}
}
emitIfChanged(getActiveWorkspaceForNavigationSync(route));
}
export function activateNavigationWorkspaceSelection(
next: ActiveWorkspaceSelection,
options: ActivateWorkspaceSelectionOptions = {},
) {
setLastWorkspaceRouteSelection(next);
writeBrowserWorkspaceUrl(next, options);
emitIfChanged(next);
}
export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
return getSnapshot();
}
export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelection | null {
return lastWorkspaceRouteSelection;
}
export function getIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return isLastWorkspaceRouteSelectionLoaded;
}
export function hydrateLastNavigationWorkspaceRouteSelection(): Promise<void> {
if (lastWorkspaceRouteSelectionHydrationPromise) {
return lastWorkspaceRouteSelectionHydrationPromise;
}
const hydrationRevision = lastWorkspaceRouteSelectionRevision;
lastWorkspaceRouteSelectionHydrationPromise =
readLastWorkspaceRouteSelectionFromStorage(hydrationRevision);
return lastWorkspaceRouteSelectionHydrationPromise;
}
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
nextWorkspaceRouteSelectionOverride = next;
}
export function syncBrowserActiveWorkspaceFromLocation() {
emitIfChanged(getBrowserLocationWorkspace());
}
export function addBrowserActiveWorkspaceLocationListener(): () => void {
if (!isWeb || typeof window === "undefined") {
return () => {};
}
const handlePopState = () => {
syncBrowserActiveWorkspaceFromLocation();
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}
export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return useSyncExternalStore(
subscribe,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
);
}
export function useIsNavigationWorkspaceSelected(input: {
serverId: string | null;
workspaceId: string | null;
enabled?: boolean;
}): boolean {
const enabled = input.enabled !== false;
return useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getSnapshot,
(selection) =>
enabled &&
Boolean(input.serverId) &&
Boolean(input.workspaceId) &&
selection?.serverId === input.serverId &&
selection.workspaceId === input.workspaceId,
Object.is,
);
}
void hydrateLastNavigationWorkspaceRouteSelection();
export function useIsNavigationProjectActive(input: {
serverId: string | null;
workspaceIds: readonly string[];
enabled?: boolean;
}): boolean {
const enabled = input.enabled !== false;
const workspaceIdsKey = input.workspaceIds.join("\0");
const workspaceIdSet = useMemo(() => {
void workspaceIdsKey;
return new Set(input.workspaceIds);
}, [input.workspaceIds, workspaceIdsKey]);
return useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getSnapshot,
(selection) =>
enabled &&
Boolean(input.serverId) &&
selection?.serverId === input.serverId &&
workspaceIdSet.has(selection.workspaceId),
Object.is,
);
return selection;
}