♻️ refactor(navigation): stable navigate hook and imperative routing (#13795)

*  fix: implement stable navigation hook and refactor navigation handling

- Introduced `useStableNavigate` hook to provide a stable `navigate` function that can be used across the application.
- Refactored components to utilize the new stable navigation approach, replacing direct access to the navigation function from the global store.
- Updated `NavigatorRegistrar` to sync the `navigate` function into a ref for consistent access.
- Removed deprecated navigation handling from various components and actions, ensuring a cleaner and more maintainable codebase.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix: refactor navigation handling to prevent state mutation

- Updated navigation reference handling in the global store to use a dedicated function for creating navigation refs, ensuring that the initial state is not mutated by nested writes.
- Adjusted tests and components to utilize the new navigation ref creation method, enhancing stability and maintainability of navigation logic.

Signed-off-by: Innei <tukon479@gmail.com>

*  test: mock Electron's net.fetch in unit tests

- Added a mock for Electron's net.fetch in the AuthCtr and BackendProxyProtocolManager tests to ensure proper handling of remote server requests.
- This change allows tests to simulate network interactions without relying on the actual fetch implementation, improving test reliability.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2026-04-14 13:28:12 +08:00 committed by GitHub
parent cd0f65210c
commit a9c5badb80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 80 additions and 66 deletions

View file

@ -29,6 +29,11 @@ vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
net: {
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
global.fetch(input as any, init as any),
),
},
shell: {
openExternal: vi.fn().mockResolvedValue(undefined),
},

View file

@ -43,6 +43,11 @@ vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: vi.fn(),
},
net: {
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
global.fetch(input as any, init as any),
),
},
}));
describe('BackendProxyProtocolManager', () => {

View file

@ -0,0 +1,23 @@
import { useCallback } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import { getStableNavigate } from '@/utils/stableNavigate';
/**
* Stable `navigate` that forwards to the live ref on each call (see `NavigatorRegistrar`).
* Prefer over subscribing to `navigationRef` from `useGlobalStore` in components.
*/
export function useStableNavigate(): NavigateFunction {
return useCallback(
((to, options) => {
const navigate = getStableNavigate();
if (!navigate) return;
if (typeof to === 'number') {
navigate(to);
} else {
navigate(to, options);
}
}) as NavigateFunction,
[],
);
}

View file

@ -1,11 +1,9 @@
import { Flexbox } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { type FC, type ReactNode } from 'react';
import { Activity, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Activity, type FC, type ReactNode, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useIsDark } from '@/hooks/useIsDark';
import { useHomeStore } from '@/store/home';
import HomeAgentIdSync from './HomeAgentIdSync';
import RecentHydration from './RecentHydration';
@ -19,17 +17,11 @@ interface LayoutProps {
const Layout: FC<LayoutProps> = ({ children }) => {
const isDarkMode = useIsDark();
const theme = useTheme(); // Keep for colorBgContainerSecondary (not in cssVar)
const navigate = useNavigate();
const { pathname } = useLocation();
const isHomeRoute = pathname === '/';
const [hasActivated, setHasActivated] = useState(isHomeRoute);
const setNavigate = useHomeStore((s) => s.setNavigate);
const content = children ?? <Outlet />;
useEffect(() => {
setNavigate(navigate);
}, [navigate, setNavigate]);
useEffect(() => {
if (isHomeRoute) setHasActivated(true);
}, [isHomeRoute]);

View file

@ -8,6 +8,7 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useInitBuiltinAgent } from '@/hooks/useInitBuiltinAgent';
import { useStableNavigate } from '@/hooks/useStableNavigate';
import { type StarterMode } from '@/store/home';
import { useHomeStore } from '@/store/home';
@ -52,10 +53,10 @@ const StarterList = memo(() => {
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.groupAgentBuilder);
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.pageAgent);
const [inputActiveMode, setInputActiveMode, navigate] = useHomeStore((s) => [
const navigate = useStableNavigate();
const [inputActiveMode, setInputActiveMode] = useHomeStore((s) => [
s.inputActiveMode,
s.setInputActiveMode,
s.navigate,
]);
const items: StarterItem[] = useMemo(
@ -99,12 +100,12 @@ const StarterList = memo(() => {
const handleClick = useCallback(
(key: StarterMode) => {
if (key === 'video') {
navigate?.('/video?model=doubao-seedance-2-0-260128');
navigate('/video?model=doubao-seedance-2-0-260128');
return;
}
if (key === 'image') {
navigate?.('/image');
navigate('/image');
return;
}
@ -115,7 +116,7 @@ const StarterList = memo(() => {
setInputActiveMode(key);
}
},
[inputActiveMode, setInputActiveMode, navigate],
[inputActiveMode, navigate, setInputActiveMode],
);
return (

View file

@ -8,7 +8,6 @@ import Loading from '@/components/Loading/BrandTextLoading';
import { MarketAuthProvider } from '@/layout/AuthProvider/MarketAuth';
import dynamic from '@/libs/next/dynamic';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { NavigatorRegistrar } from '@/utils/router';
import NavBar from './NavBar';
@ -31,7 +30,6 @@ const MobileMainLayout: FC = () => {
const showNav = MOBILE_NAV_ROUTES.has(pathname);
return (
<>
<NavigatorRegistrar />
<Suspense fallback={null}>{showCloudPromotion && <CloudBanner mobile />}</Suspense>
<MarketAuthProvider isDesktop={false}>
<Suspense fallback={<Loading debugId="MobileMainLayout > Outlet" />}>

View file

@ -269,7 +269,7 @@ describe('createPreferenceSlice', () => {
const navigate = vi.fn();
act(() => {
useGlobalStore.setState({ navigate });
useGlobalStore.setState({ navigationRef: { current: navigate } });
result.current.switchBackToChat(sessionId);
});

View file

@ -4,6 +4,7 @@ import { INBOX_SESSION_ID } from '@/const/session';
import { SESSION_CHAT_URL } from '@/const/url';
import { type GlobalStore } from '@/store/global';
import { type StoreSetter } from '@/store/types';
import { getStableNavigate } from '@/utils/stableNavigate';
import { setNamespace } from '@/utils/storeDebug';
const n = setNamespace('w');
@ -23,7 +24,7 @@ export class GlobalWorkspacePaneActionImpl {
switchBackToChat = (sessionId?: string): void => {
const target = SESSION_CHAT_URL(sessionId || INBOX_SESSION_ID, this.#get().isMobile);
this.#get().navigate?.(target);
getStableNavigate()?.(target);
};
toggleAgentSystemRoleExpand = (agentId: string, expanded?: boolean): void => {

View file

@ -203,6 +203,13 @@ export interface SystemStatus {
zenMode?: boolean;
}
export interface GlobalNavigationRef {
current: NavigateFunction | null;
}
/** Fresh ref object — use for store init and resets so `initialState` is not aliased by nested mutation. */
export const createNavigationRef = (): GlobalNavigationRef => ({ current: null });
export interface GlobalState {
hasNewVersion?: boolean;
initClientDBError?: Error;
@ -225,7 +232,8 @@ export interface GlobalState {
isServerVersionOutdated?: boolean;
isStatusInit?: boolean;
latestVersion?: string;
navigate?: NavigateFunction;
/** Imperative router navigate; see `NavigatorRegistrar` in `src/utils/router.tsx`. */
navigationRef: GlobalNavigationRef;
/**
* Server version number, used to detect client-server version consistency
*/
@ -292,6 +300,7 @@ export const initialState: GlobalState = {
initClientDBStage: DatabaseLoadingState.Idle,
isMobile: false,
isStatusInit: false,
navigationRef: createNavigationRef(),
sidebarKey: SidebarTabKey.Chat,
status: INITIAL_STATUS,
statusStorage: new AsyncLocalStorage('LOBE_SYSTEM_STATUS'),

View file

@ -10,8 +10,7 @@ import { type GlobalGeneralAction } from './actions/general';
import { generalActionSlice } from './actions/general';
import { type GlobalWorkspacePaneAction } from './actions/workspacePane';
import { globalWorkspaceSlice } from './actions/workspacePane';
import { type GlobalState } from './initialState';
import { initialState } from './initialState';
import { createNavigationRef, type GlobalState, initialState } from './initialState';
// =============== Aggregate createStoreFn ============ //
@ -25,6 +24,8 @@ const createStore: StateCreator<GlobalStore, [['zustand/devtools', never]]> = (
...parameters: Parameters<StateCreator<GlobalStore, [['zustand/devtools', never]]>>
) => ({
...initialState,
// Own ref instance so nested `.current` writes never mutate exported `initialState.navigationRef`
navigationRef: createNavigationRef(),
...flattenActions<GlobalStoreAction>([
globalWorkspaceSlice(...parameters),
generalActionSlice(...parameters),

View file

@ -1,22 +1,20 @@
import { type HomeStore } from '@/store/home/store';
import { type StoreSetter } from '@/store/types';
import { getStableNavigate } from '@/utils/stableNavigate';
type Setter = StoreSetter<HomeStore>;
export const createGroupSlice = (set: Setter, get: () => HomeStore, _api?: unknown) =>
new GroupActionImpl(set, get, _api);
export class GroupActionImpl {
readonly #get: () => HomeStore;
constructor(set: Setter, get: () => HomeStore, _api?: unknown) {
void _api;
void set;
this.#get = get;
void get;
}
switchToGroup = (groupId: string): void => {
const { navigate } = this.#get();
navigate?.(`/group/${groupId}`);
getStableNavigate()?.(`/group/${groupId}`);
};
}

View file

@ -1,5 +1,3 @@
import { type NavigateFunction } from 'react-router-dom';
import { chatGroupService } from '@/services/chatGroup';
import { documentService } from '@/services/document';
import { getAgentStoreState } from '@/store/agent';
@ -8,6 +6,7 @@ import { getChatGroupStoreState } from '@/store/agentGroup';
import { useChatStore } from '@/store/chat';
import { type HomeStore } from '@/store/home/store';
import { type StoreSetter } from '@/store/types';
import { getStableNavigate } from '@/utils/stableNavigate';
import { setNamespace } from '@/utils/storeDebug';
import { type StarterMode } from './initialState';
@ -62,10 +61,7 @@ export class HomeInputActionImpl {
});
// 3. Navigate to Agent profile page
const { navigate } = this.#get();
if (navigate) {
navigate(`/agent/${result.agentId}/profile`);
}
getStableNavigate()?.(`/agent/${result.agentId}/profile`);
// 4. Refresh agent list
this.#get().refreshAgentList();
@ -126,10 +122,7 @@ export class HomeInputActionImpl {
this.#get().refreshAgentList();
// 5. Navigate to Group profile page
const { navigate } = this.#get();
if (navigate) {
navigate(`/group/${group.id}/profile`);
}
getStableNavigate()?.(`/group/${group.id}/profile`);
// 6. Update groupAgentBuilder's model config and send initial message
const groupAgentBuilderId = builtinAgentSelectors.groupAgentBuilderId(agentState);
@ -187,10 +180,7 @@ export class HomeInputActionImpl {
});
// 3. Navigate to Page
const { navigate } = this.#get();
if (navigate) {
navigate(`/page/${newDoc.id}`);
}
getStableNavigate()?.(`/page/${newDoc.id}`);
// 4. Update pageAgent's model config and send initial message
const pageAgentId = builtinAgentSelectors.pageAgentId(agentState);
@ -221,10 +211,6 @@ export class HomeInputActionImpl {
setInputActiveMode = (mode: StarterMode): void => {
this.#set({ inputActiveMode: mode }, false, n('setInputActiveMode', mode));
};
setNavigate = (navigate: NavigateFunction): void => {
this.#set({ navigate }, false, n('setNavigate'));
};
}
export type HomeInputAction = Pick<HomeInputActionImpl, keyof HomeInputActionImpl>;

View file

@ -1,15 +1,11 @@
import { type NavigateFunction } from 'react-router-dom';
export type StarterMode = 'agent' | 'group' | 'write' | 'video' | 'research' | 'image' | null;
export interface HomeInputState {
homeInputLoading: boolean;
inputActiveMode: StarterMode;
navigate?: NavigateFunction;
}
export const initialHomeInputState: HomeInputState = {
homeInputLoading: false,
inputActiveMode: null,
navigate: undefined,
};

View file

@ -2,7 +2,7 @@
import { ThemeProvider } from '@lobehub/ui';
import { type ComponentType, type ReactElement } from 'react';
import { lazy, memo, Suspense, useCallback, useEffect } from 'react';
import { lazy, memo, Suspense, useCallback, useLayoutEffect } from 'react';
import type { RouteObject } from 'react-router-dom';
import {
createBrowserRouter,
@ -17,6 +17,7 @@ import ErrorCapture from '@/components/Error';
import Loading from '@/components/Loading/BrandTextLoading';
import SPAGlobalProvider from '@/layout/SPAGlobalProvider';
import { useGlobalStore } from '@/store/global';
import { createNavigationRef } from '@/store/global/initialState';
import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError';
async function importModule<T>(importFn: () => Promise<T>): Promise<T> {
@ -121,27 +122,16 @@ export const ErrorBoundary = ({ resetPath }: ErrorBoundaryProps) => {
};
/**
* Component to register navigate function in global store
* This allows navigation to be triggered from anywhere in the app, including stores
*
* @example
* import { NavigatorRegistrar } from '@/utils/dynamicPage';
*
* // In router root layout:
* const RootLayout = () => (
* <>
* <NavigatorRegistrar />
* <YourMainLayout />
* </>
* );
* Syncs React Router's `navigate` into `navigationRef` (see `getStableNavigate` / `useStableNavigate`).
* Mounted once on {@link RouterRoot} so imperative navigation works app-wide (desktop + mobile).
*/
export const NavigatorRegistrar = memo(() => {
const navigate = useNavigate();
useEffect(() => {
useGlobalStore.setState({ navigate });
useLayoutEffect(() => {
useGlobalStore.setState({ navigationRef: { current: navigate } });
return () => {
useGlobalStore.setState({ navigate: undefined });
useGlobalStore.setState({ navigationRef: createNavigationRef() });
};
}, [navigate]);
@ -155,6 +145,7 @@ export interface CreateAppRouterOptions {
const RouterRoot = memo(() => (
<SPAGlobalProvider>
<BusinessGlobalProvider>
<NavigatorRegistrar />
<Outlet />
</BusinessGlobalProvider>
</SPAGlobalProvider>

View file

@ -0,0 +1,8 @@
import type { NavigateFunction } from 'react-router-dom';
import { useGlobalStore } from '@/store/global';
/** Current imperative navigate from the ref synced by `NavigatorRegistrar` (non-React call sites). */
export function getStableNavigate(): NavigateFunction | null {
return useGlobalStore.getState().navigationRef.current;
}