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
10 changed files with 264 additions and 94 deletions
@@ -0,0 +1,48 @@
'use client';
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';
interface ClientRouterProps {
locale: Locales;
}
type RouterInstance = ReturnType<typeof createDesktopRouter>;
const DesktopClientRouter = memo<ClientRouterProps>(({ 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 (
<RouterProvider router={router} />
);
});
DesktopClientRouter.displayName = 'DesktopClientRouter';
export default DesktopClientRouter;
-40
View File
@@ -1,40 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
import { memo, useMemo } from 'react';
import { RouterProvider } from 'react-router-dom';
import BootErrorBoundary from '@/components/BootErrorBoundary';
import Loading from '@/components/Loading/BrandTextLoading';
import type { Locales } from '@/types/locale';
import { createDesktopRouter } from './desktopRouter.config';
interface ClientRouterProps {
locale: Locales;
}
const ClientRouter = memo<ClientRouterProps>(({ locale }) => {
const router = useMemo(() => createDesktopRouter(locale), [locale]);
return (
<BootErrorBoundary fallback={<Loading />}>
<RouterProvider router={router} />
</BootErrorBoundary>
);
});
ClientRouter.displayName = 'ClientRouter';
const DesktopRouterClient = dynamic(() => Promise.resolve(ClientRouter), {
loading: () => <Loading />,
ssr: false,
});
interface DesktopRouterProps {
locale: Locales;
}
const DesktopRouter = ({ locale }: DesktopRouterProps) => {
return <DesktopRouterClient locale={locale} />;
};
export default DesktopRouter;
+48
View File
@@ -0,0 +1,48 @@
'use client';
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';
interface ClientRouterProps {
locale: Locales;
}
type RouterInstance = ReturnType<typeof createMobileRouter>;
const MobileClientRouter = memo<ClientRouterProps>(({ 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 (
<RouterProvider router={router} />
);
});
MobileClientRouter.displayName = 'MobileClientRouter';
export default MobileClientRouter;
-41
View File
@@ -1,41 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
import { memo, useMemo } from 'react';
import { RouterProvider } from 'react-router-dom';
import BootErrorBoundary from '@/components/BootErrorBoundary';
import Loading from '@/components/Loading/BrandTextLoading';
import type { Locales } from '@/types/locale';
import { createMobileRouter } from './mobileRouter.config';
interface ClientRouterProps {
locale: Locales;
}
const ClientRouter = memo<ClientRouterProps>(({ locale }) => {
const router = useMemo(() => createMobileRouter(locale), [locale]);
return (
<BootErrorBoundary fallback={<Loading />}>
<RouterProvider router={router} />
</BootErrorBoundary>
);
});
ClientRouter.displayName = 'ClientRouter';
const MobileRouterClient = dynamic(() => Promise.resolve(ClientRouter), {
loading: () => <Loading />,
ssr: false,
});
interface MobileRouterProps {
locale: Locales;
}
const MobileRouter = ({ locale }: MobileRouterProps) => {
return <MobileRouterClient locale={locale} />;
};
export default MobileRouter;
+2 -2
View File
@@ -3,12 +3,12 @@
import { useEffect } from 'react';
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 DesktopMainLayout from './(main)/layouts/desktop';
import { idLoader, slugLoader } from './loaders/routeParams';
import Loading from '@/components/Loading/BrandTextLoading';
// Component to register navigate function in global store
const NavigatorRegistrar = () => {
@@ -38,7 +38,7 @@ const RootLayout = (props: { locale: Locales }) => {
// Hydration gate loader -always return true to bypass hydration gate
const hydrationGateLoader: LoaderFunction = () => {
return true
return null;
};
// Create desktop router configuration
+1 -1
View File
@@ -36,7 +36,7 @@ const RootLayout = (props: { locale: Locales }) => (
// Hydration gate loader -always return true to bypass hydration gate
const hydrationGateLoader: LoaderFunction = () => {
return true
return null;
};
// Create mobile router configuration
+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);
+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>