Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 37 additions & 79 deletions .agents/design/common/client-i18n-csr-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 缓存与并发去重

Expand Down Expand Up @@ -244,9 +214,8 @@ i18n 和静态 chunk,且不渲染不完整翻译。

`localStorage`/Cookie 仅继续存语言偏好,不存翻译正文。

已迁移组件使用 `useSafeTranslation('业务 namespace')`。该共享 hook 内部组合
`['common', namespace]` 并关闭 Suspense,调用方不重复声明 `common`,同时保留带 namespace 前缀的
翻译 key 类型检查。
已迁移组件统一使用 `useSafeTranslation()`,翻译 key 保留 namespace 前缀。
CSR 完整语言包门禁和 SSR 页面资源声明分别保证组件挂载时所需翻译已就绪。

### 3.5 页面渲染门禁

Expand Down Expand Up @@ -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 <AppShell {...props} clientOnly={isClientOnlyRoute}
renderPage={isClientOnlyRoute ? () => <ClientOnlyPage {...props} /> : 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` 处理

Expand Down
124 changes: 90 additions & 34 deletions packages/web/test/i18n/useSafeTranslation.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createInstance>;

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(
`<span>${[
resource.set_member_name_title,
resource.invite_member_name_placeholder,
resource.confirm_member_name,
resource.member_name_required,
resource.member_name_limit
].join('|')}</span>`
);
}
);
});
8 changes: 2 additions & 6 deletions projects/app/src/pageComponents/dashboard/agent/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,7 @@ const AppListContextProvider = ({
return fetchApps(persistedTmbIds);
},
{
disabled: !router.isReady,
refreshDeps: [
router.isReady,
searchKey,
parentId,
appType,
Expand All @@ -252,8 +250,7 @@ const AppListContextProvider = ({
() => getAppFolderPath({ sourceId: parentId, type: 'current' }),
{
manual: false,
ready: router.isReady,
refreshDeps: [parentId, router.isReady]
refreshDeps: [parentId]
}
);

Expand All @@ -264,8 +261,7 @@ const AppListContextProvider = ({
},
{
manual: false,
ready: router.isReady,
refreshDeps: [parentId, router.isReady]
refreshDeps: [parentId]
}
);

Expand Down
8 changes: 2 additions & 6 deletions projects/app/src/pageComponents/dashboard/skill/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
total: res.total
})),
{
disabled: !router.isReady,
refreshDeps: [
router.isReady,
searchKey,
parentId,
applyToolbarFilters ? listFilters.sort : '',
Expand All @@ -210,8 +208,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
},
{
manual: false,
ready: router.isReady,
refreshDeps: [parentId, router.isReady]
refreshDeps: [parentId]
}
);

Expand All @@ -224,8 +221,7 @@ const SkillListContextProvider = ({ children }: { children: ReactNode }) => {
},
{
manual: false,
ready: router.isReady,
refreshDeps: [parentId, router.isReady]
refreshDeps: [parentId]
}
);

Expand Down
8 changes: 2 additions & 6 deletions projects/app/src/pageComponents/dataset/list/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) {
...(tmbIds !== undefined ? { tmbIds } : {})
}),
{
disabled: !router.isReady,
refreshDeps: [
router.isReady,
parentId,
searchKey,
listType?.join(',') ?? 'all',
Expand 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]
}
);

Expand All @@ -215,8 +212,7 @@ function DatasetContextProvider({ children }: { children: React.ReactNode }) {
},
{
manual: false,
ready: router.isReady,
refreshDeps: [parentId, router.isReady]
refreshDeps: [parentId]
}
);

Expand Down
Loading
Loading