From 44a9b6240704fdc4c1b617603fdb117edd2c4d23 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 21 Aug 2026 09:04:25 +0000 Subject: [PATCH] Load the FAQ articles on demand instead of in every route's locale bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit en-US.json is the only eagerly bundled locale, and its FAQ articles (static.faq.*-header / *-body: 262 keys, about 17 KB gzipped, a quarter of the file) shipped in first-load JS on every route although only the FAQ surfaces render them. A webpack loader now splits that file at build time: the plain import gets the locale without the articles and a ?faq import gets only them, so the JSON on disk stays whole and Crowdin keeps one source file. The other locales are already loaded on demand as whole files and need no change. ensureFaqLoaded merges the English articles back into the translation namespace (and loads them alongside any other language, since English is the per-key fallback), so every existing i18next.t("static.faq.…") call keeps working once it has resolved. The FAQ and About pages await it on the server; the FAQ page hands the English articles to its client components through so they hydrate with the strings the server rendered; the help-center search, decks FAQ column and the two perks explainers render their FAQ strings once useFaqTranslations reports them present. Only English is ever primed from the server: the other locales belong to loadLocale as whole files, whose guard now probes a core key so a partial bundle can never pass for the full file. A vitest resolver maps the ?faq import to the plain file in tests. Closes #1598 --- apps/web/next.config.js | 11 ++ apps/web/src/app/(staticPages)/about/page.tsx | 5 +- apps/web/src/app/(staticPages)/faq/page.tsx | 19 ++- .../_components/columns/deck-faq-column.tsx | 8 +- .../points/_components/points-basic-info.tsx | 7 +- .../_components/promote-post-intro.tsx | 11 +- .../ecency-center/sections/center-faq.tsx | 9 +- apps/web/src/features/i18n/faq-resources.tsx | 22 +++ apps/web/src/features/i18n/faq-split.js | 41 ++++++ apps/web/src/features/i18n/faq.ts | 79 +++++++++++ apps/web/src/features/i18n/index.ts | 5 +- apps/web/src/features/i18n/json-query.d.ts | 7 + .../src/features/i18n/use-faq-translations.ts | 49 +++++++ .../src/specs/features/i18n/faq-split.spec.ts | 130 ++++++++++++++++++ apps/web/vitest.config.mts | 17 ++- 15 files changed, 406 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/features/i18n/faq-resources.tsx create mode 100644 apps/web/src/features/i18n/faq-split.js create mode 100644 apps/web/src/features/i18n/faq.ts create mode 100644 apps/web/src/features/i18n/json-query.d.ts create mode 100644 apps/web/src/features/i18n/use-faq-translations.ts create mode 100644 apps/web/src/specs/features/i18n/faq-split.spec.ts diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 3b67f383ad..6a73624e72 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -258,6 +258,17 @@ const config = { filename: "static/chunks/[path][name].[hash][ext]" } }); + // en-US is the only eagerly bundled locale. Its FAQ articles (~17 KB gz, a + // quarter of the file) are only rendered by the FAQ surfaces, so the + // loader splits them out: the plain import gets the locale without them, + // `?faq` gets only them, loaded on demand by features/i18n/faq.ts (#1598). + // Applies to the server bundle too so SSR and client agree on what is + // eager. The JSON on disk stays whole for Crowdin. + config.module.rules.push({ + test: /[\\/]features[\\/]i18n[\\/]locales[\\/]en-US\.json$/, + type: "javascript/auto", + use: [{ loader: path.resolve(__dirname, "src/features/i18n/faq-split.js") }] + }); config.resolve.fallback = { ...config.resolve.fallback, fs: false diff --git a/apps/web/src/app/(staticPages)/about/page.tsx b/apps/web/src/app/(staticPages)/about/page.tsx index 6315d690ad..9d608eb84b 100644 --- a/apps/web/src/app/(staticPages)/about/page.tsx +++ b/apps/web/src/app/(staticPages)/about/page.tsx @@ -8,6 +8,7 @@ import Link from "next/link"; import { blogSvg, discordSvg, githubSvg, mailSvg, newsSvg, telegramSvg, twitterSvg } from "@ui/svg"; import { Metadata, ResolvingMetadata } from "next"; import { PagesMetadataGenerator } from "@/features/metadata"; +import { ensureFaqLoaded } from "@/features/i18n"; export async function generateMetadata( props: unknown, @@ -16,7 +17,9 @@ export async function generateMetadata( return PagesMetadataGenerator.getForPage("about"); } -export default function About() { +export default async function About() { + // The FAQ headers below are not in the eager locale bundle (#1598). + await ensureFaqLoaded("en-US"); return ( <> diff --git a/apps/web/src/app/(staticPages)/faq/page.tsx b/apps/web/src/app/(staticPages)/faq/page.tsx index 26bb4ebaab..b7d82c5e80 100644 --- a/apps/web/src/app/(staticPages)/faq/page.tsx +++ b/apps/web/src/app/(staticPages)/faq/page.tsx @@ -12,7 +12,13 @@ import { } from "@/app/(staticPages)/faq/_components"; import { searchWithinFaq } from "@/app/(staticPages)/faq/utils"; import { Tsx } from "@/features/i18n/helper"; -import { NavigationLocaleWatcher } from "@/features/i18n"; +import { + NavigationLocaleWatcher, + ensureFaqLoaded, + getEnglishFaqResources, + langOptions +} from "@/features/i18n"; +import { FaqResources } from "@/features/i18n/faq-resources"; import { FaqSearchResult } from "@/app/(staticPages)/faq/_components/faq-search-result"; import { PagesMetadataGenerator } from "@/features/metadata"; @@ -32,6 +38,16 @@ interface Props { export default async function FAQ({ searchParams }: Props) { const params = await searchParams; + // The FAQ articles are not in the eager locale bundle (#1598). English is + // the fallback for every article and the language client components hydrate + // in, so it is always loaded and handed to them; a ?lang request (the same + // resolution NavigationLocaleWatcher uses) also loads that whole locale. + const requestedLang = langOptions.find( + (item) => item.code.split("-")[0] === params["lang"] + )?.code; + await Promise.all([ensureFaqLoaded("en-US"), requestedLang && ensureFaqLoaded(requestedLang)]); + const faqResources = getEnglishFaqResources(); + const searchResult = searchWithinFaq(params["q"] ?? ""); return ( @@ -40,6 +56,7 @@ export default async function FAQ({ searchParams }: Props) { + diff --git a/apps/web/src/app/decks/_components/columns/deck-faq-column.tsx b/apps/web/src/app/decks/_components/columns/deck-faq-column.tsx index 7cafb3b289..17e0b9f2f8 100644 --- a/apps/web/src/app/decks/_components/columns/deck-faq-column.tsx +++ b/apps/web/src/app/decks/_components/columns/deck-faq-column.tsx @@ -5,6 +5,7 @@ import { FormControl } from "@ui/input"; import { faqKeysGeneral } from "@/consts"; import i18next from "i18next"; import { articleSvg } from "@/assets/img/svg"; +import { useFaqTranslations } from "@/features/i18n/use-faq-translations"; interface Props { id: string; @@ -15,8 +16,11 @@ export const DeckFaqColumn = ({ id, draggable }: Props) => { const [expandedHelp, setExpandedHelp] = useState(true); const [searchText, setSearchText] = useState(""); const [dataToShow, setDataToShow] = useState([...faqKeysGeneral]); + // FAQ articles load on demand (#1598); the list renders once they are in. + const faqReady = useFaqTranslations(); useEffect(() => { + if (!faqReady) return; setDataToShow( faqKeysGeneral.filter((key) => i18next @@ -25,7 +29,7 @@ export const DeckFaqColumn = ({ id, draggable }: Props) => { .includes(searchText.toLocaleLowerCase()) ) ); - }, [searchText]); + }, [searchText, faqReady]); return ( { "" )}
- {dataToShow.map((x) => { + {faqReady && dataToShow.map((x) => { return (
{articleSvg}
diff --git a/apps/web/src/app/perks/points/_components/points-basic-info.tsx b/apps/web/src/app/perks/points/_components/points-basic-info.tsx index f359ff92f9..75925573f6 100644 --- a/apps/web/src/app/perks/points/_components/points-basic-info.tsx +++ b/apps/web/src/app/perks/points/_components/points-basic-info.tsx @@ -5,9 +5,12 @@ import { UilArrowLeft, UilSpinner } from "@tooni/iconscout-unicons-react"; import i18next from "i18next"; import Link from "next/link"; import { useActiveAccount } from "@/core/hooks/use-active-account"; +import { useFaqTranslations } from "@/features/i18n/use-faq-translations"; export function PointsBasicInfo() { const { activeUser } = useActiveAccount(); + // The explainer is a FAQ article, loaded on demand (#1598). + const faqReady = useFaqTranslations(); const { data: activeUserPoints, isPending } = useQuery( getPointsQueryOptions(activeUser?.username) ); @@ -27,7 +30,9 @@ export function PointsBasicInfo() {

{i18next.t("perks.points-title")}

{i18next.t("perks.points-description")}

-

+ {faqReady && ( +

+ )}

{i18next.t("redeem-common.balance")}:
diff --git a/apps/web/src/app/perks/promote-post/_components/promote-post-intro.tsx b/apps/web/src/app/perks/promote-post/_components/promote-post-intro.tsx index f96b5b2839..c7e7046268 100644 --- a/apps/web/src/app/perks/promote-post/_components/promote-post-intro.tsx +++ b/apps/web/src/app/perks/promote-post/_components/promote-post-intro.tsx @@ -3,19 +3,24 @@ import { Button } from "@/features/ui"; import { UilArrowRight } from "@tooni/iconscout-unicons-react"; import i18next from "i18next"; import Image from "next/image"; +import { useFaqTranslations } from "@/features/i18n/use-faq-translations"; interface Props { onContinue: () => void; } export function PromotePostIntro({ onContinue }: Props) { + // The explainer is a FAQ article, loaded on demand (#1598). + const faqReady = useFaqTranslations(); return (
-
+ {faqReady && ( +
+ )}