diff --git a/.agents/design/common/client-i18n-csr-migration.md b/.agents/design/common/client-i18n-csr-migration.md index bf07df6ddcdd..b579f1399153 100644 --- a/.agents/design/common/client-i18n-csr-migration.md +++ b/.agents/design/common/client-i18n-csr-migration.md @@ -156,53 +156,23 @@ client-only 门禁加载语言包后,通过 `addResourceBundle` 一次性注 `projects/app/public/locales`,并以版本化 URL 通过 HTTP backend 加载。试点期间不同时实现两套 资源加载机制。 -### 3.3 组件继续显式声明 namespace +### 3.3 CSR 完整语言包与 SSR 页面资源声明 -namespace 的正确性来源改为实际使用翻译的组件,而不是中央路由配置。迁移组件时,将无参调用改为 -显式声明: +CSR 应用在挂载业务子树前注册完整语言包,因此业务组件统一调用 `useSafeTranslation()`, +不再逐组件复制 namespace 加载声明。翻译调用仍使用可静态识别的 `t('namespace:literal_key')`。 -```tsx -const { t } = useTranslation(['apikey'] as const, { - useSuspense: false -}); -``` - -这样依赖仍与组件一起维护,类型参数继续受 `I18nNsType` 约束。它不再决定资源何时加载,完整语言包 -已经在 client-only 应用挂载前注册完毕。 - -规则: - -1. 每个已迁移组件必须声明自己直接调用 `t`、`Trans` 所使用的全部 namespace;不能依赖祖先组件 - “碰巧已经加载”。 -2. 动态弹窗、抽屉和懒加载组件仍声明自己的 namespace,但挂载时资源已经存在,不需要额外骨架。 -3. 完整语言包由 CSR 初始化门禁先加载,保证 `NextHead`、`Layout` 和公共组件不会展示 key; - 已迁移组件统一使用 `useSafeTranslation(namespace)`,hook 内置 `common` 并关闭 Suspense。仅使用 - `common` 的组件调用 `useSafeTranslation()`。 - `Layout` 根部声明 `price`,而 `serviceSideProps` 统一预加载 `price`,因此 SSR 页面无需再逐页声明该 namespace。 -4. 原 `serviceSideProps(context, namespaces)` 数组只能作为迁移扫描起点,必须检查页面实际组件树中的 - `useTranslation`、`Trans` 和带 namespace 前缀的 key。 -5. 不允许页面自行 `fetch` 或 `import` 翻译文件,所有资源统一由语言门禁加载。 - -页面根组件的首屏预加载声明为: - -```tsx -useSafeTranslation('apikey'); -``` - -API key 专属文案统一收敛到 `apikey`,`AccountContainer`、`ApiKeyTable`、`TagMultiSelect` 和 -`TagManageModal` 等试点可达组件分别显式声明自己的直接依赖。 - -CSR 路由集合与翻译依赖解耦,只负责渲染模式。当前 app 的集合为: +`/chat` 和 `/chat/share` 保留 SSR,`serviceSideProps` 只注入页面声明的 namespace,不能假设它们 +也经过 CSR 完整语言包门禁。SSR 页面必须声明页面和全局 Layout 可达组件所需的资源: ```ts -const clientOnlyRoutes = new Set([ - '/account/apikey', '/account/inform', '/account/setting', '/account/thirdParty', - '/account/customDomain', '/account/bill', '/account/team', '/account/info', - '/account/usage', '/account/model', '/price' -] as const); +await serviceSideProps(context, ['file', 'app', 'chat', 'workflow', 'account_team']); ``` -页面只有在组件 namespace 改造和验收完成后才加入该集合,但集合本身不再复制 namespace 数据。 +两个 SSR 聊天页面均声明 `account_team`,保证强制成员名弹窗的标题、按钮和校验文案已随 +SSR props 注入。共享组件继续使用无参数 `useSafeTranslation()`,无需增加组件级加载状态。 +`common` 和 `price` 由 `serviceSideProps` 统一补齐;组件不自行 `fetch` 或 `import` 翻译文件。 + +CSR/SSR 路由判定仅决定渲染模式,翻译依赖由完整包门禁或 SSR 页面声明分别保障。 ### 3.4 缓存与并发去重 @@ -244,9 +214,8 @@ i18n 和静态 chunk,且不渲染不完整翻译。 `localStorage`/Cookie 仅继续存语言偏好,不存翻译正文。 -已迁移组件使用 `useSafeTranslation('业务 namespace')`。该共享 hook 内部组合 -`['common', namespace]` 并关闭 Suspense,调用方不重复声明 `common`,同时保留带 namespace 前缀的 -翻译 key 类型检查。 +已迁移组件统一使用 `useSafeTranslation()`,翻译 key 保留 namespace 前缀。 +CSR 完整语言包门禁和 SSR 页面资源声明分别保证组件挂载时所需翻译已就绪。 ### 3.5 页面渲染门禁 @@ -332,41 +301,30 @@ Cookie/localStorage/内存,并发送独立的 `x-fastgpt-share-language` 请 ### 3.7 CSR 边界 删除 `getServerSideProps` 只会使 Pages Router 页面变成自动静态优化,并不等于禁用页面 HTML -预渲染。为了验证“除分享页外只在客户端渲染”,应用入口需要设置明确的 client-only boundary: - -- `/chat/share` 走原有同步 SSR 渲染路径。 -- 已迁移页面走 `{ ssr: false }` 的客户端应用壳。 -- 未迁移页面在过渡期继续走现有 SSR 路径。 - -因此过渡期不能用简单的“pathname 不是 `/chat/share` 就 CSR”开关,否则会一次性改变所有页面。 -已迁移路由由独立的 `clientOnlyRoutes` 判定: - -```ts -const isClientOnlyRoute = (pathname: string) => clientOnlyRoutes.has(pathname); -``` - -当前 `_app` 的结构是:全局 Provider 和 `AppShell` 始终挂载,已迁移路由只把页面内容交给 -`ssr: false` 的 `ClientOnlyPage`;未迁移页面和 `/chat/share` 继续由同一个 `AppShell` 渲染。 - -```tsx -const ClientOnlyPage = dynamic(() => import('@/web/context/ClientOnlyPage'), { - ssr: false -}); - -function AppRouter(props: AppPropsWithLayout) { - const isClientOnlyRoute = clientOnlyRoutes.has(props.router.pathname); - return : undefined} />; -} - -export default appWithTranslation(AppRouter, clientI18nConfig); -``` - -`AppShell` 内部只在 `clientOnly` 路由包裹 `ClientI18nGate`、`ClientI18nBoundary` 和 -`SystemStoreContextProvider.waitForReady`。完整语言包和设备信息 ready 后才挂载页面。因此已迁移路由 -不输出服务端页面业务 HTML,且不会经历页面级 namespace 门禁。 - -当全部非分享页面迁移完成后,再切换为“除 `/chat/share` 外默认 client-only”,并删除过渡兼容分支。 +预渲染。当前 `isClientOnlyRoute` 采用 SSR 例外名单:`/chat`、`/chat/share` 保留服务端 +应用配置及分享信息读取,其余页面默认 client-only。 + +`_app` 保持全局 Provider 和 `AppShell` 挂载。CSR 页面的业务内容交给以 `ssr: false` +加载的 `ClientOnlyPage`,应用壳通过设备、语言包和路由初始化门禁后才挂载业务子树。 +SSR 聊天页面直接渲染,不等待 CSR 门禁,因此仍输出服务端页面和 Head 信息。 + +`ClientRouteReadyGate` 位于 `AppShell` 的稳定位置,同时覆盖 `AppContent` 内的 +`useInitApp`、Layout 和页面。门禁由 effect 在 `router.isReady` 首次为 true 时开放, +保证 SSR/客户端首次输出一致,且首次业务请求和表单初值来自已恢复的 query。 + +首次开放后不再关闭,不监听 `asPath` 来重置状态,也不在 route change 时重新挂载门禁。 +同一 CSR 区域内的目录、筛选和页面导航不因路由门禁新增白屏;业务请求仅随 ID 或筛选条件刷新。 +已有语言包门禁和 CSR/SSR 分支切换的行为独立保留。 + +因此 App/Skill/知识库列表可移除 `disabled: !router.isReady`、`ready: router.isReady` +以及 refreshDeps 中的 `router.isReady`,创建应用页也无需额外的就绪 wrapper。 +必填参数、权限、业务配置等约束仍在所属业务边界校验;跨 SSR/CSR 共用的初始化 effect +可以保留路由检查。营销来源初始化即使未传 `sourceDomain` 也必须调用 helper,以保留 +`document.referrer` 回落和首次来源锁定语义。 + +验证要求:未就绪时 AppContent 和 Layout 的 effect 不执行;就绪后首次请求携带正确 query; +hydration 不报 mismatch;后续 query 更新保留页面编辑状态;SSR 聊天页仍输出业务内容; +直接访问 SSR 页面时成员名弹窗资源已注入,无需客户端补加载;营销归因无参数回落正常。 ### 3.8 `deviceSize` 处理 diff --git a/packages/web/test/i18n/useSafeTranslation.test.ts b/packages/web/test/i18n/useSafeTranslation.test.ts index 745fc7836912..ba6069e74276 100644 --- a/packages/web/test/i18n/useSafeTranslation.test.ts +++ b/packages/web/test/i18n/useSafeTranslation.test.ts @@ -1,66 +1,122 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it } from 'vitest'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { useTranslation } from 'next-i18next'; +import { createRequire } from 'node:module'; +import { createInstance } from 'i18next'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; +import enAccountTeam from '../../i18n/en/account_team.json'; +import zhCNAccountTeam from '../../i18n/zh-CN/account_team.json'; +import zhHantAccountTeam from '../../i18n/zh-Hant/account_team.json'; +import koKRAccountTeam from '../../i18n/ko-KR/account_team.json'; -vi.mock('next-i18next', () => ({ - useTranslation: vi.fn() -})); +// next-i18next 使用 CJS 版 react-i18next,测试 Provider 也使用同一模块,避免 ESM/CJS 上下文分离。 +const require = createRequire(import.meta.url); +const { I18nextProvider } = createRequire(require.resolve('next-i18next'))( + 'react-i18next' +) as typeof import('react-i18next'); describe('useSafeTranslation', () => { - beforeEach(() => { - vi.mocked(useTranslation).mockReturnValue({ - t: ((key: string) => key) as never, - i18n: { language: 'en' } as never, - ready: true + let i18n: ReturnType; + + beforeEach(async () => { + i18n = createInstance(); + await i18n.init({ + lng: 'en', + fallbackLng: false, + defaultNS: 'common', + ns: ['common', 'account'], + resources: { + en: { + common: {}, + account: { personal_information: 'Personal information' } + } + }, + react: { useSuspense: false } }); }); - it('调用 useTranslation 获取上下文', () => { + it('读取现有 i18next 上下文', () => { function TestComponent() { - useSafeTranslation(); + const { i18n: contextI18n } = useSafeTranslation(); + expect(contextI18n).toBe(i18n); return null; } - renderToStaticMarkup(createElement(TestComponent)); - - expect(useTranslation).toHaveBeenCalled(); + renderToStaticMarkup(createElement(I18nextProvider, { i18n }, createElement(TestComponent))); }); it('完整语言包就绪后正常翻译', () => { - vi.mocked(useTranslation).mockReturnValue({ - t: ((key: string) => `translated:${key}`) as never, - i18n: { language: 'en' } as never, - ready: true - }); - function TestComponent() { const { t } = useSafeTranslation(); return createElement('span', null, t('account:personal_information')); } - expect(renderToStaticMarkup(createElement(TestComponent))).toContain( - 'translated:account:personal_information' - ); + expect( + renderToStaticMarkup(createElement(I18nextProvider, { i18n }, createElement(TestComponent))) + ).toContain('Personal information'); }); it('支持动态翻译 key,并对未知 namespace 或普通字符串原样返回', () => { - vi.mocked(useTranslation).mockReturnValue({ - t: ((key: string) => `translated:${key}`) as never, - i18n: { language: 'en' } as never, - ready: true - }); - function TestComponent() { const { t } = useSafeTranslation(); const dynamicKey: string = 'account:personal_information'; - return createElement('span', null, `${t(dynamicKey)}|${t('09:30')}`); } - expect(renderToStaticMarkup(createElement(TestComponent))).toContain( - 'translated:account:personal_information|09:30' - ); + expect( + renderToStaticMarkup(createElement(I18nextProvider, { i18n }, createElement(TestComponent))) + ).toContain('Personal information|09:30'); }); }); + +describe('useSafeTranslation with SSR member-name resources', () => { + it.each([ + { language: 'en', resource: enAccountTeam }, + { language: 'zh-CN', resource: zhCNAccountTeam }, + { language: 'zh-Hant', resource: zhHantAccountTeam }, + { language: 'ko-KR', resource: koKRAccountTeam } + ])( + 'renders member-name text from preloaded $language resources', + async ({ language, resource }) => { + const i18n = createInstance(); + await i18n.init({ + lng: language, + fallbackLng: false, + defaultNS: 'common', + ns: ['common', 'account_team'], + resources: { [language]: { common: {}, account_team: resource } }, + react: { useSuspense: false } + }); + const MemberNameText = () => { + const { t } = useSafeTranslation(); + return createElement( + 'span', + null, + [ + t('account_team:set_member_name_title'), + t('account_team:invite_member_name_placeholder'), + t('account_team:confirm_member_name'), + t('account_team:member_name_required'), + t('account_team:member_name_limit') + ].join('|') + ); + }; + + expect( + renderToStaticMarkup( + createElement(I18nextProvider, { i18n }, createElement(MemberNameText)) + ) + ).toBe( + `${[ + resource.set_member_name_title, + resource.invite_member_name_placeholder, + resource.confirm_member_name, + resource.member_name_required, + resource.member_name_limit + ].join('|')}` + ); + } + ); +}); diff --git a/projects/app/src/pageComponents/dashboard/agent/context.tsx b/projects/app/src/pageComponents/dashboard/agent/context.tsx index 9116057a8eac..86f8d4a1daff 100644 --- a/projects/app/src/pageComponents/dashboard/agent/context.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/context.tsx @@ -228,9 +228,7 @@ const AppListContextProvider = ({ return fetchApps(persistedTmbIds); }, { - disabled: !router.isReady, refreshDeps: [ - router.isReady, searchKey, parentId, appType, @@ -252,8 +250,7 @@ const AppListContextProvider = ({ () => getAppFolderPath({ sourceId: parentId, type: 'current' }), { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); @@ -264,8 +261,7 @@ const AppListContextProvider = ({ }, { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); diff --git a/projects/app/src/pageComponents/dashboard/skill/context.tsx b/projects/app/src/pageComponents/dashboard/skill/context.tsx index 6c34c13a98bb..bb281c43daba 100644 --- a/projects/app/src/pageComponents/dashboard/skill/context.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/context.tsx @@ -184,9 +184,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { total: res.total })), { - disabled: !router.isReady, refreshDeps: [ - router.isReady, searchKey, parentId, applyToolbarFilters ? listFilters.sort : '', @@ -210,8 +208,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { }, { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); @@ -224,8 +221,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => { }, { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); diff --git a/projects/app/src/pageComponents/dataset/list/context.tsx b/projects/app/src/pageComponents/dataset/list/context.tsx index 09e51785a813..4cbe90cbbee8 100644 --- a/projects/app/src/pageComponents/dataset/list/context.tsx +++ b/projects/app/src/pageComponents/dataset/list/context.tsx @@ -180,9 +180,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { ...(tmbIds !== undefined ? { tmbIds } : {}) }), { - disabled: !router.isReady, refreshDeps: [ - router.isReady, parentId, searchKey, listType?.join(',') ?? 'all', @@ -203,8 +201,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { () => (parentId ? getDatasetById(parentId) : Promise.resolve(undefined)), { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); @@ -215,8 +212,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) { }, { manual: false, - ready: router.isReady, - refreshDeps: [parentId, router.isReady] + refreshDeps: [parentId] } ); diff --git a/projects/app/src/pages/chat/index.tsx b/projects/app/src/pages/chat/index.tsx index f1ce1214a9f8..3ad5cd339603 100644 --- a/projects/app/src/pages/chat/index.tsx +++ b/projects/app/src/pages/chat/index.tsx @@ -330,7 +330,16 @@ export async function getServerSideProps(context: any) { showFullText: chatQuoteReaderConfig?.showFullText ?? true, canDownloadSource: chatQuoteReaderConfig?.canDownloadSource ?? true, showWholeResponse: chatQuoteReaderConfig?.showWholeResponse ?? true, - ...(await serviceSideProps(context, ['file', 'app', 'chat', 'workflow', 'login', 'user'])) + // Layout 的强制成员名弹窗也会在聊天页打开,SSR 必须一并注入其翻译资源。 + ...(await serviceSideProps(context, [ + 'file', + 'app', + 'chat', + 'workflow', + 'login', + 'user', + 'account_team' + ])) } }; } diff --git a/projects/app/src/pages/chat/share.tsx b/projects/app/src/pages/chat/share.tsx index bcded885d48b..691716b0c5bc 100644 --- a/projects/app/src/pages/chat/share.tsx +++ b/projects/app/src/pages/chat/share.tsx @@ -635,7 +635,8 @@ export async function getServerSideProps(context: any) { shareId: shareId ?? '', authToken: authToken ?? '', customUid, - ...(await serviceSideProps(context, ['file', 'app', 'chat', 'workflow'], { + // 分享页同样挂载全局 Layout,预加载强制成员名弹窗所需的 namespace。 + ...(await serviceSideProps(context, ['file', 'app', 'chat', 'workflow', 'account_team'], { langCookieKey: SHARE_LANG_KEY, fallbackLangCookieKey: LANG_KEY })) diff --git a/projects/app/src/pages/dashboard/create/index.tsx b/projects/app/src/pages/dashboard/create/index.tsx index 1de35ec7aee5..368015d09764 100644 --- a/projects/app/src/pages/dashboard/create/index.tsx +++ b/projects/app/src/pages/dashboard/create/index.tsx @@ -21,7 +21,6 @@ import { postCreateApp } from '@/web/core/app/api'; import { useUploadAvatar } from '@fastgpt/web/common/file/hooks/useUploadAvatar'; import { getUploadAvatarPresignedUrl } from '@/web/common/file/api'; import { useRouter } from 'next/router'; -import Loading from '@fastgpt/web/components/common/MyLoading'; import { getEmptyAppsTemplate } from '@/web/core/app/templates'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import Avatar from '@fastgpt/web/components/common/Avatar'; @@ -70,7 +69,7 @@ export type CreateAppType = | AppTypeEnum.mcpToolSet | AppTypeEnum.httpToolSet; -const CreateAppsContent = () => { +const CreateAppsPage = () => { const { t } = useSafeTranslation(); const router = useRouter(); const { isPc } = useSystem(); @@ -621,14 +620,4 @@ const CreateAppsContent = () => { ); }; -const CreateAppsPage = () => { - const router = useRouter(); - - if (!router.isReady) { - return ; - } - - return ; -}; - export default CreateAppsPage; diff --git a/projects/app/src/web/context/AppShell.tsx b/projects/app/src/web/context/AppShell.tsx index 96d2fdad7a08..b4f8d1d634b1 100644 --- a/projects/app/src/web/context/AppShell.tsx +++ b/projects/app/src/web/context/AppShell.tsx @@ -6,7 +6,7 @@ import ChakraUIContext from '@/web/context/ChakraUI'; import { useInitApp } from '@/web/context/useInitApp'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import NextHead from '@/components/common/NextHead'; -import { type ReactElement, type ReactNode, useEffect } from 'react'; +import React, { type ReactElement, type ReactNode, useEffect } from 'react'; import { type NextPage } from 'next'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; import SystemStoreContextProvider from '@fastgpt/web/context/useSystem'; @@ -16,6 +16,7 @@ import { appClientEnv } from '@/web/common/system/env'; import ClientI18nBoundary from '@fastgpt/web/i18n/ClientI18nBoundary'; import ClientI18nGate from '@fastgpt/web/i18n/ClientI18nGate'; import { LANG_KEY } from '@fastgpt/web/i18n/utils'; +import ClientRouteReadyGate from './ClientRouteReadyGate'; type NextPageWithLayout = NextPage & { setLayout?: (page: ReactElement) => JSX.Element; @@ -95,7 +96,9 @@ const AppShell = (props: AppPropsWithLayout) => { - {props.clientOnly ? {content} : content} + + {props.clientOnly ? {content} : content} + diff --git a/projects/app/src/web/context/ClientRouteReadyGate.tsx b/projects/app/src/web/context/ClientRouteReadyGate.tsx new file mode 100644 index 000000000000..d9e1c386672c --- /dev/null +++ b/projects/app/src/web/context/ClientRouteReadyGate.tsx @@ -0,0 +1,29 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState, type ReactNode } from 'react'; + +/** + * CSR 业务子树只在路由首次就绪后挂载,统一保障 query 驱动的请求和表单初始化。 + * SSR 页面直接放行;首次就绪后不再关闭门禁,避免后续导航卸载页面、丢失编辑状态。 + */ +const ClientRouteReadyGate = ({ + enabled, + children +}: { + enabled: boolean; + children?: ReactNode; +}) => { + const { isReady } = useRouter(); + const [ready, setReady] = useState(false); + + useEffect(() => { + // hydration 首次必须与服务端一样关闭门禁,提交后才能开放业务子树。 + // eslint-disable-next-line react-hooks/set-state-in-effect + if (isReady) setReady(true); + }, [isReady]); + + // 服务端和 hydration 首次渲染都关闭 CSR 门禁,由 effect 开放,保持两端输出一致。 + if (enabled && !ready) return null; + return children; +}; + +export default ClientRouteReadyGate; diff --git a/projects/app/src/web/context/useInitApp.ts b/projects/app/src/web/context/useInitApp.ts index 9fe238a7b5db..f77f3b4d7255 100644 --- a/projects/app/src/web/context/useInitApp.ts +++ b/projects/app/src/web/context/useInitApp.ts @@ -157,7 +157,8 @@ export const useInitApp = () => { if (bd_vid) setBdVId(bd_vid); if (msclkid) setMsclkid(msclkid); if (utm_workflow) setUtmWorkflow(utm_workflow); - if (sourceDomain) initFastGPTSemSourceDomain(sourceDomain); + // 未显式携带来源时仍读取 document.referrer,并锁定本次首次归因。 + initFastGPTSemSourceDomain(sourceDomain); const utmParams: ShortUrlParams = { ...(utm_source && { shortUrlSource: utm_source }), diff --git a/projects/app/test/web/context/AppShell.test.ts b/projects/app/test/web/context/AppShell.test.ts new file mode 100644 index 000000000000..001d62d4ab90 --- /dev/null +++ b/projects/app/test/web/context/AppShell.test.ts @@ -0,0 +1,144 @@ +import { JSDOM } from 'jsdom'; +import React, { act, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AppProps } from 'next/app'; +import AppShell from '@/web/context/AppShell'; + +const mocks = vi.hoisted(() => ({ + router: { + isReady: false, + pathname: '/dashboard/agent', + query: {} as Record + }, + initializeApp: vi.fn(), + initializeLayout: vi.fn(), + languageReady: true +})); + +vi.mock('next/router', () => ({ useRouter: () => mocks.router })); +vi.mock('next/script', () => ({ default: () => null })); +vi.mock('@/web/context/useInitApp', () => ({ + useInitApp: () => { + useEffect(() => { + mocks.initializeApp(mocks.router.query); + }, []); + return { feConfigs: {}, scripts: [], title: 'FastGPT' }; + } +})); +vi.mock('@/components/Layout', () => ({ + default: ({ children }: { children: React.ReactNode }) => { + useEffect(() => { + mocks.initializeLayout(mocks.router.query); + }, []); + return children; + } +})); +vi.mock('@/web/context/QueryClient', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})); +vi.mock('@/web/context/ChakraUI', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})); +vi.mock('@fastgpt/web/context/useSystem', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})); +vi.mock('@fastgpt/web/i18n/ClientI18nGate', () => ({ + default: ({ children }: { children: React.ReactNode }) => (mocks.languageReady ? children : null) +})); +vi.mock('@fastgpt/web/i18n/ClientI18nBoundary', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})); +vi.mock('@fastgpt/web/hooks/useSafeTranslation', () => ({ + useSafeTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) +})); +vi.mock('@/components/common/NextHead', () => ({ default: () => null })); +vi.mock('@fastgpt/web/common/system/utils', () => ({ getWebReqUrl: (url: string) => url })); +vi.mock('@/web/common/utils/errorLogger', () => ({ errorLogger: { init: vi.fn() } })); +vi.mock('@/web/common/system/env', () => ({ appClientEnv: {} })); + +describe('AppShell startup boundaries', () => { + let container: HTMLDivElement; + let root: Root; + const Page = () => React.createElement('div', null, 'business-page'); + const render = async (clientOnly: boolean) => { + await act(async () => + root.render( + React.createElement(AppShell, { + Component: Page, + pageProps: {}, + router: mocks.router as unknown as AppProps['router'], + clientOnly + }) + ) + ); + }; + + beforeEach(() => { + const dom = new JSDOM(''); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.clearAllMocks(); + mocks.router.isReady = false; + mocks.router.query = {}; + mocks.router.pathname = '/dashboard/agent'; + mocks.languageReady = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + window.close(); + vi.unstubAllGlobals(); + }); + + it('also gates application and Layout initialization until CSR query is ready', async () => { + await render(true); + expect(mocks.initializeApp).not.toHaveBeenCalled(); + expect(mocks.initializeLayout).not.toHaveBeenCalled(); + expect(container.textContent).toBe(''); + + mocks.router.isReady = true; + mocks.router.query = { parentId: 'folder-1', couponCode: 'SAVE20' }; + await render(true); + + expect(mocks.initializeApp).toHaveBeenCalledExactlyOnceWith(mocks.router.query); + expect(mocks.initializeLayout).toHaveBeenCalledExactlyOnceWith(mocks.router.query); + expect(container.textContent).toBe('business-page'); + }); + + it('waits for both language resources and route readiness', async () => { + mocks.languageReady = false; + mocks.router.isReady = true; + await render(true); + expect(mocks.initializeApp).not.toHaveBeenCalled(); + + mocks.languageReady = true; + await render(true); + expect(mocks.initializeApp).toHaveBeenCalledOnce(); + }); + + it.each(['/chat', '/chat/share'])( + 'keeps %s server content outside CSR startup gates', + (pathname) => { + mocks.router.pathname = pathname; + mocks.languageReady = false; + + expect( + renderToString( + React.createElement(AppShell, { + Component: Page, + pageProps: {}, + router: mocks.router as unknown as AppProps['router'], + clientOnly: false + }) + ) + ).toContain('business-page'); + } + ); +}); diff --git a/projects/app/test/web/context/ClientRouteReadyGate.test.ts b/projects/app/test/web/context/ClientRouteReadyGate.test.ts new file mode 100644 index 000000000000..65f91549e63b --- /dev/null +++ b/projects/app/test/web/context/ClientRouteReadyGate.test.ts @@ -0,0 +1,137 @@ +import { JSDOM } from 'jsdom'; +import React, { act, useEffect, useState } from 'react'; +import { createRoot, hydrateRoot, type Root } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ClientRouteReadyGate from '@/web/context/ClientRouteReadyGate'; + +const mocks = vi.hoisted(() => ({ + router: { + isReady: false, + query: {} as Record + } +})); + +vi.mock('next/router', () => ({ useRouter: () => mocks.router })); + +describe('ClientRouteReadyGate', () => { + let container: HTMLDivElement; + let root: Root | undefined; + const requests = vi.fn(); + const mounts = vi.fn(); + const unmounts = vi.fn(); + + const BusinessPage = () => { + const [count, setCount] = useState(0); + const { parentId } = mocks.router.query; + + useEffect(() => { + mounts(); + return () => unmounts(); + }, []); + useEffect(() => { + requests(parentId); + }, [parentId]); + + return React.createElement('button', { onClick: () => setCount(count + 1) }, count); + }; + + const page = (enabled = true) => + React.createElement(ClientRouteReadyGate, { enabled }, React.createElement(BusinessPage)); + + beforeEach(() => { + const dom = new JSDOM(''); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.clearAllMocks(); + mocks.router.isReady = false; + mocks.router.query = {}; + container = document.createElement('div'); + document.body.appendChild(container); + root = undefined; + }); + + afterEach(async () => { + await act(async () => root?.unmount()); + container.remove(); + window.close(); + vi.unstubAllGlobals(); + }); + + it('waits for query hydration before mounting and requesting business data', async () => { + root = createRoot(container); + await act(async () => root!.render(page())); + expect(container.innerHTML).toBe(''); + expect(mounts).not.toHaveBeenCalled(); + expect(requests).not.toHaveBeenCalled(); + + mocks.router.query = { parentId: 'folder-1' }; + mocks.router.isReady = true; + await act(async () => root!.render(page())); + + expect(mounts).toHaveBeenCalledOnce(); + expect(requests).toHaveBeenCalledExactlyOnceWith('folder-1'); + }); + + it('allows SSR pages to render and mount without waiting for isReady', async () => { + expect(renderToString(page(false))).toContain(''); + root = createRoot(container); + await act(async () => root!.render(page(false))); + + expect(mounts).toHaveBeenCalledOnce(); + expect(container.querySelector('button')).not.toBeNull(); + }); + + it('keeps the initial CSR markup consistent when the client router is already ready', async () => { + const serverMarkup = renderToString(page()); + expect(serverMarkup).toBe(''); + container.innerHTML = serverMarkup; + mocks.router.isReady = true; + mocks.router.query = { parentId: 'folder-hydrated' }; + const onRecoverableError = vi.fn(); + + await act(async () => { + root = hydrateRoot(container, page(), { onRecoverableError }); + }); + + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(requests).toHaveBeenCalledExactlyOnceWith('folder-hydrated'); + }); + + it('preserves page state and refreshes only business dependencies during navigation', async () => { + mocks.router.isReady = true; + mocks.router.query = { parentId: 'folder-1' }; + root = createRoot(container); + await act(async () => root!.render(page())); + await act(async () => container.querySelector('button')!.click()); + + // 首次初始化完成后,路由状态变化不能关闭门禁或丢失用户编辑状态。 + mocks.router.isReady = false; + await act(async () => root!.render(page())); + expect(container.textContent).toBe('1'); + expect(unmounts).not.toHaveBeenCalled(); + + mocks.router.isReady = true; + mocks.router.query = { parentId: 'folder-2' }; + await act(async () => root!.render(page())); + + expect(container.textContent).toBe('1'); + expect(mounts).toHaveBeenCalledOnce(); + expect(requests.mock.calls).toEqual([['folder-1'], ['folder-2']]); + }); + + it('does not close an initialized gate when switching between SSR and CSR routes', async () => { + mocks.router.isReady = true; + root = createRoot(container); + await act(async () => root!.render(page(false))); + await act(async () => container.querySelector('button')!.click()); + + await act(async () => root!.render(page(true))); + expect(container.textContent).toBe('1'); + await act(async () => root!.render(page(false))); + expect(container.textContent).toBe('1'); + expect(mounts).toHaveBeenCalledOnce(); + expect(unmounts).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/app/test/web/context/useInitApp.test.ts b/projects/app/test/web/context/useInitApp.test.ts index d91c91af42e5..6caf810529ee 100644 --- a/projects/app/test/web/context/useInitApp.test.ts +++ b/projects/app/test/web/context/useInitApp.test.ts @@ -87,6 +87,7 @@ describe('useInitApp marketing params readiness gate', () => { expect(mocks.setBdVId).not.toHaveBeenCalled(); expect(mocks.setCouponCode).not.toHaveBeenCalled(); + expect(mocks.initFastGPTSemSourceDomain).not.toHaveBeenCalled(); }); it('consumes marketing params after router.isReady becomes true', () => { @@ -109,4 +110,27 @@ describe('useInitApp marketing params readiness gate', () => { }) ); }); + + it('initializes referrer attribution even without an explicit sourceDomain', () => { + mocks.router.isReady = true; + + useInitApp(); + mocks.effects.forEach((effect) => effect()); + + expect(mocks.initFastGPTSemSourceDomain).toHaveBeenCalledWith(undefined); + }); + + it('passes the explicit sourceDomain and initializes it only once', () => { + mocks.router.isReady = true; + mocks.router.query = { sourceDomain: 'https://campaign.example.com' }; + + useInitApp(); + mocks.effects.forEach((effect) => { + effect(); + effect(); + }); + + expect(mocks.initFastGPTSemSourceDomain).toHaveBeenCalledOnce(); + expect(mocks.initFastGPTSemSourceDomain).toHaveBeenCalledWith('https://campaign.example.com'); + }); }); diff --git a/projects/app/test/web/support/marketing/utils.test.ts b/projects/app/test/web/support/marketing/utils.test.ts index 383f5a781077..b97a6ef28c30 100644 --- a/projects/app/test/web/support/marketing/utils.test.ts +++ b/projects/app/test/web/support/marketing/utils.test.ts @@ -24,6 +24,14 @@ describe('marketing utils', () => { vi.stubGlobal('document', { referrer: '' }); }); + it('falls back to document.referrer when no sourceDomain is provided', () => { + vi.stubGlobal('document', { referrer: 'https://campaign.example.com' }); + + initFastGPTSemSourceDomain(); + + expect(getFastGPTSem()?.sourceDomain).toBe('https://campaign.example.com'); + }); + it('should lock sourceDomain after the first init even when source is empty', () => { initFastGPTSemSourceDomain(); vi.stubGlobal('document', { referrer: 'https://redirect.example.com' });