From e832cd27296e3710580ffee733a4aaad681982d4 Mon Sep 17 00:00:00 2001 From: matc-pub <161147791+matc-pub@users.noreply.github.com> Date: Wed, 13 Mar 2024 21:39:45 +0100 Subject: [PATCH] Lazy load translations and date-fns, server side support for "Browser Default" language (#2380) * Lazy load i18n translations. * Lazy load date-fns * Fix inconsistent DOMContentLoaded event. Only when no translations and date-fns have to be dynamically loaded (e.g. for en-US) the NavBar `componentDidMount` is early enough to listen for "DOMContentLoaded". Removes one redundant `requestNotificationPermission()` call. * Rename interface language code "pt_BR" to "pt-BR". Browsers ask for "pt-BR", but the "interface_language" saved in the settings dialog asks for "pt_BR". This change will make the settings dialog ask for "pt-BR" instead of "pt_BR". For users that already (or still) have "pt_BR" configured, "pt-BR" will be used, but the settings dialog will present it as unspecified. * Use Accept-Language request header * Prefetch translation and date-fns --------- Co-authored-by: SleeplessOne1917 <28871516+SleeplessOne1917@users.noreply.github.com> --- src/client/index.tsx | 13 +- src/server/handlers/catch-all-handler.tsx | 41 +++- src/server/index.tsx | 3 + src/server/utils/create-ssr-html.tsx | 13 ++ src/shared/components/app/navbar.tsx | 3 +- src/shared/components/person/settings.tsx | 20 +- src/shared/dynamic-imports.ts | 49 +++++ src/shared/interfaces.ts | 1 + src/shared/services/I18NextService.ts | 243 +++++++++++++++------- src/shared/services/index.ts | 2 +- src/shared/utils/app/initialize-site.ts | 3 +- src/shared/utils/app/setup-date-fns.ts | 213 ++++++++++++++++--- webpack.config.js | 8 + 13 files changed, 495 insertions(+), 117 deletions(-) create mode 100644 src/shared/dynamic-imports.ts diff --git a/src/client/index.tsx b/src/client/index.tsx index a7af06de..0c8a4edf 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,16 +1,23 @@ -import { initializeSite, setupDateFns } from "@utils/app"; +import { initializeSite } from "@utils/app"; import { hydrate } from "inferno-hydrate"; import { BrowserRouter } from "inferno-router"; import { App } from "../shared/components/app/app"; +import { loadUserLanguage } from "../shared/services/I18NextService"; +import { verifyDynamicImports } from "../shared/dynamic-imports"; import "bootstrap/js/dist/collapse"; import "bootstrap/js/dist/dropdown"; import "bootstrap/js/dist/modal"; async function startClient() { + // Allows to test imports from the browser console. + window.checkLazyScripts = () => { + verifyDynamicImports(true).then(x => console.log(x)); + }; + initializeSite(window.isoData.site_res); - await setupDateFns(); + await loadUserLanguage(); const wrapper = ( @@ -22,6 +29,8 @@ async function startClient() { if (root) { hydrate(wrapper, root); + + root.dispatchEvent(new CustomEvent("lemmy-hydrated", { bubbles: true })); } } diff --git a/src/server/handlers/catch-all-handler.tsx b/src/server/handlers/catch-all-handler.tsx index 3466f3cc..2672f716 100644 --- a/src/server/handlers/catch-all-handler.tsx +++ b/src/server/handlers/catch-all-handler.tsx @@ -20,9 +20,26 @@ import { createSsrHtml } from "../utils/create-ssr-html"; import { getErrorPageData } from "../utils/get-error-page-data"; import { setForwardedHeaders } from "../utils/set-forwarded-headers"; import { getJwtCookie } from "../utils/has-jwt-cookie"; +import { + I18NextService, + LanguageService, + UserService, +} from "../../shared/services/"; export default async (req: Request, res: Response) => { try { + const languages: string[] = + req.headers["accept-language"] + ?.split(",") + .map(x => { + const [head, tail] = x.split(/;\s*q?\s*=?/); // at ";", remove "q=" + const q = Number(tail ?? 1); // no q means q=1 + return { lang: head.trim(), q: Number.isNaN(q) ? 0 : q }; + }) + .filter(x => x.lang) + .sort((a, b) => b.q - a.q) + .map(x => (x.lang === "*" ? "en" : x.lang)) ?? []; + const activeRoute = routes.find(route => matchPath(req.path, route)); const headers = setForwardedHeaders(req.headers); @@ -60,6 +77,7 @@ export default async (req: Request, res: Response) => { if (try_site.state === "success") { site = try_site.data; initializeSite(site); + LanguageService.updateLanguages(languages); if (path !== "/setup" && !site.site_view.local_site.site_setup) { return res.redirect("/setup"); @@ -73,6 +91,16 @@ export default async (req: Request, res: Response) => { headers, }; + if (process.env.NODE_ENV === "development") { + setTimeout(() => { + // Intentionally (likely) break things if fetchInitialData tries to + // use global state after the first await of an unresolved promise. + // This simulates another request entering or leaving this + // "success" block. + UserService.Instance.myUserInfo = undefined; + I18NextService.i18n.changeLanguage("cimode"); + }); + } routeData = await activeRoute.fetchInitialData(initialFetchReq); } @@ -114,9 +142,20 @@ export default async (req: Request, res: Response) => { ); + // Another request could have initialized a new site. + initializeSite(site); + LanguageService.updateLanguages(languages); + const root = renderToString(wrapper); - res.send(await createSsrHtml(root, isoData, res.locals.cspNonce)); + res.send( + await createSsrHtml( + root, + isoData, + res.locals.cspNonce, + LanguageService.userLanguages, + ), + ); } catch (err) { // If an error is caught here, the error page couldn't even be rendered console.error(err); diff --git a/src/server/index.tsx b/src/server/index.tsx index b3955348..34fc9ccc 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -13,6 +13,7 @@ import ThemeHandler from "./handlers/theme-handler"; import ThemesListHandler from "./handlers/themes-list-handler"; import { setCacheControl, setDefaultCsp } from "./middleware"; import CodeThemeHandler from "./handlers/code-theme-handler"; +import { verifyDynamicImports } from "../shared/dynamic-imports"; const server = express(); @@ -54,6 +55,8 @@ server.get("/css/themelist", ThemesListHandler); server.get("/*", CatchAllHandler); const listener = server.listen(Number(port), hostname, () => { + verifyDynamicImports(true); + setupDateFns(); console.log( `Lemmy-ui v${VERSION} started listening on http://${hostname}:${port}`, diff --git a/src/server/utils/create-ssr-html.tsx b/src/server/utils/create-ssr-html.tsx index 0958588d..1655d3c8 100644 --- a/src/server/utils/create-ssr-html.tsx +++ b/src/server/utils/create-ssr-html.tsx @@ -7,6 +7,8 @@ import { favIconPngUrl, favIconUrl } from "../../shared/config"; import { IsoDataOptionalSite } from "../../shared/interfaces"; import { buildThemeList } from "./build-themes-list"; import { fetchIconPng } from "./fetch-icon-png"; +import { findTranslationChunkNames } from "../../shared/services/I18NextService"; +import { findDateFnsChunkNames } from "../../shared/utils/app/setup-date-fns"; const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || ""; @@ -16,6 +18,7 @@ export async function createSsrHtml( root: string, isoData: IsoDataOptionalSite, cspNonce: string, + userLanguages: readonly string[], ) { const site = isoData.site_res; @@ -63,10 +66,20 @@ export async function createSsrHtml( const helmet = Helmet.renderStatic(); + const lazyScripts = [ + ...findTranslationChunkNames(userLanguages), + ...findDateFnsChunkNames(userLanguages), + ] + .filter(x => x !== undefined) + .map(x => `${getStaticDir()}/js/${x}.client.js`) + .map(x => ``) + .join(""); + return ` + ${lazyScripts} diff --git a/src/shared/components/app/navbar.tsx b/src/shared/components/app/navbar.tsx index 900a12ec..390cd718 100644 --- a/src/shared/components/app/navbar.tsx +++ b/src/shared/components/app/navbar.tsx @@ -78,7 +78,6 @@ export class Navbar extends Component { UnreadCounterService.Instance.unreadApplicationCountSubject.subscribe( unreadApplicationCount => this.setState({ unreadApplicationCount }), ); - this.requestNotificationPermission(); document.addEventListener("mouseup", this.handleOutsideMenuClick); } @@ -468,7 +467,7 @@ export class Navbar extends Component { requestNotificationPermission() { if (UserService.Instance.myUserInfo) { - document.addEventListener("DOMContentLoaded", function () { + document.addEventListener("lemmy-hydrated", function () { if (!Notification) { toast(I18NextService.i18n.t("notifications_error"), "danger"); return; diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx index fe12640e..23ed4c8a 100644 --- a/src/shared/components/person/settings.tsx +++ b/src/shared/components/person/settings.tsx @@ -45,7 +45,11 @@ import { RequestState, wrapClient, } from "../../services/HttpService"; -import { I18NextService, languages } from "../../services/I18NextService"; +import { + I18NextService, + languages, + loadUserLanguage, +} from "../../services/I18NextService"; import { setupTippy } from "../../tippy"; import { toast } from "../../toast"; import { HtmlTags } from "../common/html-tags"; @@ -335,6 +339,11 @@ export class Settings extends Component { } } + componentWillUnmount(): void { + // In case `interface_language` change wasn't saved. + loadUserLanguage(); + } + static async fetchInitialData({ headers, }: InitialFetchRequest): Promise { @@ -791,7 +800,7 @@ export class Settings extends Component { onChange={linkEvent(this, this.handleInterfaceLangChange)} className="form-select d-inline-block w-auto" > -