From 6ff20b98f46c449087040d71bea4c9a1fc6bde45 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Fri, 1 Aug 2025 09:30:28 -0400 Subject: [PATCH] fix: refactor i18n `loadTranslations` and set timeout to 3s (#22633) * refactor * use abort controller * address comment * fix tests * fix time * fix tests --- .../test/lib/handleChildrenEventTypes.test.ts | 7 +- .../ee/billing/credit-service.test.ts | 11 +++ .../createOrganizationFromOnboarding.test.ts | 11 +++ .../lib/reminders/reminderScheduler.test.ts | 10 +++ .../compareReminderBodyToTemplate.test.ts | 13 +++- packages/lib/fetchWithTimeout.ts | 13 ++++ packages/lib/server/i18n.ts | 70 ++++++------------- packages/lib/server/repository/user.test.ts | 7 +- .../service/userCreationService.test.ts | 7 +- 9 files changed, 96 insertions(+), 53 deletions(-) create mode 100644 packages/lib/fetchWithTimeout.ts diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 6f24f398cb..ef488d7e98 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => { vi.mock("@calcom/lib/server/i18n", () => { return { - getTranslation: (key: string) => key, + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; + }, }; }); diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts index 927a7dfb2f..917b3fae8a 100644 --- a/packages/features/ee/billing/credit-service.test.ts +++ b/packages/features/ee/billing/credit-service.test.ts @@ -25,6 +25,17 @@ vi.mock("@calcom/prisma", () => { vi.mock("stripe"); +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; + }, + }; +}); + vi.mock("@calcom/lib/constants", async () => { const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants"); return { diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts index f13fd58ea0..4b8d3fb6af 100644 --- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts +++ b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts @@ -43,6 +43,17 @@ vi.mock("@calcom/lib/domainManager/organization", () => ({ createDomain: vi.fn(), })); +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; + }, + }; +}); + const mockOrganizationOnboarding = { name: "Test Org", slug: "test-org", diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts index d8c8179b7e..aa8549db6a 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts @@ -17,6 +17,16 @@ vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/emailProvider", ( sendOrScheduleWorkflowEmails: vi.fn(), })); +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; + }, + }; +}); describe("reminderScheduler", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/features/ee/workflows/lib/test/compareReminderBodyToTemplate.test.ts b/packages/features/ee/workflows/lib/test/compareReminderBodyToTemplate.test.ts index 6371b00b8b..641cddfb9a 100644 --- a/packages/features/ee/workflows/lib/test/compareReminderBodyToTemplate.test.ts +++ b/packages/features/ee/workflows/lib/test/compareReminderBodyToTemplate.test.ts @@ -1,5 +1,5 @@ import { createInstance } from "i18next"; -import { expect, test, describe } from "vitest"; +import { vi, expect, test, describe } from "vitest"; import { getTranslation } from "@calcom/lib/server/i18n"; import { TimeFormat } from "@calcom/lib/timeFormat"; @@ -10,6 +10,17 @@ import { getTemplateBodyForAction } from "../actionHelperFunctions"; import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate"; import plainTextReminderTemplates from "../reminders/templates/plainTextTemplates"; +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; + }, + }; +}); + const translation = async () => { const _i18n = createInstance(); await _i18n.init({ diff --git a/packages/lib/fetchWithTimeout.ts b/packages/lib/fetchWithTimeout.ts new file mode 100644 index 0000000000..ecb0646e26 --- /dev/null +++ b/packages/lib/fetchWithTimeout.ts @@ -0,0 +1,13 @@ +export async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + return response; + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/lib/server/i18n.ts b/packages/lib/server/i18n.ts index 3e092b3309..06000e2e77 100644 --- a/packages/lib/server/i18n.ts +++ b/packages/lib/server/i18n.ts @@ -1,39 +1,14 @@ import { createInstance } from "i18next"; +import { i18n } from "@calcom/config/next-i18next.config"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { fetchWithTimeout } from "../fetchWithTimeout"; +import logger from "../logger"; + const translationCache = new Map>(); const i18nInstanceCache = new Map(); - -/** - * Loads English fallback translations for when requested locale translations fail - * Implements caching to avoid redundant network requests - * @returns {Promise>} English translations object or empty object on failure - */ -async function loadFallbackTranslations() { - 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 {}; - } -} +const SUPPORTED_NAMESPACES = ["common"]; /** * Loads translations for a specific locale and namespace with optimized caching @@ -41,32 +16,33 @@ async function loadFallbackTranslations() { * @param {string} ns - The namespace for the translations * @returns {Promise>} Translations object or fallback translations on failure */ -export async function loadTranslations(_locale: string, ns: string) { - const locale = _locale === "zh" ? "zh-CN" : _locale; +export async function loadTranslations(_locale: string, _ns: string) { + let locale = _locale === "zh" ? "zh-CN" : _locale; + locale = i18n.locales.includes(locale) ? locale : "en"; + const ns = SUPPORTED_NAMESPACES.includes(_ns) ? _ns : "common"; 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, { + const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`; + const response = await fetchWithTimeout( + url, + { cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store", - }); + }, + 3000 + ); - 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; + if (!response.ok) { + logger.error(`Failed to fetch translations: ${response.status}`); + return {}; } + + const translations = await response.json(); + translationCache.set(cacheKey, translations); + return translations; } /** diff --git a/packages/lib/server/repository/user.test.ts b/packages/lib/server/repository/user.test.ts index c4a9ef03b9..87c19764ce 100644 --- a/packages/lib/server/repository/user.test.ts +++ b/packages/lib/server/repository/user.test.ts @@ -9,8 +9,11 @@ import { UserRepository } from "./user"; vi.mock("@calcom/lib/server/i18n", () => { return { - getTranslation: (key: string) => { - return () => key; + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; }, }; }); diff --git a/packages/lib/server/service/userCreationService.test.ts b/packages/lib/server/service/userCreationService.test.ts index 098fd7649c..006af47701 100644 --- a/packages/lib/server/service/userCreationService.test.ts +++ b/packages/lib/server/service/userCreationService.test.ts @@ -11,8 +11,11 @@ import { UserCreationService } from "./userCreationService"; vi.mock("@calcom/lib/server/i18n", () => { return { - getTranslation: (key: string) => { - return () => key; + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; }, }; });