From 2399d00d8667a24554233bb59b1069301eeb4874 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:38:23 +0800 Subject: [PATCH] refactor(i18n): about locales (#30336) Co-authored-by: yyh --- .../time-range-picker/date-picker.tsx | 4 +- .../overview/time-range-picker/index.tsx | 4 +- .../webapp-reset-password/check-code/page.tsx | 6 +-- .../webapp-reset-password/page.tsx | 6 +-- .../webapp-signin/check-code/page.tsx | 6 +-- .../components/mail-and-code-auth.tsx | 5 +-- .../components/mail-and-password-auth.tsx | 5 +-- .../csv-downloader.spec.tsx | 19 ++++---- .../csv-downloader.tsx | 6 +-- .../app/annotation/header-opts/index.spec.tsx | 41 +++++++---------- .../app/annotation/header-opts/index.tsx | 7 ++- .../setting-built-in-tool.spec.tsx | 27 ++++++------ .../agent-tools/setting-built-in-tool.tsx | 5 +-- .../tools/external-data-tool-modal.tsx | 5 +-- .../base/agent-log-modal/tool-call.tsx | 6 +-- .../moderation/form-generation.tsx | 5 +-- .../new-feature-panel/moderation/index.tsx | 6 +-- .../moderation/moderation-setting-modal.tsx | 5 +-- .../list/built-in-pipeline-list.tsx | 4 +- .../datasets/create/file-uploader/index.tsx | 4 +- .../datasets/create/step-two/index.tsx | 6 +-- .../data-source/local-file/index.tsx | 4 +- .../detail/batch-modal/csv-downloader.tsx | 5 +-- web/app/components/develop/doc.tsx | 5 +-- .../account-setting/language-page/index.tsx | 6 ++- .../account-setting/members-page/index.tsx | 5 +-- .../members-page/invite-modal/index.tsx | 4 +- .../model-provider-page/hooks.spec.ts | 18 +++----- .../model-provider-page/hooks.ts | 5 +-- web/app/components/i18n.tsx | 13 +++--- .../components/plugins/card/index.spec.tsx | 1 - .../plugins/marketplace/description/index.tsx | 9 ++-- .../plugins/marketplace/index.spec.tsx | 6 +-- .../plugins/marketplace/list/card-wrapper.tsx | 4 +- .../plugins/marketplace/list/index.spec.tsx | 6 +-- .../plugin-detail-panel/detail-header.tsx | 4 +- .../plugin-mutation-model/index.spec.tsx | 1 - .../plugins/plugin-page/debug-info.tsx | 5 +-- .../components/plugins/plugin-page/index.tsx | 5 +-- web/app/components/plugins/provider-card.tsx | 4 +- .../plugins/update-plugin/index.spec.tsx | 1 - .../test-api.spec.tsx | 23 +++++----- .../edit-custom-collection-modal/test-api.tsx | 5 +-- web/app/components/tools/mcp/create-card.tsx | 5 +-- .../components/tools/mcp/detail/tool-item.tsx | 5 +-- .../tools/provider/custom-create-card.tsx | 5 +-- web/app/components/tools/provider/detail.tsx | 5 +-- .../components/tools/provider/tool-item.tsx | 5 +-- web/app/components/with-i18n.tsx | 20 --------- .../market-place-plugin/item.tsx | 5 +-- .../uninstalled-item.tsx | 5 +-- .../nodes/document-extractor/panel.tsx | 5 +-- web/app/reset-password/check-code/page.tsx | 5 +-- web/app/reset-password/page.tsx | 5 +-- web/app/signin/_header.tsx | 7 ++- web/app/signin/check-code/page.tsx | 6 +-- .../signin/components/mail-and-code-auth.tsx | 5 +-- .../components/mail-and-password-auth.tsx | 5 +-- web/app/signin/invite-settings/page.tsx | 7 ++- web/app/signup/check-code/page.tsx | 5 +-- web/app/signup/components/input-mail.tsx | 5 +-- web/context/i18n.ts | 31 ++++--------- web/hooks/use-format-time-from-now.spec.ts | 44 +++++++++---------- web/hooks/use-format-time-from-now.ts | 4 +- web/i18n-config/DEV.md | 4 +- web/i18n-config/i18next-config.ts | 1 - web/i18n-config/server.ts | 34 ++++++++++---- web/package.json | 1 + web/pnpm-lock.yaml | 28 ++++++++++++ web/utils/server-only-context.ts | 15 +++++++ 70 files changed, 273 insertions(+), 320 deletions(-) delete mode 100644 web/app/components/with-i18n.tsx create mode 100644 web/utils/server-only-context.ts diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx index 004f83afc5..5f72e7df63 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx @@ -8,7 +8,7 @@ import { noop } from 'es-toolkit/compat' import * as React from 'react' import { useCallback } from 'react' import Picker from '@/app/components/base/date-and-time-picker/date-picker' -import { useI18N } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { cn } from '@/utils/classnames' import { formatToLocalTime } from '@/utils/format' @@ -26,7 +26,7 @@ const DatePicker: FC = ({ onStartChange, onEndChange, }) => { - const { locale } = useI18N() + const locale = useLocale() const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => { return ( diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx index 10209de97b..53794ad8db 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/index.tsx @@ -7,7 +7,7 @@ import dayjs from 'dayjs' import * as React from 'react' import { useCallback, useState } from 'react' import { HourglassShape } from '@/app/components/base/icons/src/vender/other' -import { useI18N } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { formatToLocalTime } from '@/utils/format' import DatePicker from './date-picker' import RangeSelector from './range-selector' @@ -27,7 +27,7 @@ const TimeRangePicker: FC = ({ onSelect, queryDateFormat, }) => { - const { locale } = useI18N() + const locale = useLocale() const [isCustomRange, setIsCustomRange] = useState(false) const [start, setStart] = useState(today) diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx index ac15f1df6d..fbf45259e5 100644 --- a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -3,12 +3,12 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import Countdown from '@/app/components/signin/countdown' -import I18NContext from '@/context/i18n' + +import { useLocale } from '@/context/i18n' import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common' export default function CheckCode() { @@ -19,7 +19,7 @@ export default function CheckCode() { const token = decodeURIComponent(searchParams.get('token') as string) const [code, setVerifyCode] = useState('') const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const verify = async () => { try { diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index 6acd8d08f4..ec75e15a00 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -5,13 +5,13 @@ import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' -import I18NContext from '@/context/i18n' + +import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { sendResetPasswordCode } from '@/service/common' @@ -22,7 +22,7 @@ export default function CheckCode() { const router = useRouter() const [email, setEmail] = useState('') const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const handleGetEMailVerificationCode = async () => { try { diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index 0ef63dcbd2..bda5484197 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -4,12 +4,12 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import Countdown from '@/app/components/signin/countdown' -import I18NContext from '@/context/i18n' + +import { useLocale } from '@/context/i18n' import { useWebAppStore } from '@/context/web-app-context' import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common' import { fetchAccessToken } from '@/service/share' @@ -23,7 +23,7 @@ export default function CheckCode() { const token = decodeURIComponent(searchParams.get('token') as string) const [code, setVerifyCode] = useState('') const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const codeInputRef = useRef(null) const redirectUrl = searchParams.get('redirect_url') const embeddedUserId = useWebAppStore(s => s.embeddedUserId) diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx index f3e018a1fa..f79911099f 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -2,13 +2,12 @@ import { noop } from 'es-toolkit/compat' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' -import I18NContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { sendWebAppEMailLoginCode } from '@/service/common' export default function MailAndCodeAuth() { @@ -18,7 +17,7 @@ export default function MailAndCodeAuth() { const emailFromLink = decodeURIComponent(searchParams.get('email') || '') const [email, setEmail] = useState(emailFromLink) const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const handleGetEMailVerificationCode = async () => { try { diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index 7e76a87250..ae70675e7a 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -4,12 +4,11 @@ import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import { emailRegex } from '@/config' -import I18NContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useWebAppStore } from '@/context/web-app-context' import { webAppLogin } from '@/service/common' import { fetchAccessToken } from '@/service/share' @@ -21,7 +20,7 @@ type MailAndPasswordAuthProps = { export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) { const { t } = useTranslation() - const { locale } = useContext(I18NContext) + const locale = useLocale() const router = useRouter() const searchParams = useSearchParams() const [showPassword, setShowPassword] = useState(false) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx index a3ab73b339..2ab0934fe2 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx @@ -1,7 +1,8 @@ +import type { Mock } from 'vitest' import type { Locale } from '@/i18n-config' import { render, screen } from '@testing-library/react' import * as React from 'react' -import I18nContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import CSVDownload from './csv-downloader' @@ -17,17 +18,13 @@ vi.mock('react-papaparse', () => ({ })), })) +vi.mock('@/context/i18n', () => ({ + useLocale: vi.fn(() => 'en-US'), +})) + const renderWithLocale = (locale: Locale) => { - return render( - - - , - ) + ;(useLocale as Mock).mockReturnValue(locale) + return render() } describe('CSVDownload', () => { diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx index a0c204062b..8db70104bc 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx @@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next' import { useCSVDownloader, } from 'react-papaparse' -import { useContext } from 'use-context-selector' import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' -import I18n from '@/context/i18n' + +import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' const CSV_TEMPLATE_QA_EN = [ @@ -24,7 +24,7 @@ const CSV_TEMPLATE_QA_CN = [ const CSVDownload: FC = () => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const { CSVDownloader, Type } = useCSVDownloader() const getTemplate = () => { diff --git a/web/app/components/app/annotation/header-opts/index.spec.tsx b/web/app/components/app/annotation/header-opts/index.spec.tsx index c52507fb22..4efee5a88f 100644 --- a/web/app/components/app/annotation/header-opts/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/index.spec.tsx @@ -1,10 +1,11 @@ import type { ComponentProps } from 'react' +import type { Mock } from 'vitest' import type { AnnotationItemBasic } from '../type' import type { Locale } from '@/i18n-config' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import I18NContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' import HeaderOptions from './index' @@ -163,12 +164,18 @@ vi.mock('@/app/components/billing/annotation-full', () => ({ default: () =>
, })) +vi.mock('@/context/i18n', () => ({ + useLocale: vi.fn(() => LanguagesSupported[0]), +})) + type HeaderOptionsProps = ComponentProps const renderComponent = ( props: Partial = {}, locale: Locale = LanguagesSupported[0], ) => { + ;(useLocale as Mock).mockReturnValue(locale) + const defaultProps: HeaderOptionsProps = { appId: 'test-app-id', onAdd: vi.fn(), @@ -177,17 +184,7 @@ const renderComponent = ( ...props, } - return render( - - - , - ) + return render() } const openOperationsPopover = async (user: ReturnType) => { @@ -440,20 +437,12 @@ describe('HeaderOptions', () => { await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1)) view.rerender( - - - , + , ) await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2)) diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index 62610ac862..5add1aed32 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -13,15 +13,14 @@ import { useTranslation } from 'react-i18next' import { useCSVDownloader, } from 'react-papaparse' -import { useContext } from 'use-context-selector' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' import CustomPopover from '@/app/components/base/popover' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' -import { cn } from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Button from '../../../base/button' import AddAnnotationModal from '../add-annotation-modal' import BatchAddModal from '../batch-add-annotation-modal' @@ -44,7 +43,7 @@ const HeaderOptions: FC = ({ controlUpdateList, }) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const { CSVDownloader, Type } = useCSVDownloader() const [list, setList] = useState([]) const annotationUnavailable = list.length === 0 diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx index e056baaa2f..4002d70169 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx @@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { CollectionType } from '@/app/components/tools/types' -import I18n from '@/context/i18n' import SettingBuiltInTool from './setting-built-in-tool' const fetchModelToolList = vi.fn() @@ -56,6 +55,10 @@ vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({ ReadmeEntrance: ({ className }: { className?: string }) =>
readme
, })) +vi.mock('@/context/i18n', () => ({ + useLocale: vi.fn(() => 'en-US'), +})) + const createParameter = (overrides?: Partial): ToolParameter => ({ name: 'settingParam', label: { @@ -129,18 +132,16 @@ const renderComponent = (props?: Partial - - , + , ) return { ...utils, diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index d060be104c..b8a4ac46b8 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -9,7 +9,6 @@ import { import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import ActionButton from '@/app/components/base/action-button' import Button from '@/app/components/base/button' import Drawer from '@/app/components/base/drawer' @@ -26,7 +25,7 @@ import { import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { CollectionType } from '@/app/components/tools/types' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools' import { cn } from '@/utils/classnames' @@ -58,7 +57,7 @@ const SettingBuiltInTool: FC = ({ credentialId, onAuthorizationItemClick, }) => { - const { locale } = useContext(I18n) + const locale = useLocale() const language = getLanguage(locale) const { t } = useTranslation() const passedTools = (collection as ToolWithProvider).tools diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 22bddcc000..57145cc223 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -6,7 +6,6 @@ import type { import { noop } from 'es-toolkit/compat' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import AppIcon from '@/app/components/base/app-icon' import Button from '@/app/components/base/button' import EmojiPicker from '@/app/components/base/emoji-picker' @@ -16,7 +15,7 @@ import Modal from '@/app/components/base/modal' import { SimpleSelect } from '@/app/components/base/select' import { useToastContext } from '@/app/components/base/toast' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' -import I18n, { useDocLink } from '@/context/i18n' +import { useDocLink, useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import { useCodeBasedExtensions } from '@/service/use-common' @@ -41,7 +40,7 @@ const ExternalDataToolModal: FC = ({ const { t } = useTranslation() const docLink = useDocLink() const { notify } = useToastContext() - const { locale } = useContext(I18n) + const locale = useLocale() const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' }) const [showEmojiPicker, setShowEmojiPicker] = useState(false) const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool') diff --git a/web/app/components/base/agent-log-modal/tool-call.tsx b/web/app/components/base/agent-log-modal/tool-call.tsx index 62d3e756da..d68aac7e95 100644 --- a/web/app/components/base/agent-log-modal/tool-call.tsx +++ b/web/app/components/base/agent-log-modal/tool-call.tsx @@ -6,13 +6,13 @@ import { RiErrorWarningLine, } from '@remixicon/react' import { useState } from 'react' -import { useContext } from 'use-context-selector' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import BlockIcon from '@/app/components/workflow/block-icon' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { BlockEnum } from '@/app/components/workflow/types' -import I18n from '@/context/i18n' + +import { useLocale } from '@/context/i18n' import { cn } from '@/utils/classnames' type Props = { @@ -26,7 +26,7 @@ type Props = { const ToolCallItem: FC = ({ toolCall, isLLM = false, isFinal, tokens, observation, finalAnswer }) => { const [collapseState, setCollapseState] = useState(true) - const { locale } = useContext(I18n) + const locale = useLocale() const toolName = isLLM ? 'LLM' : (toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')]) const getTime = (time: number) => { diff --git a/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx b/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx index de4adcdb04..55c8244ce7 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx @@ -1,10 +1,9 @@ import type { FC } from 'react' import type { CodeBasedExtensionForm } from '@/models/common' import type { ModerationConfig } from '@/models/debug' -import { useContext } from 'use-context-selector' import { PortalSelect } from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' type FormGenerationProps = { forms: CodeBasedExtensionForm[] @@ -16,7 +15,7 @@ const FormGeneration: FC = ({ value, onChange, }) => { - const { locale } = useContext(I18n) + const locale = useLocale() const handleFormChange = (type: string, v: string) => { onChange({ ...value, [type]: v }) diff --git a/web/app/components/base/features/new-feature-panel/moderation/index.tsx b/web/app/components/base/features/new-feature-panel/moderation/index.tsx index afab67eb85..0a22ce19f2 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/index.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/index.tsx @@ -1,16 +1,14 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import { RiEqualizer2Line } from '@remixicon/react' import { produce } from 'immer' -import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' import { FeatureEnum } from '@/app/components/base/features/types' import { ContentModeration } from '@/app/components/base/icons/src/vender/features' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useCodeBasedExtensions } from '@/service/use-common' @@ -25,7 +23,7 @@ const Moderation = ({ }: Props) => { const { t } = useTranslation() const { setShowModerationSettingModal } = useModalContext() - const { locale } = useContext(I18n) + const locale = useLocale() const featuresStore = useFeaturesStore() const moderation = useFeatures(s => s.features.moderation) const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation') diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index be51b8a2c5..c352913e30 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -5,7 +5,6 @@ import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/compat' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' @@ -15,7 +14,7 @@ import { useToastContext } from '@/app/components/base/toast' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import I18n, { useDocLink } from '@/context/i18n' +import { useDocLink, useLocale } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { LanguagesSupported } from '@/i18n-config/language' import { useCodeBasedExtensions, useModelProviders } from '@/service/use-common' @@ -45,7 +44,7 @@ const ModerationSettingModal: FC = ({ const { t } = useTranslation() const docLink = useDocLink() const { notify } = useToastContext() - const { locale } = useContext(I18n) + const locale = useLocale() const { data: modelProviders, isPending: isLoading, refetch: refetchModelProviders } = useModelProviders() const [localeData, setLocaleData] = useState(data) const { setShowAccountSettingModal } = useModalContext() diff --git a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx index 1d99645c67..31c62758c1 100644 --- a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx @@ -1,13 +1,13 @@ import { useMemo } from 'react' import { useGlobalPublicStore } from '@/context/global-public-context' -import { useI18N } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import { usePipelineTemplateList } from '@/service/use-pipeline' import CreateCard from './create-card' import TemplateCard from './template-card' const BuiltInPipelineList = () => { - const { locale } = useI18N() + const locale = useLocale() const language = useMemo(() => { if (['zh-Hans', 'ja-JP'].includes(locale)) return locale diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index fb30f61f53..e9c6693e52 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -10,7 +10,7 @@ import SimplePieChart from '@/app/components/base/simple-pie-chart' import { ToastContext } from '@/app/components/base/toast' import { IS_CE_EDITION } from '@/config' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import useTheme from '@/hooks/use-theme' import { LanguagesSupported } from '@/i18n-config/language' import { upload } from '@/service/base' @@ -40,7 +40,7 @@ const FileUploader = ({ }: IFileUploaderProps) => { const { t } = useTranslation() const { notify } = useContext(ToastContext) - const { locale } = useContext(I18n) + const locale = useLocale() const [dragging, setDragging] = useState(false) const dropRef = useRef(null) const dragRef = useRef(null) diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index 7f3b4b3589..ecc517ed48 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -12,10 +12,8 @@ import { import { noop } from 'es-toolkit/compat' import Image from 'next/image' import Link from 'next/link' -import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import { trackEvent } from '@/app/components/base/amplitude' import Badge from '@/app/components/base/badge' import Button from '@/app/components/base/button' @@ -38,7 +36,7 @@ import { useDefaultModel, useModelList, useModelListAndDefaultModelAndCurrentPro import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { FULL_DOC_PREVIEW_LENGTH, IS_CE_EDITION } from '@/config' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import I18n, { useDocLink } from '@/context/i18n' +import { useDocLink, useLocale } from '@/context/i18n' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { LanguagesSupported } from '@/i18n-config/language' import { DataSourceProvider } from '@/models/common' @@ -151,7 +149,7 @@ const StepTwo = ({ }: StepTwoProps) => { const { t } = useTranslation() const docLink = useDocLink() - const { locale } = useContext(I18n) + const locale = useLocale() const media = useBreakpoints() const isMobile = media === MediaType.mobile diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx index 21507d96bb..a5c03b671a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx @@ -11,7 +11,7 @@ import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/u import { ToastContext } from '@/app/components/base/toast' import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon' import { IS_CE_EDITION } from '@/config' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import useTheme from '@/hooks/use-theme' import { LanguagesSupported } from '@/i18n-config/language' import { upload } from '@/service/base' @@ -33,7 +33,7 @@ const LocalFile = ({ }: LocalFileProps) => { const { t } = useTranslation() const { notify } = useContext(ToastContext) - const { locale } = useContext(I18n) + const locale = useLocale() const localFileList = useDataSourceStoreWithSelector(state => state.localFileList) const dataSourceStore = useDataSourceStore() const [dragging, setDragging] = useState(false) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx index c6c2c4ed4c..83008b7d40 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx @@ -5,9 +5,8 @@ import { useTranslation } from 'react-i18next' import { useCSVDownloader, } from 'react-papaparse' -import { useContext } from 'use-context-selector' import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import { ChunkingMode } from '@/models/datasets' @@ -34,7 +33,7 @@ const CSV_TEMPLATE_CN = [ const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const { CSVDownloader, Type } = useCSVDownloader() const getTemplate = () => { diff --git a/web/app/components/develop/doc.tsx b/web/app/components/develop/doc.tsx index 40e27eb418..4e853113d4 100644 --- a/web/app/components/develop/doc.tsx +++ b/web/app/components/develop/doc.tsx @@ -2,8 +2,7 @@ import { RiCloseLine, RiListUnordered } from '@remixicon/react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import useTheme from '@/hooks/use-theme' import { LanguagesSupported } from '@/i18n-config/language' import { AppModeEnum, Theme } from '@/types/app' @@ -26,7 +25,7 @@ type IDocProps = { } const Doc = ({ appDetail }: IDocProps) => { - const { locale } = useContext(I18n) + const locale = useLocale() const { t } = useTranslation() const [toc, setToc] = useState>([]) const [isTocExpanded, setIsTocExpanded] = useState(false) diff --git a/web/app/components/header/account-setting/language-page/index.tsx b/web/app/components/header/account-setting/language-page/index.tsx index c0cc59518f..5d888281e9 100644 --- a/web/app/components/header/account-setting/language-page/index.tsx +++ b/web/app/components/header/account-setting/language-page/index.tsx @@ -8,7 +8,9 @@ import { useContext } from 'use-context-selector' import { SimpleSelect } from '@/app/components/base/select' import { ToastContext } from '@/app/components/base/toast' import { useAppContext } from '@/context/app-context' -import I18n from '@/context/i18n' + +import { useLocale } from '@/context/i18n' +import { setLocaleOnClient } from '@/i18n-config' import { languages } from '@/i18n-config/language' import { updateUserProfile } from '@/service/common' import { timezones } from '@/utils/timezone' @@ -18,7 +20,7 @@ const titleClassName = ` ` export default function LanguagePage() { - const { locale, setLocaleOnClient } = useContext(I18n) + const locale = useLocale() const { userProfile, mutateUserProfile } = useAppContext() const { notify } = useContext(ToastContext) const [editing, setEditing] = useState(false) diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index cd6a322108..d405e8e4c4 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -3,7 +3,6 @@ import type { InvitationResult } from '@/models/common' import { RiPencilLine, RiUserAddLine } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Avatar from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import Tooltip from '@/app/components/base/tooltip' @@ -12,7 +11,7 @@ import { Plan } from '@/app/components/billing/type' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { LanguagesSupported } from '@/i18n-config/language' @@ -34,7 +33,7 @@ const MembersPage = () => { dataset_operator: t('members.datasetOperator', { ns: 'common' }), normal: t('members.normal', { ns: 'common' }), } - const { locale } = useContext(I18n) + const locale = useLocale() const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext() const { data, refetch } = useMembers() diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 3c3a1a8eff..964d25e1cb 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -12,7 +12,7 @@ import Button from '@/app/components/base/button' import Modal from '@/app/components/base/modal' import { ToastContext } from '@/app/components/base/toast' import { emailRegex } from '@/config' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useProviderContextSelector } from '@/context/provider-context' import { inviteMember } from '@/service/common' import { cn } from '@/utils/classnames' @@ -47,7 +47,7 @@ const InviteModal = ({ setIsLimitExceeded(limited && (used > licenseLimit.workspace_members.limit)) }, [licenseLimit, emails]) - const { locale } = useContext(I18n) + const locale = useLocale() const [role, setRole] = useState('normal') const [isSubmitting, { diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts index 0c124f55d1..b264324374 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts @@ -1,6 +1,6 @@ import type { Mock } from 'vitest' import { renderHook } from '@testing-library/react' -import { useContext } from 'use-context-selector' +import { useLocale } from '@/context/i18n' import { useLanguage } from './hooks' vi.mock('@tanstack/react-query', () => ({ @@ -36,8 +36,7 @@ vi.mock('@/service/use-common', () => ({ // mock context hooks vi.mock('@/context/i18n', () => ({ - __esModule: true, - default: vi.fn(), + useLocale: vi.fn(() => 'en-US'), })) vi.mock('@/context/provider-context', () => ({ @@ -72,27 +71,20 @@ afterAll(() => { describe('useLanguage', () => { it('should replace hyphen with underscore in locale', () => { - (useContext as Mock).mockReturnValue({ - locale: 'en-US', - }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useLanguage()) expect(result.current).toBe('en_US') }) it('should return locale as is if no hyphen exists', () => { - (useContext as Mock).mockReturnValue({ - locale: 'enUS', - }) + ;(useLocale as Mock).mockReturnValue('enUS') const { result } = renderHook(() => useLanguage()) expect(result.current).toBe('enUS') }) it('should handle multiple hyphens', () => { - // Mock the I18n context return value - (useContext as Mock).mockReturnValue({ - locale: 'zh-Hans-CN', - }) + ;(useLocale as Mock).mockReturnValue('zh-Hans-CN') const { result } = renderHook(() => useLanguage()) expect(result.current).toBe('zh_Hans-CN') diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 8bf5ad05ba..0e35f0fb31 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -16,14 +16,13 @@ import { useMemo, useState, } from 'react' -import { useContext } from 'use-context-selector' import { useMarketplacePlugins, useMarketplacePluginsByCollectionId, } from '@/app/components/plugins/marketplace/hooks' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useEventEmitterContextContext } from '@/context/event-emitter' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { @@ -70,7 +69,7 @@ export const useSystemDefaultModelAndModelList: UseDefaultModelAndModelList = ( } export const useLanguage = () => { - const { locale } = useContext(I18n) + const locale = useLocale() return locale.replace('-', '_') } diff --git a/web/app/components/i18n.tsx b/web/app/components/i18n.tsx index 8a95363c15..e9af2face9 100644 --- a/web/app/components/i18n.tsx +++ b/web/app/components/i18n.tsx @@ -3,9 +3,10 @@ import type { FC } from 'react' import type { Locale } from '@/i18n-config' import { usePrefetchQuery } from '@tanstack/react-query' +import { useHydrateAtoms } from 'jotai/utils' import * as React from 'react' import { useEffect, useState } from 'react' -import I18NContext from '@/context/i18n' +import { localeAtom } from '@/context/i18n' import { setLocaleOnClient } from '@/i18n-config' import { getSystemFeatures } from '@/service/common' import Loading from './base/loading' @@ -18,6 +19,7 @@ const I18n: FC = ({ locale, children, }) => { + useHydrateAtoms([[localeAtom, locale]]) const [loading, setLoading] = useState(true) usePrefetchQuery({ @@ -35,14 +37,9 @@ const I18n: FC = ({ return
return ( - + <> {children} - + ) } export default React.memo(I18n) diff --git a/web/app/components/plugins/card/index.spec.tsx b/web/app/components/plugins/card/index.spec.tsx index 9085d9a500..d32aafff57 100644 --- a/web/app/components/plugins/card/index.spec.tsx +++ b/web/app/components/plugins/card/index.spec.tsx @@ -46,7 +46,6 @@ vi.mock('../marketplace/hooks', () => ({ // Mock useGetLanguage context vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', - useI18N: () => ({ locale: 'en-US' }), })) // Mock useTheme hook diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index 9a0850d127..d3ca964538 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -1,9 +1,6 @@ /* eslint-disable dify-i18n/require-ns-option */ import type { Locale } from '@/i18n-config' -import { - getLocaleOnServer, - getTranslation as translate, -} from '@/i18n-config/server' +import { getLocaleOnServer, getTranslation } from '@/i18n-config/server' type DescriptionProps = { locale?: Locale @@ -12,8 +9,8 @@ const Description = async ({ locale: localeFromProps, }: DescriptionProps) => { const localeDefault = await getLocaleOnServer() - const { t } = await translate(localeFromProps || localeDefault, 'plugin') - const { t: tCommon } = await translate(localeFromProps || localeDefault, 'common') + const { t } = await getTranslation(localeFromProps || localeDefault, 'plugin') + const { t: tCommon } = await getTranslation(localeFromProps || localeDefault, 'common') const isZhHans = localeFromProps === 'zh-Hans' return ( diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index 9cfac94ccd..6047afe950 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -191,11 +191,9 @@ vi.mock('next-themes', () => ({ }), })) -// Mock useI18N context +// Mock useLocale context vi.mock('@/context/i18n', () => ({ - useI18N: () => ({ - locale: 'en-US', - }), + useLocale: () => 'en-US', })) // Mock i18n-config/language diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index a8c12126f3..6c1d2e1656 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -12,7 +12,7 @@ import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import { useTags } from '@/app/components/plugins/hooks' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks' -import { useI18N } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils' type CardWrapperProps = { @@ -31,7 +31,7 @@ const CardWrapperComponent = ({ setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace, }] = useBoolean(false) - const { locale: localeFromLocale } = useI18N() + const localeFromLocale = useLocale() const { getTagLabel } = useTags(t) // Memoize marketplace link params to prevent unnecessary re-renders diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index e367f8fb6a..029cc7ecbc 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -49,11 +49,9 @@ vi.mock('../context', () => ({ useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), })) -// Mock useI18N context +// Mock useLocale context vi.mock('@/context/i18n', () => ({ - useI18N: () => ({ - locale: 'en-US', - }), + useLocale: () => 'en-US', })) // Mock next-themes diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index f3b60a9591..9b83e38877 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -26,7 +26,7 @@ import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-v import { API_PREFIX } from '@/config' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' -import { useGetLanguage, useI18N } from '@/context/i18n' +import { useGetLanguage, useLocale } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import useTheme from '@/hooks/use-theme' @@ -67,7 +67,7 @@ const DetailHeader = ({ const { theme } = useTheme() const locale = useGetLanguage() - const { locale: currentLocale } = useI18N() + const currentLocale = useLocale() const { checkForUpdates, fetchReleases } = useGitHubReleases() const { setShowUpdatePluginModal } = useModalContext() const { refreshModelProviders } = useProviderContext() diff --git a/web/app/components/plugins/plugin-mutation-model/index.spec.tsx b/web/app/components/plugins/plugin-mutation-model/index.spec.tsx index 2181935b1f..f007c32ef1 100644 --- a/web/app/components/plugins/plugin-mutation-model/index.spec.tsx +++ b/web/app/components/plugins/plugin-mutation-model/index.spec.tsx @@ -29,7 +29,6 @@ vi.mock('../marketplace/hooks', () => ({ // Mock useGetLanguage context vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', - useI18N: () => ({ locale: 'en-US' }), })) // Mock useTheme hook diff --git a/web/app/components/plugins/plugin-page/debug-info.tsx b/web/app/components/plugins/plugin-page/debug-info.tsx index 8bedde5c42..f62f8a4134 100644 --- a/web/app/components/plugins/plugin-page/debug-info.tsx +++ b/web/app/components/plugins/plugin-page/debug-info.tsx @@ -6,11 +6,10 @@ import { } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Tooltip from '@/app/components/base/tooltip' import { getDocsUrl } from '@/app/components/plugins/utils' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useDebugKey } from '@/service/use-plugins' import KeyValueItem from '../base/key-value-item' @@ -18,7 +17,7 @@ const i18nPrefix = 'debugInfo' const DebugInfo: FC = () => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const { data: info, isLoading } = useDebugKey() // info.key likes 4580bdb7-b878-471c-a8a4-bfd760263a53 mask the middle part using *. diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index 4975b09470..6d8542f5c9 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -11,7 +11,6 @@ import { noop } from 'es-toolkit/compat' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import TabSlider from '@/app/components/base/tab-slider' import Tooltip from '@/app/components/base/tooltip' @@ -19,7 +18,7 @@ import ReferenceSettingModal from '@/app/components/plugins/reference-setting-mo import { getDocsUrl } from '@/app/components/plugins/utils' import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { usePluginInstallation } from '@/hooks/use-query-params' import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' @@ -48,7 +47,7 @@ const PluginPage = ({ marketplace, }: PluginPageProps) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() useDocumentTitle(t('metadata.title', { ns: 'plugin' })) // Use nuqs hook for installation state diff --git a/web/app/components/plugins/provider-card.tsx b/web/app/components/plugins/provider-card.tsx index 2a323da691..a3bba8d774 100644 --- a/web/app/components/plugins/provider-card.tsx +++ b/web/app/components/plugins/provider-card.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import { getPluginLinkInMarketplace } from '@/app/components/plugins/marketplace/utils' -import { useI18N } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useRenderI18nObject } from '@/hooks/use-i18n' import { cn } from '@/utils/classnames' import Badge from '../base/badge' @@ -36,7 +36,7 @@ const ProviderCardComponent: FC = ({ setFalse: hideInstallFromMarketplace, }] = useBoolean(false) const { org, label } = payload - const { locale } = useI18N() + const locale = useLocale() // Memoize the marketplace link params to prevent unnecessary re-renders const marketplaceLinkParams = useMemo(() => ({ language: locale, theme }), [locale, theme]) diff --git a/web/app/components/plugins/update-plugin/index.spec.tsx b/web/app/components/plugins/update-plugin/index.spec.tsx index 379606a18b..2d4635f83b 100644 --- a/web/app/components/plugins/update-plugin/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/index.spec.tsx @@ -51,7 +51,6 @@ vi.mock('react-i18next', async (importOriginal) => { // Mock useGetLanguage context vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', - useI18N: () => ({ locale: 'en-US' }), })) // Mock app context for useGetIcon diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx index 2df967684a..fe3d1ada3c 100644 --- a/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/test-api.spec.tsx @@ -1,13 +1,17 @@ import type { CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { AuthType } from '@/app/components/tools/types' -import I18n from '@/context/i18n' import { testAPIAvailable } from '@/service/tools' import TestApi from './test-api' vi.mock('@/service/tools', () => ({ testAPIAvailable: vi.fn(), })) + +vi.mock('@/context/i18n', () => ({ + useLocale: vi.fn(() => 'en-US'), +})) + const testAPIAvailableMock = vi.mocked(testAPIAvailable) describe('TestApi', () => { @@ -40,19 +44,12 @@ describe('TestApi', () => { } const renderTestApi = () => { - const providerValue = { - locale: 'en-US', - i18n: {}, - setLocaleOnClient: vi.fn(), - } return render( - - - , + , ) } diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx index 978870baa1..a376543bea 100644 --- a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx @@ -5,12 +5,11 @@ import { RiSettings2Line } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Drawer from '@/app/components/base/drawer-plus' import Input from '@/app/components/base/input' import { AuthType } from '@/app/components/tools/types' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' import { testAPIAvailable } from '@/service/tools' import ConfigCredentials from './config-credentials' @@ -29,7 +28,7 @@ const TestApi: FC = ({ onHide, }) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const language = getLanguage(locale) const [credentialsModalShow, setCredentialsModalShow] = useState(false) const [tempCredential, setTempCredential] = React.useState(customCollection.credentials) diff --git a/web/app/components/tools/mcp/create-card.tsx b/web/app/components/tools/mcp/create-card.tsx index 254a5270e8..7a0496d7c3 100644 --- a/web/app/components/tools/mcp/create-card.tsx +++ b/web/app/components/tools/mcp/create-card.tsx @@ -7,9 +7,8 @@ import { } from '@remixicon/react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import { useAppContext } from '@/context/app-context' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' import { useCreateMCP } from '@/service/use-tools' import MCPModal from './modal' @@ -20,7 +19,7 @@ type Props = { const NewMCPCard = ({ handleCreate }: Props) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const language = getLanguage(locale) const { isCurrentWorkspaceManager } = useAppContext() diff --git a/web/app/components/tools/mcp/detail/tool-item.tsx b/web/app/components/tools/mcp/detail/tool-item.tsx index 3d53734c88..456005804b 100644 --- a/web/app/components/tools/mcp/detail/tool-item.tsx +++ b/web/app/components/tools/mcp/detail/tool-item.tsx @@ -2,9 +2,8 @@ import type { Tool } from '@/app/components/tools/types' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Tooltip from '@/app/components/base/tooltip' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' @@ -15,7 +14,7 @@ type Props = { const MCPToolItem = ({ tool, }: Props) => { - const { locale } = useContext(I18n) + const locale = useLocale() const language = getLanguage(locale) const { t } = useTranslation() diff --git a/web/app/components/tools/provider/custom-create-card.tsx b/web/app/components/tools/provider/custom-create-card.tsx index 56ce3845f2..637d17c3c3 100644 --- a/web/app/components/tools/provider/custom-create-card.tsx +++ b/web/app/components/tools/provider/custom-create-card.tsx @@ -7,11 +7,10 @@ import { } from '@remixicon/react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Toast from '@/app/components/base/toast' import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal' import { useAppContext } from '@/context/app-context' -import I18n, { useDocLink } from '@/context/i18n' +import { useDocLink, useLocale } from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' import { createCustomCollection } from '@/service/tools' @@ -21,7 +20,7 @@ type Props = { const Contribute = ({ onRefreshData }: Props) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const language = getLanguage(locale) const { isCurrentWorkspaceManager } = useAppContext() diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index 70d65f02bc..a23f722cbe 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -6,7 +6,6 @@ import { import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import ActionButton from '@/app/components/base/action-button' import Button from '@/app/components/base/button' import Confirm from '@/app/components/base/confirm' @@ -24,7 +23,7 @@ import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-m import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' import WorkflowToolModal from '@/app/components/tools/workflow-tool' import { useAppContext } from '@/context/app-context' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' @@ -60,7 +59,7 @@ const ProviderDetail = ({ onRefreshData, }: Props) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const language = getLanguage(locale) const needAuth = collection.allow_delete || collection.type === CollectionType.model diff --git a/web/app/components/tools/provider/tool-item.tsx b/web/app/components/tools/provider/tool-item.tsx index b240bf6a41..4e28a7427b 100644 --- a/web/app/components/tools/provider/tool-item.tsx +++ b/web/app/components/tools/provider/tool-item.tsx @@ -2,9 +2,8 @@ import type { Collection, Tool } from '../types' import * as React from 'react' import { useState } from 'react' -import { useContext } from 'use-context-selector' import SettingBuiltInTool from '@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' @@ -23,7 +22,7 @@ const ToolItem = ({ isBuiltIn, isModel, }: Props) => { - const { locale } = useContext(I18n) + const locale = useLocale() const language = getLanguage(locale) const [showDetail, setShowDetail] = useState(false) diff --git a/web/app/components/with-i18n.tsx b/web/app/components/with-i18n.tsx deleted file mode 100644 index b06024d51c..0000000000 --- a/web/app/components/with-i18n.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client' - -import type { ReactNode } from 'react' -import { useContext } from 'use-context-selector' -import I18NContext from '@/context/i18n' - -export type II18NHocProps = { - children: ReactNode -} - -const withI18N = (Component: any) => { - return (props: any) => { - const { i18n } = useContext(I18NContext) - return ( - - ) - } -} - -export default withI18N diff --git a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx b/web/app/components/workflow/block-selector/market-place-plugin/item.tsx index 6c761b4541..0fb28f8a25 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/item.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/item.tsx @@ -4,9 +4,8 @@ import type { Plugin } from '@/app/components/plugins/types.ts' import { useBoolean } from 'ahooks' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { cn } from '@/utils/classnames' import { formatNumber } from '@/utils/format' @@ -27,7 +26,7 @@ const Item: FC = ({ }) => { const { t } = useTranslation() const [open, setOpen] = React.useState(false) - const { locale } = useContext(I18n) + const locale = useLocale() const getLocalizedText = (obj: Record | undefined) => obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' const [isShowInstallModal, { diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx index ae3fa42d34..1badc2497c 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx @@ -3,9 +3,8 @@ import type { Plugin } from '@/app/components/plugins/types' import { useBoolean } from 'ahooks' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import BlockIcon from '../../block-icon' import { BlockEnum } from '../../types' @@ -17,7 +16,7 @@ const UninstalledItem = ({ payload, }: UninstalledItemProps) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const getLocalizedText = (obj: Record | undefined) => obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' diff --git a/web/app/components/workflow/nodes/document-extractor/panel.tsx b/web/app/components/workflow/nodes/document-extractor/panel.tsx index 7bca46a642..8504cdf6e5 100644 --- a/web/app/components/workflow/nodes/document-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/document-extractor/panel.tsx @@ -3,10 +3,9 @@ import type { DocExtractorNodeType } from './types' import type { NodePanelProps } from '@/app/components/workflow/types' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Field from '@/app/components/workflow/nodes/_base/components/field' import { BlockEnum } from '@/app/components/workflow/types' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import { useFileSupportTypes } from '@/service/use-common' import OutputVars, { VarItem } from '../_base/components/output-vars' @@ -22,7 +21,7 @@ const Panel: FC> = ({ data, }) => { const { t } = useTranslation() - const { locale } = useContext(I18n) + const locale = useLocale() const link = useNodeHelpLink(BlockEnum.DocExtractor) const { data: supportFileTypesResponse } = useFileSupportTypes() const supportTypes = supportFileTypesResponse?.allowed_extensions || [] diff --git a/web/app/reset-password/check-code/page.tsx b/web/app/reset-password/check-code/page.tsx index 70466ae32b..cf4a6e6ce4 100644 --- a/web/app/reset-password/check-code/page.tsx +++ b/web/app/reset-password/check-code/page.tsx @@ -3,12 +3,11 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import Countdown from '@/app/components/signin/countdown' -import I18NContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { sendResetPasswordCode, verifyResetPasswordCode } from '@/service/common' export default function CheckCode() { @@ -19,7 +18,7 @@ export default function CheckCode() { const token = decodeURIComponent(searchParams.get('token') as string) const [code, setVerifyCode] = useState('') const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const verify = async () => { try { diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx index c5e6264233..6be429960c 100644 --- a/web/app/reset-password/page.tsx +++ b/web/app/reset-password/page.tsx @@ -5,12 +5,11 @@ import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import { emailRegex } from '@/config' -import I18NContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { sendResetPasswordCode } from '@/service/common' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown' @@ -22,7 +21,7 @@ export default function CheckCode() { const router = useRouter() const [email, setEmail] = useState('') const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const handleGetEMailVerificationCode = async () => { try { diff --git a/web/app/signin/_header.tsx b/web/app/signin/_header.tsx index 01135c2bf6..63be6df674 100644 --- a/web/app/signin/_header.tsx +++ b/web/app/signin/_header.tsx @@ -1,12 +1,11 @@ 'use client' import type { Locale } from '@/i18n-config' import dynamic from 'next/dynamic' -import * as React from 'react' -import { useContext } from 'use-context-selector' import Divider from '@/app/components/base/divider' import LocaleSigninSelect from '@/app/components/base/select/locale-signin' import { useGlobalPublicStore } from '@/context/global-public-context' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' +import { setLocaleOnClient } from '@/i18n-config' import { languages } from '@/i18n-config/language' // Avoid rendering the logo and theme selector on the server @@ -20,7 +19,7 @@ const ThemeSelector = dynamic(() => import('@/app/components/base/theme-selector }) const Header = () => { - const { locale, setLocaleOnClient } = useContext(I18n) + const locale = useLocale() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) return ( diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index 1e0a460592..59579a76ec 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -4,13 +4,13 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import { trackEvent } from '@/app/components/base/amplitude' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import Countdown from '@/app/components/signin/countdown' -import I18NContext from '@/context/i18n' + +import { useLocale } from '@/context/i18n' import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' import { encryptVerificationCode } from '@/utils/encryption' import { resolvePostLoginRedirect } from '../utils/post-login-redirect' @@ -25,7 +25,7 @@ export default function CheckCode() { const language = i18n.language const [code, setVerifyCode] = useState('') const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const codeInputRef = useRef(null) const verify = async () => { diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx index c7dc8cb1f1..4454fc821f 100644 --- a/web/app/signin/components/mail-and-code-auth.tsx +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -2,13 +2,12 @@ import type { FormEvent } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' -import I18NContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { sendEMailLoginCode } from '@/service/common' type MailAndCodeAuthProps = { @@ -22,7 +21,7 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { const emailFromLink = decodeURIComponent(searchParams.get('email') || '') const [email, setEmail] = useState(emailFromLink) const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const handleGetEMailVerificationCode = async () => { try { diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx index 4d9c3fe43f..4a18e884ad 100644 --- a/web/app/signin/components/mail-and-password-auth.tsx +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -4,13 +4,12 @@ import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import { trackEvent } from '@/app/components/base/amplitude' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import { emailRegex } from '@/config' -import I18NContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { login } from '@/service/common' import { encryptPassword } from '@/utils/encryption' import { resolvePostLoginRedirect } from '../utils/post-login-redirect' @@ -23,7 +22,7 @@ type MailAndPasswordAuthProps = { export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegistration: _allowRegistration }: MailAndPasswordAuthProps) { const { t } = useTranslation() - const { locale } = useContext(I18NContext) + const locale = useLocale() const router = useRouter() const searchParams = useSearchParams() const [showPassword, setShowPassword] = useState(false) diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index aacccfaa92..360f305cbd 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -6,14 +6,14 @@ import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' import { SimpleSelect } from '@/app/components/base/select' import Toast from '@/app/components/base/toast' import { useGlobalPublicStore } from '@/context/global-public-context' -import I18n, { useDocLink } from '@/context/i18n' +import { useDocLink } from '@/context/i18n' +import { setLocaleOnClient } from '@/i18n-config' import { languages, LanguagesSupported } from '@/i18n-config/language' import { activateMember } from '@/service/common' import { useInvitationCheck } from '@/service/use-common' @@ -27,7 +27,6 @@ export default function InviteSettingsPage() { const router = useRouter() const searchParams = useSearchParams() const token = decodeURIComponent(searchParams.get('invite_token') as string) - const { setLocaleOnClient } = useContext(I18n) const [name, setName] = useState('') const [language, setLanguage] = useState(LanguagesSupported[0]) const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles') @@ -65,7 +64,7 @@ export default function InviteSettingsPage() { catch { recheck() } - }, [language, name, recheck, setLocaleOnClient, timezone, token, router, t]) + }, [language, name, recheck, timezone, token, router, t]) if (!checkRes) return diff --git a/web/app/signup/check-code/page.tsx b/web/app/signup/check-code/page.tsx index 7a818efa5e..c298c11535 100644 --- a/web/app/signup/check-code/page.tsx +++ b/web/app/signup/check-code/page.tsx @@ -4,12 +4,11 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import Countdown from '@/app/components/signin/countdown' -import I18NContext from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useMailValidity, useSendMail } from '@/service/use-common' export default function CheckCode() { @@ -20,7 +19,7 @@ export default function CheckCode() { const [token, setToken] = useState(decodeURIComponent(searchParams.get('token') as string)) const [code, setVerifyCode] = useState('') const [loading, setIsLoading] = useState(false) - const { locale } = useContext(I18NContext) + const locale = useLocale() const { mutateAsync: submitMail } = useSendMail() const { mutateAsync: verifyCode } = useMailValidity() diff --git a/web/app/signup/components/input-mail.tsx b/web/app/signup/components/input-mail.tsx index 19711a4c04..a1730b90c9 100644 --- a/web/app/signup/components/input-mail.tsx +++ b/web/app/signup/components/input-mail.tsx @@ -4,14 +4,13 @@ import { noop } from 'es-toolkit/compat' import Link from 'next/link' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Toast from '@/app/components/base/toast' import Split from '@/app/signin/split' import { emailRegex } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' -import I18n from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useSendMail } from '@/service/use-common' type Props = { @@ -22,7 +21,7 @@ export default function Form({ }: Props) { const { t } = useTranslation() const [email, setEmail] = useState('') - const { locale } = useContext(I18n) + const locale = useLocale() const { systemFeatures } = useGlobalPublicStore() const { mutateAsync: submitMail, isPending } = useSendMail() diff --git a/web/context/i18n.ts b/web/context/i18n.ts index 92d66a1b2f..e65049b506 100644 --- a/web/context/i18n.ts +++ b/web/context/i18n.ts @@ -1,33 +1,19 @@ -import type { Locale } from '@/i18n-config' -import { noop } from 'es-toolkit/compat' -import { - createContext, - useContext, -} from 'use-context-selector' +import type { Locale } from '@/i18n-config/language' +import { atom, useAtomValue } from 'jotai' import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language' -type II18NContext = { - locale: Locale - i18n: Record - setLocaleOnClient: (_lang: Locale, _reloadPage?: boolean) => Promise +export const localeAtom = atom('en-US') +export const useLocale = () => { + return useAtomValue(localeAtom) } -const I18NContext = createContext({ - locale: 'en-US', - i18n: {}, - setLocaleOnClient: async (_lang: Locale, _reloadPage?: boolean) => { - noop() - }, -}) - -export const useI18N = () => useContext(I18NContext) export const useGetLanguage = () => { - const { locale } = useI18N() + const locale = useLocale() return getLanguage(locale) } export const useGetPricingPageLanguage = () => { - const { locale } = useI18N() + const locale = useLocale() return getPricingPageLanguage(locale) } @@ -36,7 +22,7 @@ export const defaultDocBaseUrl = 'https://docs.dify.ai' export const useDocLink = (baseUrl?: string): ((path?: string, pathMap?: { [index: string]: string }) => string) => { let baseDocUrl = baseUrl || defaultDocBaseUrl baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl - const { locale } = useI18N() + const locale = useLocale() const docLanguage = getDocLanguage(locale) return (path?: string, pathMap?: { [index: string]: string }): string => { const pathUrl = path || '' @@ -45,4 +31,3 @@ export const useDocLink = (baseUrl?: string): ((path?: string, pathMap?: { [inde return `${baseDocUrl}/${docLanguage}/${targetPath}` } } -export default I18NContext diff --git a/web/hooks/use-format-time-from-now.spec.ts b/web/hooks/use-format-time-from-now.spec.ts index c5236dfbe6..94eb08de90 100644 --- a/web/hooks/use-format-time-from-now.spec.ts +++ b/web/hooks/use-format-time-from-now.spec.ts @@ -14,15 +14,13 @@ import type { Mock } from 'vitest' */ import { renderHook } from '@testing-library/react' // Import after mock to get the mocked version -import { useI18N } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { useFormatTimeFromNow } from './use-format-time-from-now' // Mock the i18n context vi.mock('@/context/i18n', () => ({ - useI18N: vi.fn(() => ({ - locale: 'en-US', - })), + useLocale: vi.fn(() => 'en-US'), })) describe('useFormatTimeFromNow', () => { @@ -47,7 +45,7 @@ describe('useFormatTimeFromNow', () => { * Should return human-readable relative time strings */ it('should format time from now in English', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -65,7 +63,7 @@ describe('useFormatTimeFromNow', () => { * Very recent timestamps should show seconds */ it('should format very recent times', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -81,7 +79,7 @@ describe('useFormatTimeFromNow', () => { * Should handle day-level granularity */ it('should format times from days ago', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -98,7 +96,7 @@ describe('useFormatTimeFromNow', () => { * dayjs fromNow also supports future times (e.g., "in 2 hours") */ it('should format future times', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -117,7 +115,7 @@ describe('useFormatTimeFromNow', () => { * Should use Chinese characters for time units */ it('should format time in Chinese (Simplified)', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'zh-Hans' }) + ;(useLocale as Mock).mockReturnValue('zh-Hans') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -134,7 +132,7 @@ describe('useFormatTimeFromNow', () => { * Should use Spanish words for relative time */ it('should format time in Spanish', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'es-ES' }) + ;(useLocale as Mock).mockReturnValue('es-ES') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -151,7 +149,7 @@ describe('useFormatTimeFromNow', () => { * Should use French words for relative time */ it('should format time in French', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'fr-FR' }) + ;(useLocale as Mock).mockReturnValue('fr-FR') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -168,7 +166,7 @@ describe('useFormatTimeFromNow', () => { * Should use Japanese characters */ it('should format time in Japanese', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'ja-JP' }) + ;(useLocale as Mock).mockReturnValue('ja-JP') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -185,7 +183,7 @@ describe('useFormatTimeFromNow', () => { * Should use pt-br locale mapping */ it('should format time in Portuguese (Brazil)', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'pt-BR' }) + ;(useLocale as Mock).mockReturnValue('pt-BR') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -202,7 +200,7 @@ describe('useFormatTimeFromNow', () => { * Unknown locales should default to English */ it('should fallback to English for unsupported locale', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'xx-XX' as any }) + ;(useLocale as Mock).mockReturnValue('xx-XX' as any) const { result } = renderHook(() => useFormatTimeFromNow()) @@ -222,7 +220,7 @@ describe('useFormatTimeFromNow', () => { * Should format as a very old date */ it('should handle timestamp 0', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -238,7 +236,7 @@ describe('useFormatTimeFromNow', () => { * Should handle dates far in the future */ it('should handle very large timestamps', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -260,12 +258,12 @@ describe('useFormatTimeFromNow', () => { const oneHourAgo = now - (60 * 60 * 1000) // First render with English - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') rerender() const englishResult = result.current.formatTimeFromNow(oneHourAgo) // Second render with Spanish - ;(useI18N as Mock).mockReturnValue({ locale: 'es-ES' }) + ;(useLocale as Mock).mockReturnValue('es-ES') rerender() const spanishResult = result.current.formatTimeFromNow(oneHourAgo) @@ -280,7 +278,7 @@ describe('useFormatTimeFromNow', () => { * dayjs should automatically choose the appropriate unit */ it('should use appropriate time units for different durations', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result } = renderHook(() => useFormatTimeFromNow()) @@ -342,7 +340,7 @@ describe('useFormatTimeFromNow', () => { const oneHourAgo = now - (60 * 60 * 1000) locales.forEach((locale) => { - ;(useI18N as Mock).mockReturnValue({ locale }) + ;(useLocale as Mock).mockReturnValue(locale) const { result } = renderHook(() => useFormatTimeFromNow()) const formatted = result.current.formatTimeFromNow(oneHourAgo) @@ -360,7 +358,7 @@ describe('useFormatTimeFromNow', () => { * The formatTimeFromNow function should be memoized with useCallback */ it('should memoize formatTimeFromNow function', () => { - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') const { result, rerender } = renderHook(() => useFormatTimeFromNow()) @@ -379,11 +377,11 @@ describe('useFormatTimeFromNow', () => { it('should create new function when locale changes', () => { const { result, rerender } = renderHook(() => useFormatTimeFromNow()) - ;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) + ;(useLocale as Mock).mockReturnValue('en-US') rerender() const englishFunction = result.current.formatTimeFromNow - ;(useI18N as Mock).mockReturnValue({ locale: 'es-ES' }) + ;(useLocale as Mock).mockReturnValue('es-ES') rerender() const spanishFunction = result.current.formatTimeFromNow diff --git a/web/hooks/use-format-time-from-now.ts b/web/hooks/use-format-time-from-now.ts index 970a64e7d5..ba140bee69 100644 --- a/web/hooks/use-format-time-from-now.ts +++ b/web/hooks/use-format-time-from-now.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { useCallback } from 'react' -import { useI18N } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import { localeMap } from '@/i18n-config/language' import 'dayjs/locale/de' import 'dayjs/locale/es' @@ -27,7 +27,7 @@ import 'dayjs/locale/zh-tw' dayjs.extend(relativeTime) export const useFormatTimeFromNow = () => { - const { locale } = useI18N() + const locale = useLocale() const formatTimeFromNow = useCallback((time: number) => { const dayjsLocale = localeMap[locale] ?? 'en' return dayjs(time).locale(dayjsLocale).fromNow() diff --git a/web/i18n-config/DEV.md b/web/i18n-config/DEV.md index c40591a9e3..41a7bec19d 100644 --- a/web/i18n-config/DEV.md +++ b/web/i18n-config/DEV.md @@ -7,7 +7,7 @@ - useTranslation - useGetLanguage -- useI18N +- useLocale - useRenderI18nObject ## impl @@ -46,6 +46,6 @@ ## TODO - [ ] ts docs for useGetLanguage -- [ ] ts docs for useI18N +- [ ] ts docs for useLocale - [ ] client docs for i18n - [ ] server docs for i18n diff --git a/web/i18n-config/i18next-config.ts b/web/i18n-config/i18next-config.ts index 7bd2de5b39..107954a384 100644 --- a/web/i18n-config/i18next-config.ts +++ b/web/i18n-config/i18next-config.ts @@ -2,7 +2,6 @@ import type { Locale } from '.' import { camelCase, kebabCase } from 'es-toolkit/compat' import i18n from 'i18next' - import { initReactI18next } from 'react-i18next' import appAnnotation from '../i18n/en-US/app-annotation.json' import appApi from '../i18n/en-US/app-api.json' diff --git a/web/i18n-config/server.ts b/web/i18n-config/server.ts index cb2519e69a..91cb2f2a6d 100644 --- a/web/i18n-config/server.ts +++ b/web/i18n-config/server.ts @@ -1,3 +1,4 @@ +import type { i18n as I18nInstance } from 'i18next' import type { Locale } from '.' import type { NamespaceCamelCase, NamespaceKebabCase } from './i18next-config' import { match } from '@formatjs/intl-localematcher' @@ -7,29 +8,39 @@ import resourcesToBackend from 'i18next-resources-to-backend' import Negotiator from 'negotiator' import { cookies, headers } from 'next/headers' import { initReactI18next } from 'react-i18next/initReactI18next' +import serverOnlyContext from '@/utils/server-only-context' import { i18n } from '.' -// https://locize.com/blog/next-13-app-dir-i18n/ -const initI18next = async (lng: Locale, ns: NamespaceKebabCase) => { - const i18nInstance = createInstance() - await i18nInstance +const [getLocaleCache, setLocaleCache] = serverOnlyContext(null) +const [getI18nInstance, setI18nInstance] = serverOnlyContext(null) + +const getOrCreateI18next = async (lng: Locale) => { + let instance = getI18nInstance() + if (instance) + return instance + + instance = createInstance() + await instance .use(initReactI18next) .use(resourcesToBackend((language: Locale, namespace: NamespaceKebabCase) => { return import(`../i18n/${language}/${namespace}.json`) })) .init({ - lng: lng === 'zh-Hans' ? 'zh-Hans' : lng, - ns, - defaultNS: ns, + lng, fallbackLng: 'en-US', keySeparator: false, }) - return i18nInstance + setI18nInstance(instance) + return instance } export async function getTranslation(lng: Locale, ns: NamespaceKebabCase) { const camelNs = camelCase(ns) as NamespaceCamelCase - const i18nextInstance = await initI18next(lng, ns) + const i18nextInstance = await getOrCreateI18next(lng) + + if (!i18nextInstance.hasLoadedNamespace(camelNs)) + await i18nextInstance.loadNamespaces(camelNs) + return { t: i18nextInstance.getFixedT(lng, camelNs), i18n: i18nextInstance, @@ -37,6 +48,10 @@ export async function getTranslation(lng: Locale, ns: NamespaceKebabCase) { } export const getLocaleOnServer = async (): Promise => { + const cached = getLocaleCache() + if (cached) + return cached + const locales: string[] = i18n.locales let languages: string[] | undefined @@ -58,5 +73,6 @@ export const getLocaleOnServer = async (): Promise => { // match locale const matchedLocale = match(languages, locales, i18n.defaultLocale) as Locale + setLocaleCache(matchedLocale) return matchedLocale } diff --git a/web/package.json b/web/package.json index 317502cb66..300b9b450a 100644 --- a/web/package.json +++ b/web/package.json @@ -92,6 +92,7 @@ "i18next": "^25.7.3", "i18next-resources-to-backend": "^1.2.1", "immer": "^11.1.0", + "jotai": "^2.16.1", "js-audio-recorder": "^1.0.7", "js-cookie": "^3.0.5", "js-yaml": "^4.1.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a2d3debc3c..3c4f881fdf 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -192,6 +192,9 @@ importers: immer: specifier: ^11.1.0 version: 11.1.0 + jotai: + specifier: ^2.16.1 + version: 2.16.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.7)(react@19.2.3) js-audio-recorder: specifier: ^1.0.7 version: 1.0.7 @@ -6207,6 +6210,24 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jotai@2.16.1: + resolution: {integrity: sha512-vrHcAbo3P7Br37C8Bv6JshMtlKMPqqmx0DDREtTjT4nf3QChDrYdbH+4ik/9V0cXA57dK28RkJ5dctYvavcIlg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': ~19.2.7 + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-audio-recorder@1.0.7: resolution: {integrity: sha512-JiDODCElVHGrFyjGYwYyNi7zCbKk9va9C77w+zCPMmi4C6ix7zsX2h3ddHugmo4dOTOTCym9++b/wVW9nC0IaA==} @@ -15331,6 +15352,13 @@ snapshots: jiti@2.6.1: {} + jotai@2.16.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.7)(react@19.2.3): + optionalDependencies: + '@babel/core': 7.28.5 + '@babel/template': 7.27.2 + '@types/react': 19.2.7 + react: 19.2.3 + js-audio-recorder@1.0.7: {} js-base64@3.7.8: {} diff --git a/web/utils/server-only-context.ts b/web/utils/server-only-context.ts new file mode 100644 index 0000000000..e58dbfe98b --- /dev/null +++ b/web/utils/server-only-context.ts @@ -0,0 +1,15 @@ +// credit: https://github.com/manvalls/server-only-context/blob/main/src/index.ts + +import { cache } from 'react' + +export default (defaultValue: T): [() => T, (v: T) => void] => { + const getRef = cache(() => ({ current: defaultValue })) + + const getValue = (): T => getRef().current + + const setValue = (value: T) => { + getRef().current = value + } + + return [getValue, setValue] +}