diff --git a/src/client/index.tsx b/src/client/index.tsx index a7af06def..0c8a4edfe 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 3466f3cc2..2672f7160 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 b3955348f..34fc9cccf 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 0958588d3..1655d3c81 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 900a12ec5..390cd718d 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 fe12640e1..23ed4c8ac 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" > -