Compare commits

...

1 Commits

Author SHA1 Message Date
Dhruwang
ac208e8722 fix: lazy load surveys translations 2026-03-09 19:29:02 +05:30
6 changed files with 121 additions and 73 deletions

View File

@@ -5,7 +5,7 @@ import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LanguageIcon } from "@/components/icons/language-icon";
import { mixColor } from "@/lib/color";
import { getI18nLanguage } from "@/lib/i18n-utils";
import i18n from "@/lib/i18n.config";
import i18n, { loadLanguage } from "@/lib/i18n.config";
import { getLanguageDisplayName } from "@/lib/language-display-name";
import { useClickOutside } from "@/lib/use-click-outside-hook";
import { cn, isRTLLanguage } from "@/lib/utils";
@@ -47,9 +47,11 @@ export function LanguageSwitch({
const handleI18nLanguage = (languageCode: string) => {
const calculatedLanguage = getI18nLanguage(languageCode, surveyLanguages);
if (i18n.language !== calculatedLanguage) {
i18n.changeLanguage(calculatedLanguage);
}
loadLanguage(calculatedLanguage).then(() => {
if (i18n.language !== calculatedLanguage) {
i18n.changeLanguage(calculatedLanguage);
}
});
};
const changeLanguage = (languageCode: string) => {

View File

@@ -1,22 +1,42 @@
import { ComponentChildren } from "preact";
import { useEffect } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { I18nextProvider } from "react-i18next";
import i18n from "../../lib/i18n.config";
import i18n, { loadLanguage } from "../../lib/i18n.config";
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
// Set language synchronously on initial render so children get the correct translations immediately.
// This is safe because all translations are pre-loaded (bundled) in i18n.config.ts.
if (i18n.language !== language) {
// If translations are already available (English or previously loaded), set language synchronously
const alreadyLoaded = language === "en" || i18n.hasResourceBundle(language, "translation");
if (alreadyLoaded && i18n.language !== language) {
i18n.changeLanguage(language);
}
// Handle language prop changes after initial render
const [isReady, setIsReady] = useState(alreadyLoaded);
useEffect(() => {
if (i18n.language !== language) {
i18n.changeLanguage(language);
if (i18n.hasResourceBundle(language, "translation")) {
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
setIsReady(true);
return;
}
let cancelled = false;
setIsReady(false);
loadLanguage(language).then(() => {
if (!cancelled) {
i18n.changeLanguage(language);
setIsReady(true);
}
});
return () => {
cancelled = true;
};
}, [language]);
if (!isReady) return null;
// work around for react-i18next not supporting preact
return <I18nextProvider i18n={i18n}>{children as unknown as React.ReactNode}</I18nextProvider>;
};

View File

@@ -4,6 +4,7 @@ import { RenderSurvey } from "@/components/general/render-survey";
import { I18nProvider } from "@/components/i18n/provider";
import { FILE_PICK_EVENT } from "@/lib/constants";
import { getI18nLanguage } from "@/lib/i18n-utils";
import { setLocaleBaseUrl } from "@/lib/i18n.config";
import { addCustomThemeToDom, addStylesToDom, setStyleNonce } from "@/lib/styles";
export const renderSurveyInline = (props: SurveyContainerProps) => {
@@ -19,8 +20,9 @@ export const renderSurvey = (props: SurveyContainerProps) => {
// render SurveyNew
// if survey type is link, we don't pass the placement, overlay, clickOutside, onClose
const { mode, containerId, languageCode } = props;
const { mode, containerId, languageCode, appUrl } = props;
setLocaleBaseUrl(`${appUrl || ""}/js/locales`);
addStylesToDom();
addCustomThemeToDom({ styling: props.styling });

View File

@@ -1,70 +1,77 @@
import i18n from "i18next";
import ICU from "i18next-icu";
import { initReactI18next } from "react-i18next";
import arTranslations from "../../locales/ar.json";
import daTranslations from "../../locales/da.json";
import deTranslations from "../../locales/de.json";
import enTranslations from "../../locales/en.json";
import esTranslations from "../../locales/es.json";
import frTranslations from "../../locales/fr.json";
import hiTranslations from "../../locales/hi.json";
import huTranslations from "../../locales/hu.json";
import itTranslations from "../../locales/it.json";
import jaTranslations from "../../locales/ja.json";
import nlTranslations from "../../locales/nl.json";
import ptTranslations from "../../locales/pt.json";
import roTranslations from "../../locales/ro.json";
import ruTranslations from "../../locales/ru.json";
import svTranslations from "../../locales/sv.json";
import uzTranslations from "../../locales/uz.json";
import zhHansTranslations from "../../locales/zh-Hans.json";
const SUPPORTED_LOCALES = [
"ar",
"da",
"de",
"en",
"es",
"fr",
"hi",
"hu",
"it",
"ja",
"nl",
"pt",
"ro",
"ru",
"sv",
"uz",
"zh-Hans",
] as const;
i18n
.use(ICU)
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: [
"ar",
"da",
"de",
"en",
"es",
"fr",
"hi",
"hu",
"it",
"ja",
"nl",
"pt",
"ro",
"ru",
"sv",
"uz",
"zh-Hans",
],
supportedLngs: [...SUPPORTED_LOCALES],
resources: {
ar: { translation: arTranslations },
da: { translation: daTranslations },
de: { translation: deTranslations },
en: { translation: enTranslations },
es: { translation: esTranslations },
fr: { translation: frTranslations },
hi: { translation: hiTranslations },
hu: { translation: huTranslations },
it: { translation: itTranslations },
ja: { translation: jaTranslations },
nl: { translation: nlTranslations },
pt: { translation: ptTranslations },
ro: { translation: roTranslations },
ru: { translation: ruTranslations },
sv: { translation: svTranslations },
uz: { translation: uzTranslations },
"zh-Hans": { translation: zhHansTranslations },
},
interpolation: { escapeValue: false },
});
let localeBaseUrl = "";
export const setLocaleBaseUrl = (url: string) => {
localeBaseUrl = url;
};
const pendingLoads: Record<string, Promise<void>> = {};
export const loadLanguage = async (lng: string): Promise<void> => {
if (lng === "en" || i18n.hasResourceBundle(lng, "translation")) {
return;
}
if (lng in pendingLoads) {
return pendingLoads[lng];
}
if (!localeBaseUrl) {
return;
}
pendingLoads[lng] = (async () => {
try {
const response = await fetch(`${localeBaseUrl}/${lng}.json`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const translations = await response.json();
i18n.addResourceBundle(lng, "translation", translations);
} catch {
console.warn(`[formbricks] Failed to load translations for "${lng}". Using English fallback.`);
} finally {
delete pendingLoads[lng];
}
})();
return pendingLoads[lng];
};
export default i18n;

View File

@@ -1,7 +1,7 @@
import preact from "@preact/preset-vite";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { loadEnv } from "vite";
import { type PluginOption, loadEnv } from "vite";
import dts from "vite-plugin-dts";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
@@ -27,7 +27,7 @@ const config = ({ mode }) => {
define: {
"process.env.NODE_ENV": JSON.stringify(mode),
},
plugins: [preact(), tsconfigPaths()],
plugins: [preact(), tsconfigPaths() as PluginOption],
};
// Check if we're building the UMD bundle (separate build step)
@@ -53,7 +53,11 @@ const config = ({ mode }) => {
},
plugins: [
...sharedConfig.plugins,
copyCompiledAssetsPlugin({ filename: "surveys", distDir: resolve(__dirname, "dist") }),
copyCompiledAssetsPlugin({
filename: "surveys",
distDir: resolve(__dirname, "dist"),
localesDir: resolve(__dirname, "locales"),
}),
],
});
}
@@ -63,11 +67,7 @@ const config = ({ mode }) => {
...sharedConfig,
test: {
name: "surveys",
environment: "node",
environmentMatchGlobs: [
["**/*.test.tsx", "jsdom"],
["**/lib/**/*.test.ts", "jsdom"],
],
environment: "jsdom",
setupFiles: ["./vitestSetup.ts"],
include: ["**/*.test.ts", "**/*.test.tsx"],
exclude: ["dist/**", "node_modules/**"],
@@ -109,7 +109,7 @@ const config = ({ mode }) => {
entryRoot: "src",
}),
copyCompiledAssetsPlugin({ filename: "surveys", distDir: resolve(__dirname, "dist") }),
process.env.ANALYZE === "true" && visualizer({ filename: resolve(__dirname, "stats.html"), open: false, gzipSize: true, brotliSize: true }),
process.env.ANALYZE === "true" && (visualizer({ filename: resolve(__dirname, "stats.html"), open: false, gzipSize: true, brotliSize: true }) as PluginOption),
],
});
};

View File

@@ -7,6 +7,7 @@ interface CopyCompiledAssetsPluginOptions {
filename: string;
distDir: string;
skipDirectoryCheck?: boolean; // New option to skip checking non-existent directories
localesDir?: string; // Optional directory containing locale JSON files to copy
}
const ensureDirectoryExists = async (dirPath: string): Promise<void> => {
@@ -85,6 +86,22 @@ export function copyCompiledAssetsPlugin(options: CopyCompiledAssetsPluginOption
}
console.log(`Copied ${String(copiedFiles)} files to ${outputDir} (${options.filename})`);
// Copy locale JSON files if localesDir is specified
if (options.localesDir) {
const localesOutputDir = path.resolve(outputDir, "locales");
await ensureDirectoryExists(localesOutputDir);
const localeFiles = (await readdir(options.localesDir)).filter(
(f) => f.endsWith(".json") && f !== "i18n.json"
);
for (const file of localeFiles) {
await copyFile(path.resolve(options.localesDir, file), path.resolve(localesOutputDir, file));
}
console.log(`Copied ${String(localeFiles.length)} locale files to ${localesOutputDir}`);
}
} catch (error) {
if (options.skipDirectoryCheck) {
console.error(