} };
+}
+
+/**
+ * Hands the server-loaded English FAQ articles to the client before the FAQ
+ * page's client components hydrate. English only, see primeEnglishFaq; a
+ * visitor on another language gets the whole locale file through loadLocale
+ * when the client switches. Rendered by the server page ahead of them, so the
+ * registration (idempotent, no listeners) happens in tree order during the
+ * hydration render and those components see the same strings the server
+ * rendered; loading them in an effect instead would hydrate raw keys first and
+ * then flash to the text (#1598).
+ */
+export function FaqResources({ resources }: Props) {
+ primeEnglishFaq(resources);
+ return null;
+}
diff --git a/apps/web/src/features/i18n/faq-split.js b/apps/web/src/features/i18n/faq-split.js
new file mode 100644
index 0000000000..5a522f3db0
--- /dev/null
+++ b/apps/web/src/features/i18n/faq-split.js
@@ -0,0 +1,41 @@
+// Splits the FAQ articles out of the eagerly bundled en-US locale.
+//
+// `static.faq.*-header` / `*-body` are the FAQ articles: 262 keys, ~17 KB
+// gzipped, about a quarter of en-US.json, and only the FAQ surfaces render
+// them. Without this every route shipped them in first-load JS (#1598).
+//
+// This is a webpack loader (see next.config.js) applied to
+// `features/i18n/locales/en-US.json`:
+// - `require("./locales/en-US.json")` -> the locale WITHOUT the articles
+// - `import("./locales/en-US.json?faq")` -> ONLY the articles
+// The JSON on disk stays whole, so Crowdin keeps one source file and the other
+// locales (already loaded on demand as whole files) need no change.
+//
+// Plain CommonJS: webpack loads it at build time, and the spec imports it.
+const FAQ_CONTENT_KEY = /-(header|body)$/;
+
+function splitFaq(locale) {
+ const faq = (locale.static && locale.static.faq) || {};
+ const coreFaq = {};
+ const contentFaq = {};
+ for (const key of Object.keys(faq)) {
+ (FAQ_CONTENT_KEY.test(key) ? contentFaq : coreFaq)[key] = faq[key];
+ }
+ const core = { ...locale, static: { ...locale.static, faq: coreFaq } };
+ return { core, faq: { static: { faq: contentFaq } } };
+}
+
+function isFaqQuery(resourceQuery) {
+ return typeof resourceQuery === "string" && /(^|[?&])faq(=|&|$)/.test(resourceQuery);
+}
+
+function loader(source) {
+ const { core, faq } = splitFaq(JSON.parse(source));
+ const out = isFaqQuery(this.resourceQuery) ? faq : core;
+ return `module.exports = ${JSON.stringify(out)};`;
+}
+
+module.exports = loader;
+module.exports.splitFaq = splitFaq;
+module.exports.isFaqQuery = isFaqQuery;
+module.exports.FAQ_CONTENT_KEY = FAQ_CONTENT_KEY;
diff --git a/apps/web/src/features/i18n/faq.ts b/apps/web/src/features/i18n/faq.ts
new file mode 100644
index 0000000000..a1eb6237d9
--- /dev/null
+++ b/apps/web/src/features/i18n/faq.ts
@@ -0,0 +1,79 @@
+import i18n from "i18next";
+import { loadLocale } from "./index";
+
+/**
+ * FAQ articles (`static.faq.*-header` / `*-body`) are split out of the eagerly
+ * bundled en-US locale by the webpack loader in ./faq-split.js and merged back
+ * into the "translation" namespace on demand, so every existing
+ * `i18next.t("static.faq.…")` call keeps working once `ensureFaqLoaded` has
+ * resolved for the active language (#1598).
+ *
+ * Other locales are loaded on demand as whole files (see loadLocale), articles
+ * included, so for them "loaded" simply means the locale bundle is present.
+ */
+const PROBE_KEY = "what-is-ecency-header";
+const pending = new Map
>();
+
+type LocaleJson = { static?: { faq?: Record } } & Record;
+
+// The loader returns only `{ static: { faq } }`; without it (vitest, or a
+// bundler that ignores the rule) the import resolves to the whole locale, so
+// reduce it to the same shape either way.
+function pickFaq(mod: unknown): { static: { faq: Record } } {
+ const json = ((mod as { default?: LocaleJson }).default ?? mod) as LocaleJson;
+ return { static: { faq: json.static?.faq ?? {} } };
+}
+
+// Probes the bundle itself rather than remembering what was loaded, so a
+// bundle that is replaced or removed elsewhere is simply loaded again.
+export function isFaqLoaded(lang: string = i18n.language): boolean {
+ const bundle = i18n.getResourceBundle(lang, "translation") as LocaleJson | undefined;
+ return Boolean(bundle?.static?.faq?.[PROBE_KEY]);
+}
+
+/**
+ * Register the ENGLISH articles synchronously (idempotent). English only: the
+ * other locales are owned by loadLocale as whole files, and registering a
+ * partial bundle for one of them would make loadLocale believe the locale is
+ * already present and never fetch it, leaving the rest of the UI untranslated.
+ */
+export function primeEnglishFaq(resources: { static: { faq: Record } }) {
+ if (isFaqLoaded("en-US")) return;
+ i18n.addResourceBundle("en-US", "translation", resources, true, true);
+}
+
+export function ensureFaqLoaded(lang: string = i18n.language || "en-US"): Promise {
+ if (isFaqLoaded(lang)) return Promise.resolve();
+ const inFlight = pending.get(lang);
+ if (inFlight) return inFlight;
+ const task = (async () => {
+ if (lang === "en-US") {
+ const mod = await import(/* webpackChunkName: "i18n-faq" */ "./locales/en-US.json?faq");
+ primeEnglishFaq(pickFaq(mod));
+ } else {
+ // Whole-file locale, articles included. en-US is the fallback language
+ // for any article a translation lacks, and its articles are no longer
+ // in the eager bundle, so load them alongside or an untranslated
+ // article would render as its raw key.
+ await Promise.all([loadLocale(lang), ensureFaqLoaded("en-US")]);
+ }
+ })().finally(() => pending.delete(lang));
+ pending.set(lang, task);
+ return task;
+}
+
+/**
+ * The English articles, for a server component to hand to so
+ * client components (which hydrate in the browser's language, English until a
+ * switch loads a whole locale file) have them before they render. Call after
+ * ensureFaqLoaded("en-US").
+ */
+export function getEnglishFaqResources(): { static: { faq: Record } } {
+ const bundle = i18n.getResourceBundle("en-US", "translation") as LocaleJson | undefined;
+ const faq = bundle?.static?.faq ?? {};
+ const content: Record = {};
+ for (const key of Object.keys(faq)) {
+ if (/-(header|body)$/.test(key)) content[key] = faq[key];
+ }
+ return { static: { faq: content } };
+}
diff --git a/apps/web/src/features/i18n/index.ts b/apps/web/src/features/i18n/index.ts
index acb6bcc995..5bab79145c 100644
--- a/apps/web/src/features/i18n/index.ts
+++ b/apps/web/src/features/i18n/index.ts
@@ -108,7 +108,9 @@ const localeLoaders: Record Promise> = {
export async function loadLocale(lang: string) {
if (lang === "en-US" || !localeLoaders[lang]) return;
- if (i18n.hasResourceBundle(lang, "translation")) return;
+ // Probe a core key rather than the bundle's existence: a partial bundle
+ // (FAQ articles registered early) must not pass for the whole file.
+ if (i18n.getResourceBundle(lang, "translation")?.g) return;
const mod = await localeLoaders[lang]();
i18n.addResourceBundle(lang, "translation", mod.default || mod);
@@ -180,3 +182,4 @@ export function initI18next(): Promise {
initI18next().catch((err) => console.error("[i18n] init failed:", err));
export * from "./navigation-locale-watcher";
+export { ensureFaqLoaded, getEnglishFaqResources, isFaqLoaded } from "./faq";
diff --git a/apps/web/src/features/i18n/json-query.d.ts b/apps/web/src/features/i18n/json-query.d.ts
new file mode 100644
index 0000000000..bd314ac10a
--- /dev/null
+++ b/apps/web/src/features/i18n/json-query.d.ts
@@ -0,0 +1,7 @@
+// `./locales/en-US.json?faq` is served by the webpack loader in ./faq-split.js
+// (the FAQ articles only). Type it loosely; faq.ts reduces whatever comes back
+// to the shape it needs.
+declare module "*.json?faq" {
+ const value: unknown;
+ export default value;
+}
diff --git a/apps/web/src/features/i18n/use-faq-translations.ts b/apps/web/src/features/i18n/use-faq-translations.ts
new file mode 100644
index 0000000000..cc02f36a70
--- /dev/null
+++ b/apps/web/src/features/i18n/use-faq-translations.ts
@@ -0,0 +1,49 @@
+"use client";
+
+import i18n from "i18next";
+import { useEffect, useState } from "react";
+import { ensureFaqLoaded, isFaqLoaded } from "./faq";
+
+const RETRY_DELAYS_MS = [2000, 8000];
+
+/**
+ * True once the FAQ articles for the active language are registered with
+ * i18next. Render FAQ strings only when this is true: before that
+ * `i18next.t("static.faq.…-body")` returns the raw key (#1598).
+ *
+ * Readiness is always re-probed against the language that is current when a
+ * load settles, so a load started for a previous language cannot mark the
+ * new one ready. A failed chunk request is retried a couple of times with a
+ * delay before the strings are left hidden until the next language change.
+ */
+export function useFaqTranslations(): boolean {
+ const [ready, setReady] = useState(() => isFaqLoaded(i18n.language));
+
+ useEffect(() => {
+ let cancelled = false;
+ let timer: ReturnType | undefined;
+ const settle = () => !cancelled && setReady(isFaqLoaded(i18n.language));
+ const load = (attempt = 0) => {
+ settle();
+ ensureFaqLoaded(i18n.language)
+ .then(settle)
+ .catch(() => {
+ if (cancelled || attempt >= RETRY_DELAYS_MS.length) return;
+ timer = setTimeout(() => load(attempt + 1), RETRY_DELAYS_MS[attempt]);
+ });
+ };
+ const onLanguageChanged = () => {
+ if (timer) clearTimeout(timer);
+ load();
+ };
+ load();
+ i18n.on("languageChanged", onLanguageChanged);
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ i18n.off("languageChanged", onLanguageChanged);
+ };
+ }, []);
+
+ return ready;
+}
diff --git a/apps/web/src/specs/features/i18n/faq-split.spec.ts b/apps/web/src/specs/features/i18n/faq-split.spec.ts
new file mode 100644
index 0000000000..6b341998b9
--- /dev/null
+++ b/apps/web/src/specs/features/i18n/faq-split.spec.ts
@@ -0,0 +1,130 @@
+// @vitest-environment node
+import fs from "fs";
+import path from "path";
+import { vi } from "vitest";
+// The global setup replaces i18next with a key-echoing stub; this spec needs
+// the real instance to prove the merge-back.
+vi.unmock("i18next");
+import i18n from "i18next";
+import loader, { splitFaq, isFaqQuery } from "@/features/i18n/faq-split";
+import { initI18next, loadLocale } from "@/features/i18n";
+import { ensureFaqLoaded, getEnglishFaqResources, isFaqLoaded, primeEnglishFaq } from "@/features/i18n/faq";
+
+const LOCALE = path.resolve(__dirname, "../../../features/i18n/locales/en-US.json");
+const enUs = JSON.parse(fs.readFileSync(LOCALE, "utf8"));
+
+/**
+ * The FAQ articles leave the eager en-US bundle through the webpack loader and
+ * come back on demand (#1598). The loader is a plain function, so its split is
+ * checked here against the real locale file; the runtime half is checked
+ * against the real i18next instance.
+ */
+describe("faq-split loader", () => {
+ const { core, faq } = splitFaq(enUs);
+
+ it("moves every article and nothing else", () => {
+ const articles = Object.keys(enUs.static.faq).filter((k) => /-(header|body)$/.test(k));
+ expect(articles.length).toBeGreaterThan(200);
+ expect(Object.keys(faq.static.faq).sort()).toEqual(articles.sort());
+ expect(Object.keys(core.static.faq).some((k) => /-(header|body)$/.test(k))).toBe(false);
+ });
+
+ it("keeps the small FAQ keys every page may use in the core bundle", () => {
+ for (const k of ["page-title", "page-sub-title", "search", "search-not-found", "search-placeholder", "search-link-copied", "toggle-icon-info", "about-ecency", "working", "about-blockchain", "features"]) {
+ expect(core.static.faq[k]).toBe(enUs.static.faq[k]);
+ }
+ });
+
+ it("leaves every other namespace untouched and the union equals the source", () => {
+ const { static: _s, ...restCore } = core;
+ const { static: _o, ...restOrig } = enUs;
+ expect(restCore).toEqual(restOrig);
+ expect(core.static.about).toEqual(enUs.static.about);
+ expect(core.static.mobile).toEqual(enUs.static.mobile);
+ expect({ ...core.static.faq, ...faq.static.faq }).toEqual(enUs.static.faq);
+ });
+
+ it("is worth doing: the articles are a large share of the file", () => {
+ const size = (o: unknown) => JSON.stringify(o).length;
+ expect(size(faq)).toBeGreaterThan(size(enUs) * 0.2);
+ });
+
+ it("serves the core for the plain import and the articles for ?faq", () => {
+ const source = JSON.stringify(enUs);
+ const plain = loader.call({ resourceQuery: "" }, source);
+ const query = loader.call({ resourceQuery: "?faq" }, source);
+ const evalModule = (code: string) => {
+ const mod = { exports: {} as { static: { faq: Record }; g?: unknown } };
+ // eslint-disable-next-line no-new-func
+ new Function("module", code)(mod);
+ return mod.exports;
+ };
+ expect(evalModule(plain).static.faq["what-is-ecency-header"]).toBeUndefined();
+ expect(evalModule(plain).static.faq["page-title"]).toBe(enUs.static.faq["page-title"]);
+ expect(evalModule(query).static.faq["what-is-ecency-header"]).toBe(enUs.static.faq["what-is-ecency-header"]);
+ expect(evalModule(query).g).toBeUndefined();
+ expect(isFaqQuery("?faq")).toBe(true);
+ expect(isFaqQuery("?other=1&faq")).toBe(true);
+ expect(isFaqQuery("?faqs")).toBe(false);
+ expect(isFaqQuery(undefined)).toBe(false);
+ });
+});
+
+describe("FAQ articles on demand", () => {
+ beforeAll(async () => {
+ // Without the webpack loader the app's init registers the whole file;
+ // reset the bundle to what the production build ships (the core).
+ await initI18next();
+ const { core } = splitFaq(enUs);
+ i18n.removeResourceBundle("en-US", "translation");
+ i18n.addResourceBundle("en-US", "translation", core);
+ });
+
+ it("starts without the articles and reports them missing", () => {
+ expect(isFaqLoaded("en-US")).toBe(false);
+ expect(i18n.t("static.faq.what-is-ecency-header")).toBe("static.faq.what-is-ecency-header");
+ });
+
+ it("merges the articles back into the translation namespace without touching other keys", async () => {
+ await ensureFaqLoaded("en-US");
+ expect(isFaqLoaded("en-US")).toBe(true);
+ expect(i18n.t("static.faq.what-is-ecency-header")).toBe(enUs.static.faq["what-is-ecency-header"]);
+ expect(i18n.t("static.faq.page-title")).toBe(enUs.static.faq["page-title"]);
+ expect(i18n.t("g.copy")).toBe(enUs.g.copy);
+ });
+
+ it("exposes only the English articles for the server to hand to the client", () => {
+ const res = getEnglishFaqResources();
+ expect(Object.keys(res.static.faq).every((k) => /-(header|body)$/.test(k))).toBe(true);
+ expect(res.static.faq["what-is-ecency-body"]).toBe(enUs.static.faq["what-is-ecency-body"]);
+ });
+
+ it("loads the English articles alongside another language, for per-key fallback", async () => {
+ i18n.removeResourceBundle("en-US", "translation");
+ i18n.addResourceBundle("en-US", "translation", splitFaq(enUs).core);
+ await ensureFaqLoaded("es-ES");
+ expect(isFaqLoaded("es-ES")).toBe(true);
+ expect(isFaqLoaded("en-US")).toBe(true);
+ expect(i18n.getResourceBundle("en-US", "translation").static.faq["what-is-ecency-body"]).toBe(
+ enUs.static.faq["what-is-ecency-body"]
+ );
+ });
+
+ it("primes the English articles synchronously and idempotently", () => {
+ i18n.removeResourceBundle("en-US", "translation");
+ i18n.addResourceBundle("en-US", "translation", splitFaq(enUs).core);
+ expect(isFaqLoaded("en-US")).toBe(false);
+ primeEnglishFaq({ static: { faq: { "what-is-ecency-header": "First" } } });
+ primeEnglishFaq({ static: { faq: { "what-is-ecency-header": "ignored" } } });
+ expect(i18n.t("static.faq.what-is-ecency-header")).toBe("First");
+ expect(i18n.t("static.faq.page-title")).toBe(enUs.static.faq["page-title"]);
+ });
+
+ it("never leaves another language half-loaded: loadLocale still fetches the whole file", async () => {
+ // A partial bundle must not pass loadLocale's guard.
+ i18n.addResourceBundle("es-ES", "translation", { static: { faq: { "what-is-ecency-header": "partial" } } }, true, true);
+ await loadLocale("es-ES");
+ expect(i18n.getResourceBundle("es-ES", "translation").g).toBeDefined();
+ expect(i18n.getResourceBundle("es-ES", "translation").static.faq["what-is-ecency-body"]).toBeTruthy();
+ });
+});
diff --git a/apps/web/vitest.config.mts b/apps/web/vitest.config.mts
index 6af93ba3d1..be81a214fd 100644
--- a/apps/web/vitest.config.mts
+++ b/apps/web/vitest.config.mts
@@ -1,4 +1,4 @@
-import { defineConfig } from 'vitest/config';
+import { defineConfig, type Plugin } from 'vitest/config';
import path from 'path';
import react from '@vitejs/plugin-react';
@@ -18,9 +18,22 @@ const stubStyles = {
}
};
+// `./locales/en-US.json?faq` is served by a webpack loader in the app build
+// (features/i18n/faq-split.js: only the FAQ articles). Vite has no such loader
+// and chokes on the query, so resolve it to the plain file; faq.ts reduces the
+// whole locale to the same shape.
+const faqQuery: Plugin = {
+ name: 'faq-locale-query',
+ enforce: 'pre',
+ resolveId(id, importer) {
+ if (!id.endsWith('en-US.json?faq')) return null;
+ return this.resolve(id.replace('?faq', ''), importer, { skipSelf: true });
+ }
+};
+
export default defineConfig({
// cast: dual vite versions in the workspace make the Plugin types diverge
- plugins: [stubStyles as any, react()],
+ plugins: [stubStyles as any, faqQuery, react()],
test: {
globals: true,
environment: 'jsdom',