Skip to content

Commit

Permalink
Lazy load translations and date-fns, server side support for "Browser…
Browse files Browse the repository at this point in the history
… 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>
  • Loading branch information
matc-pub and SleeplessOne1917 committed Mar 13, 2024
1 parent c80136e commit e832cd2
Show file tree
Hide file tree
Showing 13 changed files with 493 additions and 115 deletions.
13 changes: 11 additions & 2 deletions 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 = (
<BrowserRouter>
Expand All @@ -22,6 +29,8 @@ async function startClient() {

if (root) {
hydrate(wrapper, root);

root.dispatchEvent(new CustomEvent("lemmy-hydrated", { bubbles: true }));
}
}

Expand Down
41 changes: 40 additions & 1 deletion src/server/handlers/catch-all-handler.tsx
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}

Expand Down Expand Up @@ -114,9 +142,20 @@ export default async (req: Request, res: Response) => {
</StaticRouter>
);

// 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);
Expand Down
3 changes: 3 additions & 0 deletions src/server/index.tsx
Expand Up @@ -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();

Expand Down Expand Up @@ -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}`,
Expand Down
13 changes: 13 additions & 0 deletions src/server/utils/create-ssr-html.tsx
Expand Up @@ -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"] || "";

Expand All @@ -16,6 +18,7 @@ export async function createSsrHtml(
root: string,
isoData: IsoDataOptionalSite,
cspNonce: string,
userLanguages: readonly string[],
) {
const site = isoData.site_res;

Expand Down Expand Up @@ -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 => `<link rel="preload" as="script" href="${x}" />`)
.join("");

return `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
<head>
${lazyScripts}
<script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>
<!-- A remote debugging utility for mobile -->
Expand Down
3 changes: 1 addition & 2 deletions src/shared/components/app/navbar.tsx
Expand Up @@ -78,7 +78,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
UnreadCounterService.Instance.unreadApplicationCountSubject.subscribe(
unreadApplicationCount => this.setState({ unreadApplicationCount }),
);
this.requestNotificationPermission();

document.addEventListener("mouseup", this.handleOutsideMenuClick);
}
Expand Down Expand Up @@ -468,7 +467,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {

requestNotificationPermission() {
if (UserService.Instance.myUserInfo) {
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("lemmy-hydrated", function () {
if (!Notification) {
toast(I18NextService.i18n.t("notifications_error"), "danger");
return;
Expand Down
20 changes: 18 additions & 2 deletions src/shared/components/person/settings.tsx
Expand Up @@ -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";
Expand Down Expand Up @@ -335,6 +339,11 @@ export class Settings extends Component<any, SettingsState> {
}
}

componentWillUnmount(): void {
// In case `interface_language` change wasn't saved.
loadUserLanguage();
}

static async fetchInitialData({
headers,
}: InitialFetchRequest): Promise<SettingsData> {
Expand Down Expand Up @@ -791,7 +800,7 @@ export class Settings extends Component<any, SettingsState> {
onChange={linkEvent(this, this.handleInterfaceLangChange)}
className="form-select d-inline-block w-auto"
>
<option disabled aria-hidden="true">
<option disabled aria-hidden="true" selected>
{I18NextService.i18n.t("interface_language")}
</option>
<option value="browser">
Expand Down Expand Up @@ -1451,6 +1460,12 @@ export class Settings extends Component<any, SettingsState> {
const newLang = event.target.value ?? "browser";
I18NextService.i18n.changeLanguage(
newLang === "browser" ? navigator.languages : newLang,
() => {
// Now the language is loaded, can be synchronous. Let the state update first.
window.requestAnimationFrame(() => {
i.forceUpdate();
});
},
);

i.setState(
Expand Down Expand Up @@ -1549,6 +1564,7 @@ export class Settings extends Component<any, SettingsState> {
});

UserService.Instance.myUserInfo = siteRes.data.my_user;
loadUserLanguage();
}

toast(I18NextService.i18n.t("saved"));
Expand Down
49 changes: 49 additions & 0 deletions src/shared/dynamic-imports.ts
@@ -0,0 +1,49 @@
import { verifyTranslationImports } from "./services/I18NextService";
import { verifyDateFnsImports } from "@utils/app/setup-date-fns";

export class ImportReport {
error: Array<{ id: string; error: Error | string | undefined }> = [];
success: string[] = [];
}

export type ImportReportCollection = {
translation?: ImportReport;
"date-fns"?: ImportReport;
};

function collect(
verbose: boolean,
kind: keyof ImportReportCollection,
collection: ImportReportCollection,
report: ImportReport,
) {
collection[kind] = report;
if (verbose) {
for (const { id, error } of report.error) {
console.warn(`${kind} "${id}" failed: ${error}`);
}
const good = report.success.length;
const bad = report.error.length;
if (bad) {
console.error(`${bad} out of ${bad + good} ${kind} imports failed.`);
} else {
console.log(`${good} ${kind} imports verified.`);
}
}
}

// This verifies that the parameters used for parameterized imports are
// correct, that the respective chunks are reachable or bundled, and that the
// returned objects match expectations.
export async function verifyDynamicImports(
verbose: boolean,
): Promise<ImportReportCollection> {
const collection: ImportReportCollection = {};
await verifyTranslationImports().then(report =>
collect(verbose, "translation", collection, report),
);
await verifyDateFnsImports().then(report =>
collect(verbose, "date-fns", collection, report),
);
return collection;
}
1 change: 1 addition & 0 deletions src/shared/interfaces.ts
Expand Up @@ -21,6 +21,7 @@ export type IsoDataOptionalSite<T extends RouteData = any> = Partial<
declare global {
interface Window {
isoData: IsoData;
checkLazyScripts?: () => void;
}
}

Expand Down

0 comments on commit e832cd2

Please sign in to comment.