mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72b87757c6 | |||
| b6dc3eac19 | |||
| 14d4786e2d | |||
| a7b5bc428e | |||
| 3079385980 | |||
| 3f74db2399 |
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { DesktopWorkspace, MobileWorkspace } from './components/WorkspaceLayout';
|
||||
import TelemetryNotification from './components/features/TelemetryNotification';
|
||||
@@ -17,16 +17,6 @@ const MobileChatPage = memo(() => {
|
||||
});
|
||||
|
||||
const DesktopChatPage = memo(() => {
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('DesktopChatPage');
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle />
|
||||
|
||||
@@ -24,7 +24,6 @@ const CloudBanner = dynamic(() => import('@/features/AlertBanner/CloudBanner'));
|
||||
|
||||
const Layout = memo((props: { locale: Locales }) => {
|
||||
const { locale } = props;
|
||||
|
||||
const { isPWA } = usePlatform();
|
||||
const theme = useTheme();
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { memo, Suspense, useMemo } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import type { Locales } from '@/types/locale';
|
||||
|
||||
import { createDesktopRouter } from './desktopRouter.config';
|
||||
@@ -11,68 +12,34 @@ interface ClientRouterProps {
|
||||
locale: Locales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure CSR Loading Fallback Component
|
||||
*
|
||||
* This component is displayed during:
|
||||
* - Initial router data loading
|
||||
* - Route transitions with loaders
|
||||
* - Any async route-level operations
|
||||
*
|
||||
* NOTE: This runs ONLY on the client, never during SSR
|
||||
*/
|
||||
const RouterLoadingFallback = () => (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
justifyContent: 'center',
|
||||
width: '100vw',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
animation: 'spin 1s linear infinite',
|
||||
border: '4px solid #f3f3f3',
|
||||
borderRadius: '50%',
|
||||
borderTop: '4px solid #3498db',
|
||||
height: '40px',
|
||||
margin: '0 auto 16px',
|
||||
width: '40px',
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: '#666', fontSize: '14px' }}>Loading...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
type RouterInstance = ReturnType<typeof createDesktopRouter>;
|
||||
|
||||
/**
|
||||
* Desktop Client Router Component
|
||||
*
|
||||
* Pure CSR (Client-Side Rendering) implementation:
|
||||
* - Wrapped with Next.js dynamic import (ssr: false) to prevent SSR
|
||||
* - Uses React Suspense for client-side loading states
|
||||
* - Router instance is memoized based on locale
|
||||
* - All route loaders execute ONLY in the browser
|
||||
*
|
||||
* This component uses ReactDOM.createRoot semantics (via Next.js dynamic import),
|
||||
* NOT hydration. There is no server-rendered HTML to match against.
|
||||
*/
|
||||
const DesktopClientRouter = memo<ClientRouterProps>(({ locale }) => {
|
||||
const router = useMemo(() => createDesktopRouter(locale), [locale]);
|
||||
// Use state to hold router instance, initially null
|
||||
const [router, setRouter] = useState<RouterInstance | null>(null);
|
||||
|
||||
// useEffect ensures this only runs on the client
|
||||
useEffect(() => {
|
||||
// Create router instance only after component mounts in browser
|
||||
const desktopRouter = createDesktopRouter(locale);
|
||||
setRouter(desktopRouter);
|
||||
|
||||
// Cleanup is not necessary as router should persist until unmount
|
||||
return () => {
|
||||
// Cleanup if needed
|
||||
};
|
||||
}, [locale]); // Recreate router if locale changes
|
||||
|
||||
// If router hasn't been created yet (during SSR or first client render),
|
||||
// show a loading placeholder. This ensures server output matches client output,
|
||||
// avoiding hydration mismatch.
|
||||
if (!router) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
// Once router is created, render RouterProvider
|
||||
return (
|
||||
<Suspense fallback={<RouterLoadingFallback />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
<RouterProvider router={router} />
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { Locales } from '@/types/locale';
|
||||
|
||||
/**
|
||||
* Dynamic import with SSR disabled
|
||||
*
|
||||
* This creates a pure CSR boundary:
|
||||
* - Server renders: nothing (null)
|
||||
* - Client hydration: null (matches server)
|
||||
* - After hydration: DesktopClientRouter mounts with ReactDOM.createRoot semantics
|
||||
*
|
||||
* The loading component is optional here since DesktopClientRouter
|
||||
* has its own fallbackElement for route-level loading states.
|
||||
*/
|
||||
const DesktopRouterClient = dynamic(() => import('./DesktopClientRouter'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface DesktopRouterProps {
|
||||
locale: Locales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop Router Wrapper
|
||||
*
|
||||
* This wrapper exists to:
|
||||
* 1. Create a client component boundary for Next.js
|
||||
* 2. Enable code splitting (Desktop bundle separated from Mobile)
|
||||
* 3. Ensure the entire react-router-dom tree is client-only
|
||||
*/
|
||||
const DesktopRouter = ({ locale }: DesktopRouterProps) => {
|
||||
return <DesktopRouterClient locale={locale} />;
|
||||
};
|
||||
|
||||
export default DesktopRouter;
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { memo, Suspense, useMemo } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import type { Locales } from '@/types/locale';
|
||||
|
||||
import { createMobileRouter } from './mobileRouter.config';
|
||||
@@ -11,68 +12,34 @@ interface ClientRouterProps {
|
||||
locale: Locales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure CSR Loading Fallback Component
|
||||
*
|
||||
* This component is displayed during:
|
||||
* - Initial router data loading
|
||||
* - Route transitions with loaders
|
||||
* - Any async route-level operations
|
||||
*
|
||||
* NOTE: This runs ONLY on the client, never during SSR
|
||||
*/
|
||||
const RouterLoadingFallback = () => (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
justifyContent: 'center',
|
||||
width: '100vw',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
animation: 'spin 1s linear infinite',
|
||||
border: '4px solid #f3f3f3',
|
||||
borderRadius: '50%',
|
||||
borderTop: '4px solid #3498db',
|
||||
height: '40px',
|
||||
margin: '0 auto 16px',
|
||||
width: '40px',
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: '#666', fontSize: '14px' }}>Loading...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
type RouterInstance = ReturnType<typeof createMobileRouter>;
|
||||
|
||||
/**
|
||||
* Mobile Client Router Component
|
||||
*
|
||||
* Pure CSR (Client-Side Rendering) implementation:
|
||||
* - Wrapped with Next.js dynamic import (ssr: false) to prevent SSR
|
||||
* - Uses React Suspense for client-side loading states
|
||||
* - Router instance is memoized based on locale
|
||||
* - All route loaders execute ONLY in the browser
|
||||
*
|
||||
* This component uses ReactDOM.createRoot semantics (via Next.js dynamic import),
|
||||
* NOT hydration. There is no server-rendered HTML to match against.
|
||||
*/
|
||||
const MobileClientRouter = memo<ClientRouterProps>(({ locale }) => {
|
||||
const router = useMemo(() => createMobileRouter(locale), [locale]);
|
||||
// Use state to hold router instance, initially null
|
||||
const [router, setRouter] = useState<RouterInstance | null>(null);
|
||||
|
||||
// useEffect ensures this only runs on the client
|
||||
useEffect(() => {
|
||||
// Create router instance only after component mounts in browser
|
||||
const mobileRouter = createMobileRouter(locale);
|
||||
setRouter(mobileRouter);
|
||||
|
||||
// Cleanup is not necessary as router should persist until unmount
|
||||
return () => {
|
||||
// Cleanup if needed
|
||||
};
|
||||
}, [locale]); // Recreate router if locale changes
|
||||
|
||||
// If router hasn't been created yet (during SSR or first client render),
|
||||
// show a loading placeholder. This ensures server output matches client output,
|
||||
// avoiding hydration mismatch.
|
||||
if (!router) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
// Once router is created, render RouterProvider
|
||||
return (
|
||||
<Suspense fallback={<RouterLoadingFallback />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
<RouterProvider router={router} />
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { Locales } from '@/types/locale';
|
||||
|
||||
/**
|
||||
* Dynamic import with SSR disabled
|
||||
*
|
||||
* This creates a pure CSR boundary:
|
||||
* - Server renders: nothing (null)
|
||||
* - Client hydration: null (matches server)
|
||||
* - After hydration: MobileClientRouter mounts with ReactDOM.createRoot semantics
|
||||
*
|
||||
* The loading component is optional here since MobileClientRouter
|
||||
* has its own fallbackElement for route-level loading states.
|
||||
*/
|
||||
const MobileRouterClient = dynamic(() => import('./MobileClientRouter'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface MobileRouterProps {
|
||||
locale: Locales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile Router Wrapper
|
||||
*
|
||||
* This wrapper exists to:
|
||||
* 1. Create a client component boundary for Next.js
|
||||
* 2. Enable code splitting (Mobile bundle separated from Desktop)
|
||||
* 3. Ensure the entire react-router-dom tree is client-only
|
||||
*/
|
||||
const MobileRouter = ({ locale }: MobileRouterProps) => {
|
||||
return <MobileRouterClient locale={locale} />;
|
||||
};
|
||||
|
||||
export default MobileRouter;
|
||||
@@ -1,172 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { createBrowserRouter, redirect } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { type LoaderFunction, createBrowserRouter, redirect, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import type { Locales } from '@/types/locale';
|
||||
|
||||
import DesktopMainLayout from './(main)/layouts/desktop';
|
||||
import { idLoader, slugLoader } from './loaders/routeParams';
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
/**
|
||||
* Desktop Router Configuration - Pure CSR Mode
|
||||
*
|
||||
* IMPORTANT: This router runs ONLY in the browser (client-side).
|
||||
*
|
||||
* Key characteristics:
|
||||
* - createBrowserRouter uses window.history API (client-only)
|
||||
* - All loaders execute in the browser during navigation
|
||||
* - No server-side rendering or hydration involved
|
||||
* - Route data fetching happens on-demand during client navigation
|
||||
*
|
||||
* The entire router tree is wrapped with Next.js dynamic import (ssr: false),
|
||||
* ensuring this code never executes on the server.
|
||||
*/
|
||||
// Component to register navigate function in global store
|
||||
const NavigatorRegistrar = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Chat components
|
||||
const DesktopChatPage = dynamic(
|
||||
() => import('./(main)/chat/index').then((m) => m.DesktopChatPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const ChatLayout = dynamic(() => import('./(main)/chat/_layout/Desktop'), { ssr: false });
|
||||
useEffect(() => {
|
||||
useGlobalStore.setState({ navigate });
|
||||
|
||||
// Discover List components
|
||||
const DesktopHomePage = dynamic(
|
||||
() => import('./(main)/discover/(list)/(home)/index').then((m) => m.DesktopHomePage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DesktopAssistantPage = dynamic(
|
||||
() => import('./(main)/discover/(list)/assistant/index').then((m) => m.DesktopAssistantPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverAssistantLayout = dynamic(
|
||||
() => import('./(main)/discover/(list)/assistant/_layout/Desktop'),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverListMcpPage = dynamic(
|
||||
() => import('./(main)/discover/(list)/mcp/index').then((m) => m.DesktopMcpPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverMcpLayout = dynamic(
|
||||
() => import('./(main)/discover/(list)/mcp/_layout/Desktop'),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverListModelPage = dynamic(
|
||||
() => import('./(main)/discover/(list)/model/index').then((m) => m.DesktopModelPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverModelLayout = dynamic(
|
||||
() => import('./(main)/discover/(list)/model/_layout/Desktop'),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverListProviderPage = dynamic(
|
||||
() => import('./(main)/discover/(list)/provider/index').then((m) => m.DesktopProviderPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverListLayout = dynamic(
|
||||
() => import('./(main)/discover/(list)/_layout/Desktop/index'),
|
||||
{ ssr: false },
|
||||
);
|
||||
return () => {
|
||||
useGlobalStore.setState({ navigate: undefined });
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Discover Detail components
|
||||
const DesktopDiscoverAssistantDetailPage = dynamic(
|
||||
() =>
|
||||
import('./(main)/discover/(detail)/assistant/index').then(
|
||||
(m) => m.DesktopDiscoverAssistantDetailPage,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverDetailMcpPage = dynamic(
|
||||
() => import('./(main)/discover/(detail)/mcp/index').then((m) => m.DesktopMcpPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverDetailModelPage = dynamic(
|
||||
() => import('./(main)/discover/(detail)/model/index').then((m) => m.DesktopModelPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverDetailProviderPage = dynamic(
|
||||
() => import('./(main)/discover/(detail)/provider/index').then((m) => m.DesktopProviderPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverDetailLayout = dynamic(
|
||||
() => import('./(main)/discover/(detail)/_layout/Desktop'),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DiscoverLayout = dynamic(
|
||||
() => import('./(main)/discover/_layout/Desktop/index'),
|
||||
{ ssr: false },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Knowledge components
|
||||
const KnowledgeHome = dynamic(() => import('./(main)/knowledge/routes/KnowledgeHome'), {
|
||||
ssr: false,
|
||||
});
|
||||
const KnowledgeBasesList = dynamic(() => import('./(main)/knowledge/routes/KnowledgeBasesList'), {
|
||||
ssr: false,
|
||||
});
|
||||
const KnowledgeBaseDetail = dynamic(
|
||||
() => import('./(main)/knowledge/routes/KnowledgeBaseDetail'),
|
||||
{ ssr: false },
|
||||
);
|
||||
const KnowledgeLayout = dynamic(() => import('./(main)/knowledge/_layout/Desktop'), {
|
||||
ssr: false,
|
||||
});
|
||||
// Root layout wrapper component - just registers navigator and renders outlet
|
||||
// Note: Desktop layout is provided by individual route components
|
||||
const RootLayout = (props: { locale: Locales }) => {
|
||||
return (
|
||||
<>
|
||||
<NavigatorRegistrar />
|
||||
<DesktopMainLayout locale={props.locale} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Settings components
|
||||
const SettingsLayout = dynamic(() => import('./(main)/settings/_layout/Desktop'), { ssr: false });
|
||||
const SettingsLayoutWrapper = dynamic(() => import('./(main)/settings/_layout/DesktopWrapper'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
// Image components
|
||||
const ImagePage = dynamic(() => import('./(main)/image'), { ssr: false });
|
||||
const ImageLayoutWrapper = dynamic(() => import('./(main)/image/_layout/DesktopWrapper'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
// Labs components
|
||||
const LabsPage = dynamic(() => import('./(main)/labs'), { ssr: false });
|
||||
|
||||
// Profile components
|
||||
const ProfileHomePage = dynamic(() => import('./(main)/profile/(home)/desktop'), { ssr: false });
|
||||
const ProfileApikeyPage = dynamic(() => import('./(main)/profile/apikey/index'), { ssr: false });
|
||||
const DesktopProfileSecurityPage = dynamic(
|
||||
() => import('./(main)/profile/security/index').then((m) => m.DesktopProfileSecurityPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DesktopProfileStatsPage = dynamic(
|
||||
() => import('./(main)/profile/stats/index').then((m) => m.DesktopProfileStatsPage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const DesktopProfileUsagePage = dynamic(
|
||||
() => import('./(main)/profile/usage/index').then((m) => m.DesktopProfileUsagePage),
|
||||
{ ssr: false },
|
||||
);
|
||||
const ProfileLayoutWrapper = dynamic(() => import('./(main)/profile/_layout/DesktopWrapper'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
// Root layout wrapper component
|
||||
const RootLayout = (props: { locale: Locales }) => <DesktopMainLayout locale={props.locale} />;
|
||||
// Hydration gate loader -always return true to bypass hydration gate
|
||||
const hydrationGateLoader: LoaderFunction = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Create desktop router configuration
|
||||
export const createDesktopRouter = (locale: Locales) =>
|
||||
createBrowserRouter([
|
||||
{
|
||||
HydrateFallback: () => <Loading />,
|
||||
children: [
|
||||
// Chat routes
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <DesktopChatPage />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/chat/index').then((m) => ({
|
||||
Component: m.DesktopChatPage,
|
||||
})),
|
||||
},
|
||||
{
|
||||
element: <DesktopChatPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/chat/index').then((m) => ({
|
||||
Component: m.DesktopChatPage,
|
||||
})),
|
||||
path: '*',
|
||||
},
|
||||
],
|
||||
element: <ChatLayout />,
|
||||
lazy: () =>
|
||||
import('./(main)/chat/_layout/Desktop').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'chat',
|
||||
},
|
||||
|
||||
@@ -179,72 +81,117 @@ export const createDesktopRouter = (locale: Locales) =>
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <DesktopAssistantPage />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/assistant/index').then((m) => ({
|
||||
Component: m.DesktopAssistantPage,
|
||||
})),
|
||||
},
|
||||
],
|
||||
element: <DiscoverAssistantLayout />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/assistant/_layout/Desktop').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'assistant',
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <DiscoverListModelPage />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/model/index').then((m) => ({
|
||||
Component: m.DesktopModelPage,
|
||||
})),
|
||||
},
|
||||
],
|
||||
element: <DiscoverModelLayout />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/model/_layout/Desktop').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'model',
|
||||
},
|
||||
{
|
||||
element: <DiscoverListProviderPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/provider/index').then((m) => ({
|
||||
Component: m.DesktopProviderPage,
|
||||
})),
|
||||
path: 'provider',
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <DiscoverListMcpPage />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/mcp/index').then((m) => ({
|
||||
Component: m.DesktopMcpPage,
|
||||
})),
|
||||
},
|
||||
],
|
||||
element: <DiscoverMcpLayout />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/mcp/_layout/Desktop').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'mcp',
|
||||
},
|
||||
{
|
||||
element: <DesktopHomePage />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/(home)/index').then((m) => ({
|
||||
Component: m.DesktopHomePage,
|
||||
})),
|
||||
},
|
||||
],
|
||||
element: <DiscoverListLayout />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(list)/_layout/Desktop/index').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
// Detail routes (with DetailLayout)
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <DesktopDiscoverAssistantDetailPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(detail)/assistant/index').then((m) => ({
|
||||
Component: m.DesktopDiscoverAssistantDetailPage,
|
||||
})),
|
||||
loader: slugLoader,
|
||||
path: 'assistant/:slug',
|
||||
},
|
||||
{
|
||||
element: <DiscoverDetailModelPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(detail)/model/index').then((m) => ({
|
||||
Component: m.DesktopModelPage,
|
||||
})),
|
||||
loader: slugLoader,
|
||||
path: 'model/:slug',
|
||||
},
|
||||
{
|
||||
element: <DiscoverDetailProviderPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(detail)/provider/index').then((m) => ({
|
||||
Component: m.DesktopProviderPage,
|
||||
})),
|
||||
loader: slugLoader,
|
||||
path: 'provider/:slug',
|
||||
},
|
||||
{
|
||||
element: <DiscoverDetailMcpPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(detail)/mcp/index').then((m) => ({
|
||||
Component: m.DesktopMcpPage,
|
||||
})),
|
||||
loader: slugLoader,
|
||||
path: 'mcp/:slug',
|
||||
},
|
||||
],
|
||||
element: <DiscoverDetailLayout />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/(detail)/_layout/Desktop').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
],
|
||||
element: <DiscoverLayout />,
|
||||
lazy: () =>
|
||||
import('./(main)/discover/_layout/Desktop/index').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'discover',
|
||||
},
|
||||
|
||||
@@ -252,25 +199,40 @@ export const createDesktopRouter = (locale: Locales) =>
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <KnowledgeHome />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/knowledge/routes/KnowledgeHome').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
element: <KnowledgeBasesList />,
|
||||
lazy: () =>
|
||||
import('./(main)/knowledge/routes/KnowledgeBasesList').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'bases',
|
||||
},
|
||||
{
|
||||
element: <KnowledgeBaseDetail />,
|
||||
lazy: () =>
|
||||
import('./(main)/knowledge/routes/KnowledgeBaseDetail').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
loader: idLoader,
|
||||
path: 'bases/:id',
|
||||
},
|
||||
{
|
||||
element: <KnowledgeBaseDetail />,
|
||||
lazy: () =>
|
||||
import('./(main)/knowledge/routes/KnowledgeBaseDetail').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
loader: idLoader,
|
||||
path: '*',
|
||||
},
|
||||
],
|
||||
element: <KnowledgeLayout />,
|
||||
lazy: () =>
|
||||
import('./(main)/knowledge/_layout/Desktop').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'knowledge',
|
||||
},
|
||||
|
||||
@@ -278,11 +240,17 @@ export const createDesktopRouter = (locale: Locales) =>
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <SettingsLayout />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/settings/_layout/Desktop').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
],
|
||||
element: <SettingsLayoutWrapper />,
|
||||
lazy: () =>
|
||||
import('./(main)/settings/_layout/DesktopWrapper').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'settings',
|
||||
},
|
||||
|
||||
@@ -290,17 +258,26 @@ export const createDesktopRouter = (locale: Locales) =>
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <ImagePage />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/image').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
],
|
||||
element: <ImageLayoutWrapper />,
|
||||
lazy: () =>
|
||||
import('./(main)/image/_layout/DesktopWrapper').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'image',
|
||||
},
|
||||
|
||||
// Labs routes
|
||||
{
|
||||
element: <LabsPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/labs').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'labs',
|
||||
},
|
||||
|
||||
@@ -308,27 +285,45 @@ export const createDesktopRouter = (locale: Locales) =>
|
||||
{
|
||||
children: [
|
||||
{
|
||||
element: <ProfileHomePage />,
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import('./(main)/profile/(home)/desktop').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
element: <ProfileApikeyPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/profile/apikey/index').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'apikey',
|
||||
},
|
||||
{
|
||||
element: <DesktopProfileSecurityPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/profile/security/index').then((m) => ({
|
||||
Component: m.DesktopProfileSecurityPage,
|
||||
})),
|
||||
path: 'security',
|
||||
},
|
||||
{
|
||||
element: <DesktopProfileStatsPage />,
|
||||
lazy: () =>
|
||||
import('./(main)/profile/stats/index').then((m) => ({
|
||||
Component: m.DesktopProfileStatsPage,
|
||||
})),
|
||||
path: 'stats',
|
||||
},
|
||||
{
|
||||
element: <DesktopProfileUsagePage />,
|
||||
lazy: () =>
|
||||
import('./(main)/profile/usage/index').then((m) => ({
|
||||
Component: m.DesktopProfileUsagePage,
|
||||
})),
|
||||
path: 'usage',
|
||||
},
|
||||
],
|
||||
element: <ProfileLayoutWrapper />,
|
||||
lazy: () =>
|
||||
import('./(main)/profile/_layout/DesktopWrapper').then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
path: 'profile',
|
||||
},
|
||||
|
||||
@@ -345,6 +340,7 @@ export const createDesktopRouter = (locale: Locales) =>
|
||||
},
|
||||
],
|
||||
element: <RootLayout locale={locale} />,
|
||||
loader: hydrationGateLoader,
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { createBrowserRouter, redirect, useNavigate } from 'react-router-dom';
|
||||
import { type LoaderFunction, createBrowserRouter, redirect, useNavigate } from 'react-router-dom';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import type { Locales } from '@/types/locale';
|
||||
|
||||
import { MobileMainLayout } from './(main)/layouts/mobile';
|
||||
import { idLoader, slugLoader } from './loaders/routeParams';
|
||||
|
||||
/**
|
||||
* Mobile Router Configuration - Pure CSR Mode
|
||||
*
|
||||
* IMPORTANT: This router runs ONLY in the browser (client-side).
|
||||
*
|
||||
* Key characteristics:
|
||||
* - createBrowserRouter uses window.history API (client-only)
|
||||
* - All loaders execute in the browser during navigation
|
||||
* - No server-side rendering or hydration involved
|
||||
* - Route data fetching happens on-demand during client navigation
|
||||
*
|
||||
* The entire router tree is wrapped with Next.js dynamic import (ssr: false),
|
||||
* ensuring this code never executes on the server.
|
||||
*/
|
||||
|
||||
// Component to register navigate function in global store
|
||||
const NavigatorRegistrar = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -48,11 +34,16 @@ const RootLayout = (props: { locale: Locales }) => (
|
||||
</>
|
||||
);
|
||||
|
||||
// Hydration gate loader -always return true to bypass hydration gate
|
||||
const hydrationGateLoader: LoaderFunction = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Create mobile router configuration
|
||||
export const createMobileRouter = (locale: Locales) =>
|
||||
createBrowserRouter([
|
||||
{
|
||||
HydrateFallback: () => <Loading />,
|
||||
children: [
|
||||
// Chat routes
|
||||
{
|
||||
@@ -381,6 +372,7 @@ export const createMobileRouter = (locale: Locales) =>
|
||||
},
|
||||
],
|
||||
element: <RootLayout locale={locale} />,
|
||||
loader: hydrationGateLoader,
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { DynamicLayoutProps } from '@/types/next';
|
||||
import { RouteVariants } from '@/utils/server/routeVariants';
|
||||
|
||||
import DesktopRouter from './DesktopRouter';
|
||||
import MobileRouter from './MobileRouter';
|
||||
import DesktopClientRouter from './DesktopClientRouter';
|
||||
import MobileClientRouter from './MobileClientRouter';
|
||||
|
||||
export default async (props: DynamicLayoutProps) => {
|
||||
// Get isMobile from variants parameter on server side
|
||||
const isMobile = await RouteVariants.getIsMobile(props);
|
||||
const { locale } = await RouteVariants.getVariantsFromProps(props);
|
||||
|
||||
// Conditionally load and render based on device type
|
||||
// Using native dynamic import ensures complete code splitting
|
||||
// Mobile and Desktop bundles will be completely separate
|
||||
// Pass device type and locale to client-side RouterSelector
|
||||
// This ensures the server component only does data fetching,
|
||||
// and the actual router rendering happens entirely on the client
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileRouter locale={locale} />;
|
||||
return <MobileClientRouter locale={locale} />;
|
||||
}
|
||||
|
||||
return <DesktopRouter locale={locale} />;
|
||||
return <DesktopClientRouter locale={locale} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* HydrationDebugger - 用于调试 React 水合问题的工具类
|
||||
*/
|
||||
class HydrationDebugger {
|
||||
/**
|
||||
* 比较服务端和客户端 HTML 的差异
|
||||
* @param serverHtml - 从服务器获取的纯 HTML 字符串
|
||||
*/
|
||||
static debugHydration(serverHtml: string) {
|
||||
// 确保这个方法只在浏览器环境中执行
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('[HydrationDebugger] debugHydration 只能在客户端调用。');
|
||||
return;
|
||||
}
|
||||
|
||||
// 格式化函数,使 HTML 更易于比较
|
||||
const formatHtml = (html: string): string => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = html;
|
||||
|
||||
// 简单的格式化逻辑:通过缩进标准化结构
|
||||
let formatted = '';
|
||||
let indent = '';
|
||||
const nodes = el.innerHTML.split(/>\s*</);
|
||||
|
||||
nodes.forEach((node, index, arr) => {
|
||||
if (/^\/\w/.test(node)) {
|
||||
indent = indent.slice(2);
|
||||
}
|
||||
|
||||
let closing = '>';
|
||||
// 如果不是自闭合标签或者最后一个节点
|
||||
if (node.includes('</') === false && index !== arr.length - 1) {
|
||||
closing = '>\n';
|
||||
}
|
||||
|
||||
formatted += indent + '<' + node + closing;
|
||||
|
||||
if (/^<?\w[^>]*[^/]$/.test(node)) {
|
||||
indent += ' ';
|
||||
}
|
||||
});
|
||||
|
||||
return formatted.trim();
|
||||
};
|
||||
|
||||
console.log('--- 开始水合差异调试 ---');
|
||||
|
||||
// 1. 获取客户端渲染后的 HTML
|
||||
const clientBodyHtml = document.body.innerHTML;
|
||||
|
||||
// 2. 为了简化对比,我们只关注 body 内部
|
||||
const serverBodyMatch = serverHtml.match(/<body[^>]*>([\S\s]*)<\/body>/);
|
||||
|
||||
if (serverBodyMatch?.[1]) {
|
||||
const serverBodyHtml = serverBodyMatch[1];
|
||||
|
||||
if (serverBodyHtml.trim() === clientBodyHtml.trim()) {
|
||||
console.log(
|
||||
'%c✅ 水合匹配成功!服务器和客户端主体内容一致。',
|
||||
'color: green; font-weight: bold;',
|
||||
);
|
||||
} else {
|
||||
console.error('%c❌ 水合不匹配!服务器和客户端主体内容存在差异。', 'color: red; font-weight: bold;');
|
||||
|
||||
// 使用 console.group 来组织输出,方便折叠
|
||||
console.groupCollapsed('🔍 服务端 Body HTML (格式化后)');
|
||||
console.log(formatHtml(serverBodyHtml));
|
||||
console.groupEnd();
|
||||
|
||||
console.groupCollapsed('🔍 客户端 Body HTML (格式化后)');
|
||||
console.log(formatHtml(clientBodyHtml));
|
||||
console.groupEnd();
|
||||
|
||||
// 尝试找出具体差异点
|
||||
this.findDifferences(serverBodyHtml, clientBodyHtml);
|
||||
|
||||
console.log('%c💡 提示: 请使用文本对比工具比较以上两份 HTML 以定位差异点。', 'color: blue;');
|
||||
}
|
||||
} else {
|
||||
console.error('[HydrationDebugger] 无法从服务端 HTML 中提取 <body> 内容。');
|
||||
}
|
||||
|
||||
console.log('--- 水合差异调试结束 ---');
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试找出具体的差异点
|
||||
*/
|
||||
private static findDifferences(serverHtml: string, clientHtml: string) {
|
||||
// 简单的差异检测:比较长度和部分内容
|
||||
console.groupCollapsed('📊 差异统计');
|
||||
console.log(`服务端 HTML 长度: ${serverHtml.length} 字符`);
|
||||
console.log(`客户端 HTML 长度: ${clientHtml.length} 字符`);
|
||||
console.log(`差异: ${Math.abs(serverHtml.length - clientHtml.length)} 字符`);
|
||||
|
||||
// 检查常见的水合错误模式
|
||||
const patterns = [
|
||||
{ name: 'localStorage 相关', regex: /localStorage/g },
|
||||
{ name: 'sessionStorage 相关', regex: /sessionStorage/g },
|
||||
{ name: 'window 对象访问', regex: /window\./g },
|
||||
{ name: 'document 对象访问', regex: /document\./g },
|
||||
{ name: 'data-reactroot 属性', regex: /data-reactroot/g },
|
||||
{ name: '空白字符差异', regex: /\s+/g },
|
||||
];
|
||||
|
||||
patterns.forEach(({ name, regex }) => {
|
||||
const serverMatches = serverHtml.match(regex)?.length || 0;
|
||||
const clientMatches = clientHtml.match(regex)?.length || 0;
|
||||
|
||||
if (serverMatches !== clientMatches) {
|
||||
console.warn(`⚠️ ${name} 出现次数不一致: 服务端 ${serverMatches}, 客户端 ${clientMatches}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HydrationDebugHelper - 自动对比服务端和客户端 HTML 的调试组件
|
||||
* 仅在开发环境使用
|
||||
*/
|
||||
const HydrationDebugHelper = () => {
|
||||
useEffect(() => {
|
||||
fetch(window.location.href)
|
||||
.then((res) => res.text())
|
||||
.then((serverHtml) => {
|
||||
// 使用 setTimeout 确保在 React 完成水合后再执行比较
|
||||
setTimeout(() => {
|
||||
HydrationDebugger.debugHydration(serverHtml);
|
||||
}, 1000); // 增加延迟以确保水合完成
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[HydrationDebugger] 获取服务端 HTML 失败:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null; // 这个组件不渲染任何 UI
|
||||
};
|
||||
|
||||
export default HydrationDebugHelper;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { enableNextAuth } from '@lobechat/const';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createStoreUpdater } from 'zustand-utils';
|
||||
|
||||
@@ -55,6 +55,14 @@ const StoreInitialization = memo(() => {
|
||||
*/
|
||||
const isLoginOnInit = Boolean(enableNextAuth ? isSignedIn : isLogin);
|
||||
|
||||
// 如果用户未登录,直接设置水合完成,避免应用卡住
|
||||
useEffect(() => {
|
||||
if (!isLoginOnInit) {
|
||||
useGlobalStore.setState({ isAppHydrated: true });
|
||||
console.log('[Hydration] Client state hydration completed (not logged in).');
|
||||
}
|
||||
}, [isLoginOnInit]);
|
||||
|
||||
// init inbox agent and default agent config
|
||||
useInitAgentStore(isLoginOnInit, serverConfig.defaultAgent?.config);
|
||||
|
||||
@@ -63,7 +71,16 @@ const StoreInitialization = memo(() => {
|
||||
|
||||
// init user state
|
||||
useInitUserState(isLoginOnInit, serverConfig, {
|
||||
onError: () => {
|
||||
// 即使失败也要设置标志,避免应用卡住
|
||||
useGlobalStore.setState({ isAppHydrated: true });
|
||||
console.warn('[Hydration] Client state initialization failed.');
|
||||
},
|
||||
onSuccess: (state) => {
|
||||
// 设置水合完成标志
|
||||
useGlobalStore.setState({ isAppHydrated: true });
|
||||
console.log('[Hydration] Client state initialized successfully.');
|
||||
|
||||
if (state.isOnboard === false) {
|
||||
router.push('/onboard');
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { ReactNode, Suspense } from 'react';
|
||||
import { LobeAnalyticsProviderWrapper } from '@/components/Analytics/LobeAnalyticsProviderWrapper';
|
||||
import { getServerFeatureFlagsValue } from '@/config/featureFlags';
|
||||
import { appEnv } from '@/envs/app';
|
||||
import DevPanel from '@/features/DevPanel';
|
||||
import { getServerGlobalConfig } from '@/server/globalConfig';
|
||||
import { ServerConfigStoreProvider } from '@/store/serverConfig/Provider';
|
||||
import { getAntdLocale } from '@/utils/locale';
|
||||
|
||||
import AntdV5MonkeyPatch from './AntdV5MonkeyPatch';
|
||||
import AppTheme from './AppTheme';
|
||||
import HydrationDebugHelper from './HydrationDebugHelper';
|
||||
import ImportSettings from './ImportSettings';
|
||||
import Locale from './Locale';
|
||||
import QueryProvider from './Query';
|
||||
@@ -63,7 +63,7 @@ const GlobalLayout = async ({
|
||||
<StoreInitialization />
|
||||
<Suspense>
|
||||
<ImportSettings />
|
||||
{process.env.NODE_ENV === 'development' && <DevPanel />}
|
||||
<HydrationDebugHelper />
|
||||
</Suspense>
|
||||
</ServerConfigStoreProvider>
|
||||
</AppTheme>
|
||||
|
||||
@@ -122,6 +122,12 @@ export interface GlobalState {
|
||||
* 启动时为 Idle,完成为 Ready,报错为 Error
|
||||
*/
|
||||
initClientDBStage: DatabaseLoadingState;
|
||||
/**
|
||||
* 应用水合状态标志
|
||||
* 用于指示客户端状态是否已从 StoreInitialization 完成加载
|
||||
* 默认为 false,StoreInitialization 完成后设置为 true
|
||||
*/
|
||||
isAppHydrated: boolean;
|
||||
isMobile?: boolean;
|
||||
isStatusInit?: boolean;
|
||||
latestVersion?: string;
|
||||
@@ -164,6 +170,7 @@ export const INITIAL_STATUS = {
|
||||
|
||||
export const initialState: GlobalState = {
|
||||
initClientDBStage: DatabaseLoadingState.Idle,
|
||||
isAppHydrated: false,
|
||||
isMobile: false,
|
||||
isStatusInit: false,
|
||||
sidebarKey: SidebarTabKey.Chat,
|
||||
|
||||
Reference in New Issue
Block a user