Compare commits

..

6 Commits

Author SHA1 Message Date
ONLY-yours 72b87757c6 fix: delete error boudle 2025-11-19 17:54:58 +08:00
ONLY-yours b6dc3eac19 fix: open debuger 2025-11-19 16:23:41 +08:00
ONLY-yours 14d4786e2d fix: add hydrate debug 2025-11-19 15:44:17 +08:00
ONLY-yours a7b5bc428e fix: slove hydrations error 2025-11-19 15:29:22 +08:00
ONLY-yours 3079385980 fix: when not login should isAppHydrated as true 2025-11-19 13:30:30 +08:00
ONLY-yours 3f74db2399 test: add hydrationGateLoader back 2025-11-19 13:16:14 +08:00
13 changed files with 409 additions and 403 deletions
+1 -11
View File
@@ -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();
+26 -59
View File
@@ -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} />
);
});
-38
View File
@@ -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;
+26 -59
View File
@@ -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} />
);
});
-38
View File
@@ -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;
+167 -171
View File
@@ -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: '/',
},
]);
+8 -16
View File
@@ -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: '/',
},
]);
+8 -7
View File
@@ -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');
}
+2 -2
View File
@@ -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>
+7
View File
@@ -122,6 +122,12 @@ export interface GlobalState {
* 启动时为 Idle,完成为 Ready,报错为 Error
*/
initClientDBStage: DatabaseLoadingState;
/**
* 应用水合状态标志
* 用于指示客户端状态是否已从 StoreInitialization 完成加载
* 默认为 falseStoreInitialization 完成后设置为 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,