Skip to content

Commit e832cd2

Browse files
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 <[email protected]>
1 parent c80136e commit e832cd2

13 files changed

+493
-115
lines changed

src/client/index.tsx

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1-
import { initializeSite, setupDateFns } from "@utils/app";
1+
import { initializeSite } from "@utils/app";
22
import { hydrate } from "inferno-hydrate";
33
import { BrowserRouter } from "inferno-router";
44
import { App } from "../shared/components/app/app";
5+
import { loadUserLanguage } from "../shared/services/I18NextService";
6+
import { verifyDynamicImports } from "../shared/dynamic-imports";
57

68
import "bootstrap/js/dist/collapse";
79
import "bootstrap/js/dist/dropdown";
810
import "bootstrap/js/dist/modal";
911

1012
async function startClient() {
13+
// Allows to test imports from the browser console.
14+
window.checkLazyScripts = () => {
15+
verifyDynamicImports(true).then(x => console.log(x));
16+
};
17+
1118
initializeSite(window.isoData.site_res);
1219

13-
await setupDateFns();
20+
await loadUserLanguage();
1421

1522
const wrapper = (
1623
<BrowserRouter>
@@ -22,6 +29,8 @@ async function startClient() {
2229

2330
if (root) {
2431
hydrate(wrapper, root);
32+
33+
root.dispatchEvent(new CustomEvent("lemmy-hydrated", { bubbles: true }));
2534
}
2635
}
2736

src/server/handlers/catch-all-handler.tsx

+40-1
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,26 @@ import { createSsrHtml } from "../utils/create-ssr-html";
2020
import { getErrorPageData } from "../utils/get-error-page-data";
2121
import { setForwardedHeaders } from "../utils/set-forwarded-headers";
2222
import { getJwtCookie } from "../utils/has-jwt-cookie";
23+
import {
24+
I18NextService,
25+
LanguageService,
26+
UserService,
27+
} from "../../shared/services/";
2328

2429
export default async (req: Request, res: Response) => {
2530
try {
31+
const languages: string[] =
32+
req.headers["accept-language"]
33+
?.split(",")
34+
.map(x => {
35+
const [head, tail] = x.split(/;\s*q?\s*=?/); // at ";", remove "q="
36+
const q = Number(tail ?? 1); // no q means q=1
37+
return { lang: head.trim(), q: Number.isNaN(q) ? 0 : q };
38+
})
39+
.filter(x => x.lang)
40+
.sort((a, b) => b.q - a.q)
41+
.map(x => (x.lang === "*" ? "en" : x.lang)) ?? [];
42+
2643
const activeRoute = routes.find(route => matchPath(req.path, route));
2744

2845
const headers = setForwardedHeaders(req.headers);
@@ -60,6 +77,7 @@ export default async (req: Request, res: Response) => {
6077
if (try_site.state === "success") {
6178
site = try_site.data;
6279
initializeSite(site);
80+
LanguageService.updateLanguages(languages);
6381

6482
if (path !== "/setup" && !site.site_view.local_site.site_setup) {
6583
return res.redirect("/setup");
@@ -73,6 +91,16 @@ export default async (req: Request, res: Response) => {
7391
headers,
7492
};
7593

94+
if (process.env.NODE_ENV === "development") {
95+
setTimeout(() => {
96+
// Intentionally (likely) break things if fetchInitialData tries to
97+
// use global state after the first await of an unresolved promise.
98+
// This simulates another request entering or leaving this
99+
// "success" block.
100+
UserService.Instance.myUserInfo = undefined;
101+
I18NextService.i18n.changeLanguage("cimode");
102+
});
103+
}
76104
routeData = await activeRoute.fetchInitialData(initialFetchReq);
77105
}
78106

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

145+
// Another request could have initialized a new site.
146+
initializeSite(site);
147+
LanguageService.updateLanguages(languages);
148+
117149
const root = renderToString(wrapper);
118150

119-
res.send(await createSsrHtml(root, isoData, res.locals.cspNonce));
151+
res.send(
152+
await createSsrHtml(
153+
root,
154+
isoData,
155+
res.locals.cspNonce,
156+
LanguageService.userLanguages,
157+
),
158+
);
120159
} catch (err) {
121160
// If an error is caught here, the error page couldn't even be rendered
122161
console.error(err);

src/server/index.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import ThemeHandler from "./handlers/theme-handler";
1313
import ThemesListHandler from "./handlers/themes-list-handler";
1414
import { setCacheControl, setDefaultCsp } from "./middleware";
1515
import CodeThemeHandler from "./handlers/code-theme-handler";
16+
import { verifyDynamicImports } from "../shared/dynamic-imports";
1617

1718
const server = express();
1819

@@ -54,6 +55,8 @@ server.get("/css/themelist", ThemesListHandler);
5455
server.get("/*", CatchAllHandler);
5556

5657
const listener = server.listen(Number(port), hostname, () => {
58+
verifyDynamicImports(true);
59+
5760
setupDateFns();
5861
console.log(
5962
`Lemmy-ui v${VERSION} started listening on http://${hostname}:${port}`,

src/server/utils/create-ssr-html.tsx

+13
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { favIconPngUrl, favIconUrl } from "../../shared/config";
77
import { IsoDataOptionalSite } from "../../shared/interfaces";
88
import { buildThemeList } from "./build-themes-list";
99
import { fetchIconPng } from "./fetch-icon-png";
10+
import { findTranslationChunkNames } from "../../shared/services/I18NextService";
11+
import { findDateFnsChunkNames } from "../../shared/utils/app/setup-date-fns";
1012

1113
const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || "";
1214

@@ -16,6 +18,7 @@ export async function createSsrHtml(
1618
root: string,
1719
isoData: IsoDataOptionalSite,
1820
cspNonce: string,
21+
userLanguages: readonly string[],
1922
) {
2023
const site = isoData.site_res;
2124

@@ -63,10 +66,20 @@ export async function createSsrHtml(
6366

6467
const helmet = Helmet.renderStatic();
6568

69+
const lazyScripts = [
70+
...findTranslationChunkNames(userLanguages),
71+
...findDateFnsChunkNames(userLanguages),
72+
]
73+
.filter(x => x !== undefined)
74+
.map(x => `${getStaticDir()}/js/${x}.client.js`)
75+
.map(x => `<link rel="preload" as="script" href="${x}" />`)
76+
.join("");
77+
6678
return `
6779
<!DOCTYPE html>
6880
<html ${helmet.htmlAttributes.toString()}>
6981
<head>
82+
${lazyScripts}
7083
<script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>
7184
7285
<!-- A remote debugging utility for mobile -->

src/shared/components/app/navbar.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
7878
UnreadCounterService.Instance.unreadApplicationCountSubject.subscribe(
7979
unreadApplicationCount => this.setState({ unreadApplicationCount }),
8080
);
81-
this.requestNotificationPermission();
8281

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

469468
requestNotificationPermission() {
470469
if (UserService.Instance.myUserInfo) {
471-
document.addEventListener("DOMContentLoaded", function () {
470+
document.addEventListener("lemmy-hydrated", function () {
472471
if (!Notification) {
473472
toast(I18NextService.i18n.t("notifications_error"), "danger");
474473
return;

src/shared/components/person/settings.tsx

+18-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ import {
4545
RequestState,
4646
wrapClient,
4747
} from "../../services/HttpService";
48-
import { I18NextService, languages } from "../../services/I18NextService";
48+
import {
49+
I18NextService,
50+
languages,
51+
loadUserLanguage,
52+
} from "../../services/I18NextService";
4953
import { setupTippy } from "../../tippy";
5054
import { toast } from "../../toast";
5155
import { HtmlTags } from "../common/html-tags";
@@ -335,6 +339,11 @@ export class Settings extends Component<any, SettingsState> {
335339
}
336340
}
337341

342+
componentWillUnmount(): void {
343+
// In case `interface_language` change wasn't saved.
344+
loadUserLanguage();
345+
}
346+
338347
static async fetchInitialData({
339348
headers,
340349
}: InitialFetchRequest): Promise<SettingsData> {
@@ -791,7 +800,7 @@ export class Settings extends Component<any, SettingsState> {
791800
onChange={linkEvent(this, this.handleInterfaceLangChange)}
792801
className="form-select d-inline-block w-auto"
793802
>
794-
<option disabled aria-hidden="true">
803+
<option disabled aria-hidden="true" selected>
795804
{I18NextService.i18n.t("interface_language")}
796805
</option>
797806
<option value="browser">
@@ -1451,6 +1460,12 @@ export class Settings extends Component<any, SettingsState> {
14511460
const newLang = event.target.value ?? "browser";
14521461
I18NextService.i18n.changeLanguage(
14531462
newLang === "browser" ? navigator.languages : newLang,
1463+
() => {
1464+
// Now the language is loaded, can be synchronous. Let the state update first.
1465+
window.requestAnimationFrame(() => {
1466+
i.forceUpdate();
1467+
});
1468+
},
14541469
);
14551470

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

15511566
UserService.Instance.myUserInfo = siteRes.data.my_user;
1567+
loadUserLanguage();
15521568
}
15531569

15541570
toast(I18NextService.i18n.t("saved"));

src/shared/dynamic-imports.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { verifyTranslationImports } from "./services/I18NextService";
2+
import { verifyDateFnsImports } from "@utils/app/setup-date-fns";
3+
4+
export class ImportReport {
5+
error: Array<{ id: string; error: Error | string | undefined }> = [];
6+
success: string[] = [];
7+
}
8+
9+
export type ImportReportCollection = {
10+
translation?: ImportReport;
11+
"date-fns"?: ImportReport;
12+
};
13+
14+
function collect(
15+
verbose: boolean,
16+
kind: keyof ImportReportCollection,
17+
collection: ImportReportCollection,
18+
report: ImportReport,
19+
) {
20+
collection[kind] = report;
21+
if (verbose) {
22+
for (const { id, error } of report.error) {
23+
console.warn(`${kind} "${id}" failed: ${error}`);
24+
}
25+
const good = report.success.length;
26+
const bad = report.error.length;
27+
if (bad) {
28+
console.error(`${bad} out of ${bad + good} ${kind} imports failed.`);
29+
} else {
30+
console.log(`${good} ${kind} imports verified.`);
31+
}
32+
}
33+
}
34+
35+
// This verifies that the parameters used for parameterized imports are
36+
// correct, that the respective chunks are reachable or bundled, and that the
37+
// returned objects match expectations.
38+
export async function verifyDynamicImports(
39+
verbose: boolean,
40+
): Promise<ImportReportCollection> {
41+
const collection: ImportReportCollection = {};
42+
await verifyTranslationImports().then(report =>
43+
collect(verbose, "translation", collection, report),
44+
);
45+
await verifyDateFnsImports().then(report =>
46+
collect(verbose, "date-fns", collection, report),
47+
);
48+
return collection;
49+
}

src/shared/interfaces.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type IsoDataOptionalSite<T extends RouteData = any> = Partial<
2121
declare global {
2222
interface Window {
2323
isoData: IsoData;
24+
checkLazyScripts?: () => void;
2425
}
2526
}
2627

0 commit comments

Comments
 (0)