perf: make useLocale hook to use server-fetched i18n if in App router context (#20597)

* write I18nProvider

* rename

* update useLocale

* fix

* use a singleton

* safer

* better

* fix e2e test 1

* fix e2e test 2

* fix e2e test 3

* fix e2e test 4

* fix

* refactor

* address comments

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix flaky test

* add more caching

* revert changes to e2e tests

* add jsdoc

* memoize

* update window object with new translations in /settings/my-account/general

* remove dead code

* only use locale and ns as deps

* fix
This commit is contained in:
Benny Joo
2025-04-11 14:28:26 -07:00
committed by GitHub
parent 9eb1fe06e3
commit b17b5a169f
7 changed files with 184 additions and 103 deletions
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { createContext, useMemo } from "react";
import type { ReactNode } from "react";
type AppRouterI18nContextType = {
translations: Record<string, string>;
ns: string;
locale: string;
};
export const AppRouterI18nContext = createContext<AppRouterI18nContextType | null>(null);
export function AppRouterI18nProvider({
children,
translations,
locale,
ns,
}: AppRouterI18nContextType & {
children: ReactNode;
}) {
// Memoize the value to prevent re-renders unless the data changes
const value = useMemo(
() => ({
translations,
locale,
ns,
}),
[locale, ns]
);
return <AppRouterI18nContext.Provider value={value}>{children}</AppRouterI18nContext.Provider>;
}
+17 -3
View File
@@ -5,10 +5,12 @@ import { headers, cookies } from "next/headers";
import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { IconSprites } from "@calcom/ui/components/icon";
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
import "../styles/globals.css";
import { AppRouterI18nProvider } from "./AppRouterI18nProvider";
import { SpeculationRules } from "./SpeculationRules";
import { Providers } from "./providers";
@@ -83,10 +85,15 @@ const getInitialProps = async (url: string) => {
const embedColorScheme = searchParams?.get("ui.color-scheme");
const req = { headers: await headers(), cookies: await cookies() };
const newLocale = await getLocale(req);
const newLocale = (await getLocale(req)) ?? "en";
const direction = dir(newLocale);
return { isEmbed, embedColorScheme, locale: newLocale, direction };
return {
isEmbed,
embedColorScheme,
locale: newLocale,
direction,
};
};
const getFallbackProps = () => ({
@@ -107,6 +114,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const { locale, direction, isEmbed, embedColorScheme } = isSSG
? getFallbackProps()
: await getInitialProps(fullUrl);
const ns = "common";
const translations = await loadTranslations(locale, ns);
return (
<html
@@ -153,7 +162,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
"/insights",
]}
/>
<Providers>{children}</Providers>
<Providers>
<AppRouterI18nProvider translations={translations} locale={locale} ns={ns}>
{children}
</AppRouterI18nProvider>
</Providers>
{!isEmbed && <NotificationSoundHandler />}
<NotificationSoundHandler />
</body>
@@ -1,7 +1,5 @@
"use client";
import type { SSRConfig } from "next-i18next/dist/types/types";
// import I18nLanguageHandler from "@components/I18nLanguageHandler";
import { usePathname } from "next/navigation";
import Script from "next/script";
@@ -16,7 +14,6 @@ export type PageWrapperProps = Readonly<{
requiresLicense: boolean;
nonce: string | undefined;
isBookingPage?: boolean;
i18n?: SSRConfig;
}>;
function PageWrapper(props: PageWrapperProps) {
@@ -45,7 +42,6 @@ function PageWrapper(props: PageWrapperProps) {
return (
<AppProviders {...providerProps}>
{/* <I18nLanguageHandler locales={props.router.locales || []} /> */}
<>
<Script
nonce={nonce}
+12 -77
View File
@@ -1,16 +1,12 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { useSession } from "next-auth/react";
import { EventCollectionProvider } from "next-collect/client";
import { appWithTranslation } from "next-i18next";
import type { SSRConfig } from "next-i18next/dist/types/types";
import { ThemeProvider } from "next-themes";
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
import type { AppProps as NextAppProps } from "next/app";
import type { ReadonlyURLSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect } from "react";
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
@@ -23,17 +19,10 @@ import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
import type { WithLocaleProps } from "@lib/withLocale";
import type { WithNonceProps } from "@lib/withNonce";
import { useViewerI18n } from "@components/I18nLanguageHandler";
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
import { getThemeProviderProps } from "./getThemeProviderProps";
const I18nextAdapter = appWithTranslation<
NextJsAppProps<SSRConfig> & {
children: React.ReactNode;
}
>(({ children }) => <>{children}</>);
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<
NextAppProps<
@@ -41,7 +30,6 @@ export type AppProps = Omit<
WithNonceProps<{
themeBasis?: string;
session: Session;
i18n?: SSRConfig;
}>
>
>,
@@ -64,57 +52,6 @@ const getEmbedNamespace = (searchParams: ReadonlyURLSearchParams) => {
return typeof window !== "undefined" ? window.getEmbedNamespace() : searchParams.get("embed") ?? null;
};
const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSRConfig }) => {
/**
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
**/
const session = useSession();
const fallbackLocale =
typeof window !== "undefined" && window.document.documentElement.lang
? window.document.documentElement.lang
: "en";
const newLocale = typeof window !== "undefined" && window.calNewLocale ? window.calNewLocale : null;
const locale = session?.data?.user.locale ?? newLocale ?? fallbackLocale;
useEffect(() => {
try {
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
delete window.document.documentElement["lang"];
window.document.documentElement.lang = locale;
// Next.js writes the locale to the same attribute
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
// which can result in a race condition
// this property descriptor ensures this never happens
Object.defineProperty(window.document.documentElement, "lang", {
configurable: true,
// value: locale,
set: function (this) {
// empty setter on purpose
},
get: function () {
return locale;
},
});
} catch (error) {
console.error(error);
window.document.documentElement.lang = locale;
}
window.document.dir = dir(locale);
}, [locale]);
const clientViewerI18n = useViewerI18n(locale);
const i18n = clientViewerI18n.data?.i18n ?? props.i18n;
return (
// @ts-expect-error AppWithTranslationHoc expects AppProps
<I18nextAdapter pageProps={{ _nextI18Next: i18n?._nextI18Next }}>{props.children}</I18nextAdapter>
);
};
type CalcomThemeProps = Readonly<{
isBookingPage: boolean;
nonce: string | undefined;
@@ -178,19 +115,17 @@ const AppProviders = (props: PageWrapperProps) => {
const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
nonce={props.nonce}
isThemeSupported={isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>{props.children}</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
nonce={props.nonce}
isThemeSupported={isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>{props.children}</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</EventCollectionProvider>
);
+56 -7
View File
@@ -1,18 +1,24 @@
import { createInstance } from "i18next";
import type { TFunction, i18n } from "i18next";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext";
import { AppRouterI18nContext } from "@calcom/web/app/AppRouterI18nProvider";
export const useLocale = (namespace: Parameters<typeof useTranslation>[0] = "common") => {
type useLocaleReturnType = {
i18n: i18n;
t: TFunction;
isLocaleReady: boolean;
};
// @internal
const useClientLocale = (namespace: Parameters<typeof useTranslation>[0] = "common"): useLocaleReturnType => {
const context = useAtomsContext();
const { i18n, t } = useTranslation(namespace);
const isLocaleReady = Object.keys(i18n).length > 0;
if (context?.clientId) {
return { i18n: context.i18n, t: context.t, isLocaleReady: true } as unknown as {
i18n: ReturnType<typeof useTranslation>["i18n"];
t: ReturnType<typeof useTranslation>["t"];
isLocaleReady: boolean;
};
return { i18n: context.i18n, t: context.t, isLocaleReady: true } as unknown as useLocaleReturnType;
}
return {
i18n,
@@ -20,3 +26,46 @@ export const useLocale = (namespace: Parameters<typeof useTranslation>[0] = "com
isLocaleReady,
};
};
// @internal
const serverI18nInstances = new Map();
export const useLocale = (): useLocaleReturnType => {
const appRouterContext = useContext(AppRouterI18nContext);
const clientI18n = useClientLocale();
if (appRouterContext) {
const { translations, locale, ns } = appRouterContext;
const instanceKey = `${locale}-${ns}`;
// Check if we already have an instance for this locale and namespace
if (!serverI18nInstances.has(instanceKey)) {
const i18n = createInstance();
i18n.init({
lng: locale,
resources: {
[locale]: {
[ns]: translations,
},
},
});
serverI18nInstances.set(instanceKey, {
t: i18n.getFixedT(locale, ns),
isLocaleReady: true,
i18n,
});
}
return serverI18nInstances.get(instanceKey);
}
console.warn(
"useLocale hook is being used outside of App Router - hence this hook will use a global, client-side i18n which can cause a small flicker"
);
return {
t: clientI18n.t,
isLocaleReady: clientI18n.isLocaleReady,
i18n: clientI18n.i18n,
};
};
+61 -12
View File
@@ -2,31 +2,79 @@ import { createInstance } from "i18next";
import { WEBAPP_URL } from "@calcom/lib/constants";
const translationCache = new Map<string, Record<string, string>>();
const i18nInstanceCache = new Map<string, any>();
/**
* Loads English fallback translations for when requested locale translations fail
* Implements caching to avoid redundant network requests
* @returns {Promise<Record<string, string>>} English translations object or empty object on failure
*/
async function loadFallbackTranslations() {
try {
const res = await fetch(`${WEBAPP_URL}/static/locales/en/common.json`);
if (res) {
return await res.json();
}
} catch {
console.error("Could not fetch fallback translations.");
const cacheKey = "en-common";
if (translationCache.has(cacheKey)) {
return translationCache.get(cacheKey);
}
try {
const res = await fetch(`${WEBAPP_URL}/static/locales/en/common.json`, {
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
});
if (!res.ok) {
throw new Error(`Failed to fetch fallback translations: ${res.status}`);
}
const translations = await res.json();
translationCache.set(cacheKey, translations);
return translations;
} catch (error) {
console.error("Could not fetch fallback translations:", error);
return {};
}
return;
}
async function loadTranslations(locale: string, ns: string) {
/**
* Loads translations for a specific locale and namespace with optimized caching
* @param {string} _locale - The locale code (e.g., 'en', 'fr', 'zh')
* @param {string} ns - The namespace for the translations
* @returns {Promise<Record<string, string>>} Translations object or fallback translations on failure
*/
export async function loadTranslations(_locale: string, ns: string) {
const locale = _locale === "zh" ? "zh-CN" : _locale;
const cacheKey = `${locale}-${ns}`;
if (translationCache.has(cacheKey)) {
return translationCache.get(cacheKey);
}
try {
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
const response = await fetch(url);
return await response.json();
const response = await fetch(url, {
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
});
if (!response.ok) {
throw new Error(`Failed to fetch translations: ${response.status}`);
}
const translations = await response.json();
translationCache.set(cacheKey, translations);
return translations;
} catch (error) {
console.warn(`Failed to load translations for ${locale}/${ns}, falling back to English:`, error);
const fallbackTranslations = await loadFallbackTranslations();
return fallbackTranslations ?? {};
return fallbackTranslations;
}
}
/**
* Creates or retrieves a cached i18next translation function for the specified locale and namespace
* @param {string} locale - The locale code (e.g., 'en', 'fr')
* @param {string} ns - The namespace for the translations
* @returns {Promise<Function>} A translation function bound to the specified locale and namespace
*/
export const getTranslation = async (locale: string, ns: string) => {
const cacheKey = `${locale}-${ns}`;
if (i18nInstanceCache.has(cacheKey)) {
@@ -46,6 +94,7 @@ export const getTranslation = async (locale: string, ns: string) => {
fallbackLng: "en",
});
// Cache the i18n instance
i18nInstanceCache.set(cacheKey, _i18n);
return _i18n.getFixedT(locale, ns);
};
@@ -32,6 +32,11 @@ export function UnpublishedEntity(props: UnpublishedEntityProps) {
avatar={<Avatar alt={slug ?? ""} imageSrc={getPlaceholderAvatar(props.logoUrl, slug)} size="lg" />}
headline={t("team_is_unpublished", {
team: props.name,
interpolation: {
// This is needed because React automatically escapes special characters like apostrophes
// for security (XSS prevention), but we want to display the actual apostrophe in the UI
escapeValue: false,
},
})}
description={t(`${props.orgSlug ? "org" : "team"}_is_unpublished_description`)}
/>