From 7485b3d71f49fe21a9d64352f19c3d2ba33709d2 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 1 Sep 2026 18:30:05 +0200 Subject: [PATCH] Use a dedicated i18next instance instead of the global singleton Element Call configured the global i18next singleton. When Element Call runs embedded in a host application rather than as its own page, that singleton belongs to the host, so configuring it would clobber the host's translations. Create Element Call's own instance in utils/i18n.ts, configure it in the initializer, and pass it to components via . Drop .use(initReactI18next) from the initializer: it registers the instance as react-i18next's global default, which is the global we are trying to avoid. Tests and stories keep using it, so that they do not need to wrap every render in a provider. Two modules imported `t` directly from "i18next" and so were bound to the global instance: utils/errors.ts now calls i18n.t() on the instance (reached at call time, since i18next only assigns `t` during init), and QrCode uses useTranslation() like every other component. No functional change. --- .storybook/preview.tsx | 6 ++++-- src/App.tsx | 34 +++++++++++++++++-------------- src/QrCode.tsx | 3 ++- src/initializer.tsx | 8 +++++--- src/utils/errors.ts | 45 +++++++++++++++++++++--------------------- src/utils/i18n.ts | 21 ++++++++++++++++++++ src/vitest.setup.ts | 6 ++++-- 7 files changed, 77 insertions(+), 46 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 757c1f8a7..5b841b829 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details. import type { Preview } from "@storybook/react-vite"; import { TooltipProvider } from "@vector-im/compound-web"; -import i18n from "i18next"; import { logger } from "matrix-js-sdk/lib/logger"; import EN from "../locales/en/app.json"; import { initReactI18next } from "react-i18next"; +import { i18n } from "../src/utils/i18n"; import "../src/index.css"; -// Bare-minimum i18n config +// Bare-minimum i18n config. +// Unlike the app, stories register the instance as react-i18next's default +// rather than wrapping every story in an . i18n .use(initReactI18next) .init({ diff --git a/src/App.tsx b/src/App.tsx index 8f6ef21a1..36afc4c2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/lib/logger"; +import { I18nextProvider } from "react-i18next"; import { HomePage } from "./home/HomePage"; import { LoginPage } from "./auth/LoginPage"; @@ -32,6 +33,7 @@ import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { AppBar } from "./AppBar"; +import { i18n } from "./utils/i18n"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -98,20 +100,22 @@ export const App: FC = ({ vm }) => { ); return ( - - - - - - {header === HeaderStyle.AppBar ? ( - {content} - ) : ( - content - )} - - - - - + + + + + + + {header === HeaderStyle.AppBar ? ( + {content} + ) : ( + content + )} + + + + + + ); }; diff --git a/src/QrCode.tsx b/src/QrCode.tsx index 09bd92ea7..da5693957 100644 --- a/src/QrCode.tsx +++ b/src/QrCode.tsx @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { type FC, useEffect, useState } from "react"; import { toDataURL } from "qrcode"; import classNames from "classnames"; -import { t } from "i18next"; +import { useTranslation } from "react-i18next"; import styles from "./QrCode.module.css"; @@ -18,6 +18,7 @@ interface Props { } export const QrCode: FC = ({ data, className }) => { + const { t } = useTranslation(); const [url, setUrl] = useState(null); useEffect(() => { diff --git a/src/initializer.tsx b/src/initializer.tsx index 91436d100..c0e8407ec 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details. */ import React from "react"; -import i18n, { +import { type BackendModule, type ReadCallback, type ResourceKey, } from "i18next"; -import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import * as Sentry from "@sentry/react"; import { logger } from "matrix-js-sdk/lib/logger"; @@ -35,6 +34,7 @@ import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; +import { i18n } from "./utils/i18n.ts"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // { @@ -148,10 +148,12 @@ export class Initializer { document.documentElement.lang = lng; }); + // Note: deliberately no `.use(initReactI18next)` — that would register this + // instance as react-i18next's global default, which is the very global we + // are avoiding. Components receive it through `` instead. await i18n .use(Backend) .use(languageDetector) - .use(initReactI18next) .init({ fallbackLng: "en", defaultNS: "app", diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 0ac569278..73b904c95 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { t } from "i18next"; import { type ConnectionError } from "livekit-client"; -import { i18nKey } from "./i18n"; +import { i18n, i18nKey } from "./i18n"; export enum ErrorCode { /** @@ -78,10 +77,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError { */ public constructor(domain: string) { super( - t("error.call_is_not_supported"), + i18n.t("error.call_is_not_supported"), ErrorCode.MISSING_MATRIX_RTC_TRANSPORT, ErrorCategory.CONFIGURATION_ISSUE, - t("error.matrix_rtc_transport_missing", { + i18n.t("error.matrix_rtc_transport_missing", { domain, brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", errorCode: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT, @@ -97,10 +96,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError { export class ConnectionLostError extends ElementCallError { public constructor() { super( - t("error.connection_lost"), + i18n.t("error.connection_lost"), ErrorCode.CONNECTION_LOST_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, - t("error.connection_lost_description"), + i18n.t("error.connection_lost_description"), ); } } @@ -117,10 +116,10 @@ export class MembershipManagerError extends ElementCallError { */ public constructor(error: Error) { super( - t("error.membership_manager"), + i18n.t("error.membership_manager"), ErrorCode.INTERNAL_MEMBERSHIP_MANAGER, ErrorCategory.SYSTEM_FAILURE, - t("error.membership_manager_description"), + i18n.t("error.membership_manager_description"), error, ); } @@ -134,10 +133,10 @@ export class MembershipManagerError extends ElementCallError { export class StickyEventsRequiredError extends ElementCallError { public constructor() { super( - t("error.sticky_events_required"), + i18n.t("error.sticky_events_required"), ErrorCode.STICKY_EVENTS_NOT_SUPPORTED, ErrorCategory.CONFIGURATION_ISSUE, - t("error.sticky_events_required_description"), + i18n.t("error.sticky_events_required_description"), ); } } @@ -148,10 +147,10 @@ export class StickyEventsRequiredError extends ElementCallError { export class E2EENotSupportedError extends ElementCallError { public constructor() { super( - t("error.e2ee_unsupported"), + i18n.t("error.e2ee_unsupported"), ErrorCode.E2EE_NOT_SUPPORTED, ErrorCategory.CLIENT_CONFIGURATION, - t("error.e2ee_unsupported_description"), + i18n.t("error.e2ee_unsupported_description"), ); } } @@ -166,7 +165,7 @@ export class UnknownCallError extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.UNKNOWN_ERROR, ErrorCategory.UNKNOWN, undefined, @@ -186,7 +185,7 @@ export class FailToGetOpenIdToken extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.OPEN_ID_ERROR, ErrorCategory.CONFIGURATION_ISSUE, undefined, @@ -203,10 +202,10 @@ export class NoMatrix2AuthorizationService extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE, ErrorCategory.CONFIGURATION_ISSUE, - t("error.no_matrix_2_authorization_service"), + i18n.t("error.no_matrix_2_authorization_service"), // Properly set it as a cause for a better reporting on sentry error, ); @@ -223,7 +222,7 @@ export class FailToStartLivekitConnection extends ElementCallError { */ public constructor(e?: string) { super( - t("error.failed_to_start_livekit"), + i18n.t("error.failed_to_start_livekit"), ErrorCode.FAILED_TO_START_LIVEKIT, ErrorCategory.NETWORK_CONNECTIVITY, e, @@ -237,10 +236,10 @@ export class FailToStartLivekitConnection extends ElementCallError { export class InsufficientCapacityError extends ElementCallError { public constructor() { super( - t("error.insufficient_capacity"), + i18n.t("error.insufficient_capacity"), ErrorCode.INSUFFICIENT_CAPACITY_ERROR, ErrorCategory.UNKNOWN, - t("error.insufficient_capacity_description"), + i18n.t("error.insufficient_capacity_description"), ); } } @@ -252,10 +251,10 @@ export class InsufficientCapacityError extends ElementCallError { export class SFURoomCreationRestrictedError extends ElementCallError { public constructor() { super( - t("error.room_creation_restricted"), + i18n.t("error.room_creation_restricted"), ErrorCode.SFU_ERROR, ErrorCategory.CONFIGURATION_ISSUE, - t("error.room_creation_restricted_description"), + i18n.t("error.room_creation_restricted_description"), ); } } @@ -266,7 +265,7 @@ export class SFURoomCreationRestrictedError extends ElementCallError { export class PeerConnectionTimeoutError extends ElementCallError { public constructor() { super( - t("error.peer_connection_timeout"), + i18n.t("error.peer_connection_timeout"), ErrorCode.SFU_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, ); @@ -283,7 +282,7 @@ export class PeerConnectionTimeoutError extends ElementCallError { export class LivekitConnectionError extends ElementCallError { public constructor(cause: ConnectionError) { super( - t("error.livekit_connection_error"), + i18n.t("error.livekit_connection_error"), ErrorCode.SFU_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, ); diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 51bf2fb63..abe28f54f 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -5,5 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import i18next, { type i18n as I18nInstance } from "i18next"; + // Custom marker function to allow i18next extraction export const i18nKey = (key: string): string => key; + +/** + * Element Call's own i18next instance. + * + * We deliberately do not use the global i18next singleton: when Element Call is + * embedded in a host application (rather than running as its own page), that + * singleton belongs to the host, and configuring it would clobber the host's + * translations. + * + * It is configured by `Initializer.initBeforeReact` and made available to + * components via ``; tests and stories configure it directly. + * + * Non-React code should call `i18n.t(...)` on this instance. Components should + * use `useTranslation()` instead, so that they re-render on a language change. + * Note that `t` must be reached through the instance at call time — i18next + * only assigns it during `init()`, so destructuring it at module scope would + * capture an uninitialised function. + */ +export const i18n: I18nInstance = i18next.createInstance(); diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index be7179de4..00d289b54 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details. import "@formatjs/intl-durationformat/polyfill.js"; import "@formatjs/intl-segmenter/polyfill"; -import i18n from "i18next"; import posthog from "posthog-js"; import { initReactI18next } from "react-i18next"; import { afterEach } from "vitest"; @@ -18,8 +17,11 @@ import "@testing-library/jest-dom/vitest"; import EN from "../locales/en/app.json"; import { Config } from "./config/Config"; +import { i18n } from "./utils/i18n"; -// Bare-minimum i18n config +// Bare-minimum i18n config. +// Unlike the app, tests register the instance as react-i18next's default rather +// than wrapping every render in an . i18n .use(initReactI18next) .init({