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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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`)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user