mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
♻️ 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:
parent
cd0f65210c
commit
a9c5badb80
15 changed files with 80 additions and 66 deletions
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
23
src/hooks/useStableNavigate.ts
Normal file
23
src/hooks/useStableNavigate.ts
Normal 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,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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" />}>
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ describe('createPreferenceSlice', () => {
|
|||
const navigate = vi.fn();
|
||||
|
||||
act(() => {
|
||||
useGlobalStore.setState({ navigate });
|
||||
useGlobalStore.setState({ navigationRef: { current: navigate } });
|
||||
result.current.switchBackToChat(sessionId);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
8
src/utils/stableNavigate.ts
Normal file
8
src/utils/stableNavigate.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue