Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
// 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
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/(staticPages)/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<>
<Theme />
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/app/(staticPages)/faq/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 (
Expand All @@ -40,6 +56,7 @@ export default async function FAQ({ searchParams }: Props) {
<Feedback />
<Theme />
<Navbar />
<FaqResources resources={faqResources} />
<FaqSearchListener searchResult={searchResult} />
<NavigationLocaleWatcher searchParams={params} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,8 +16,11 @@ export const DeckFaqColumn = ({ id, draggable }: Props) => {
const [expandedHelp, setExpandedHelp] = useState(true);
const [searchText, setSearchText] = useState("");
const [dataToShow, setDataToShow] = useState<string[]>([...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
Expand All @@ -25,7 +29,7 @@ export const DeckFaqColumn = ({ id, draggable }: Props) => {
.includes(searchText.toLocaleLowerCase())
)
);
}, [searchText]);
}, [searchText, faqReady]);

return (
<GenericDeckColumn
Expand Down Expand Up @@ -61,7 +65,7 @@ export const DeckFaqColumn = ({ id, draggable }: Props) => {
""
)}
<div className="faq-content">
{dataToShow.map((x) => {
{faqReady && dataToShow.map((x) => {
return (
<a className="faq-article" href={`/faq#${x}`} target="_blank" key={x}>
<div className="faq-image">{articleSvg}</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand All @@ -27,7 +30,9 @@ export function PointsBasicInfo() {
</Link>
<h1 className="font-bold text-xl mt-2 md:mt-4 lg:mt-6">{i18next.t("perks.points-title")}</h1>
<h2 className="opacity-50 mb-4">{i18next.t("perks.points-description")}</h2>
<p dangerouslySetInnerHTML={{ __html: i18next.t("static.faq.what-is-points-body") }} />
{faqReady && (
<p dangerouslySetInnerHTML={{ __html: i18next.t("static.faq.what-is-points-body") }} />
)}

<div className="flex items-center gap-2 mt-4">
<div className="opacity-50">{i18next.t("redeem-common.balance")}:</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-center text-gray-600 dark:text-gray-400 my-4 md:my-6 lg:my-8">
<Image width={375} height={400} alt="" src="/assets/undraw-promote.svg" className="mx-auto" />
<div className="flex flex-col gap-4 items-start">
<div
dangerouslySetInnerHTML={{ __html: i18next.t("static.faq.how-promotion-work-body") }}
/>
{faqReady && (
<div
dangerouslySetInnerHTML={{ __html: i18next.t("static.faq.how-promotion-work-body") }}
/>
)}
<LoginRequired>
<Button size="lg" icon={<UilArrowRight />} onClick={onContinue}>
{i18next.t("g.continue")}
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/features/ecency-center/sections/center-faq.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ import data from "@/features/ecency-center/data/path.json";
import useMount from "react-use/lib/useMount";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { useFaqTranslations } from "@/features/i18n/use-faq-translations";

export function CenterFaq() {
const pathname = usePathname();
// FAQ articles load on demand (#1598); search and titles wait for them.
const faqReady = useFaqTranslations();

const [searchText, setSearchText] = useState("");
const [faqKeys, setFaqKeys] = useState<string[]>([]);
const [defaultFaqKeys, setDefaultFaqKeys] = useState<string[]>([]);
const [datatoShow, setDatatoShow] = useState<string[]>([]);

useEffect(() => {
if (!searchText) {
if (!searchText || !faqReady) {
setDatatoShow(defaultFaqKeys);
return;
}
Expand All @@ -35,7 +38,7 @@ export function CenterFaq() {
});

setDatatoShow(searchResult);
}, [defaultFaqKeys, faqKeys, searchText]);
}, [defaultFaqKeys, faqKeys, searchText, faqReady]);

useMount(() => {
const faqKeys = [...faqKeysGeneral];
Expand Down Expand Up @@ -68,7 +71,7 @@ export function CenterFaq() {
) : (
""
)}
{datatoShow.map((x, i) => (
{faqReady && datatoShow.map((x, i) => (
<div
className="animate-fade-in-up"
style={{ animationDelay: `${Math.min(i, 5) * 50}ms` }}
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/features/i18n/faq-resources.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use client";

import { primeEnglishFaq } from "./faq";

interface Props {
resources: { static: { faq: Record<string, string> } };
}

/**
* 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;
}
41 changes: 41 additions & 0 deletions apps/web/src/features/i18n/faq-split.js
Original file line number Diff line number Diff line change
@@ -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;
79 changes: 79 additions & 0 deletions apps/web/src/features/i18n/faq.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<void>>();

type LocaleJson = { static?: { faq?: Record<string, string> } } & Record<string, unknown>;

// 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<string, string> } } {
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<string, string> } }) {
if (isFaqLoaded("en-US")) return;
i18n.addResourceBundle("en-US", "translation", resources, true, true);
}

export function ensureFaqLoaded(lang: string = i18n.language || "en-US"): Promise<void> {
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 <FaqResources> 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<string, string> } } {
const bundle = i18n.getResourceBundle("en-US", "translation") as LocaleJson | undefined;
const faq = bundle?.static?.faq ?? {};
const content: Record<string, string> = {};
for (const key of Object.keys(faq)) {
if (/-(header|body)$/.test(key)) content[key] = faq[key];
}
return { static: { faq: content } };
}
5 changes: 4 additions & 1 deletion apps/web/src/features/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ const localeLoaders: Record<string, () => Promise<any>> = {

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);
Expand Down Expand Up @@ -180,3 +182,4 @@ export function initI18next(): Promise<void> {
initI18next().catch((err) => console.error("[i18n] init failed:", err));

export * from "./navigation-locale-watcher";
export { ensureFaqLoaded, getEnglishFaqResources, isFaqLoaded } from "./faq";
7 changes: 7 additions & 0 deletions apps/web/src/features/i18n/json-query.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading