Refactor i18n so that we don't have to wait for translations to be downloaded to render the page

This commit is contained in:
Joseph Garrone
2024-06-08 15:50:04 +02:00
parent e3144adc61
commit 01fb89674c
2 changed files with 332 additions and 224 deletions

View File

@ -1,8 +1,10 @@
import "keycloakify/tools/Object.fromEntries"; import "keycloakify/tools/Object.fromEntries";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useMemo } from "react";
import { useConst } from "keycloakify/tools/useConst";
import { id } from "tsafe/id";
import { assert } from "tsafe/assert";
import fallbackMessages from "./baseMessages/en"; import fallbackMessages from "./baseMessages/en";
import { getMessages } from "./baseMessages"; import { getMessages } from "./baseMessages";
import { assert } from "tsafe/assert";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import { Reflect } from "tsafe/Reflect"; import { Reflect } from "tsafe/Reflect";
@ -86,35 +88,14 @@ export type I18n = GenericI18n<MessageKey>;
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: { export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string }; [languageTag: string]: { [key in ExtraMessageKey]: string };
}) { }) {
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null { type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n; isTranslationsDownloadOngoing: boolean } {
const { kcContext } = params; const { kcContext } = params;
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined); const partialI18n = useMemo(
(): Pick<I18n, "currentLanguageTag" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> => ({
const refHasStartedFetching = useRef(false); currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
refHasStartedFetching.current = true;
(async () => {
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
setI18n({
...createI18nTranslationFunctions({
fallbackMessages: {
...fallbackMessages,
...(extraMessages[fallbackLanguageTag] ?? {})
} as any,
messages: {
...(await getMessages(currentLanguageTag)),
...(extraMessages[currentLanguageTag] ?? {})
} as any
}),
currentLanguageTag,
getChangeLocalUrl: newLanguageTag => { getChangeLocalUrl: newLanguageTag => {
const { locale } = kcContext; const { locale } = kcContext;
@ -129,24 +110,94 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
labelBySupportedLanguageTag: Object.fromEntries( labelBySupportedLanguageTag: Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
) )
}),
[]
);
const { createI18nTranslationFunctions } = useMemo(
() =>
createI18nTranslationFunctionsFactory<MessageKey, ExtraMessageKey>({
fallbackMessages,
extraFallbackMessages: extraMessages[fallbackLanguageTag],
extraMessages: extraMessages[partialI18n.currentLanguageTag]
}),
[]
);
const [i18n, setI18n] = useState<I18n | undefined>(undefined);
const refHasStartedFetching = useConst(() => ({ current: false }));
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
let isActive = true;
refHasStartedFetching.current = true;
(async () => {
const messages = await getMessages(partialI18n.currentLanguageTag);
if (!isActive) {
return;
}
setI18n({
...partialI18n,
...createI18nTranslationFunctions({ messages })
}); });
})(); })();
return () => {
isActive = false;
};
}, []); }, []);
return i18n ?? null; const pendingI18n = useMemo(() => {
if (i18n !== undefined) {
return undefined;
}
return id<I18n>({
...partialI18n,
...createI18nTranslationFunctions({ messages: undefined })
});
}, []);
return {
i18n: i18n ?? (assert(pendingI18n !== undefined), pendingI18n),
isTranslationsDownloadOngoing: i18n === undefined
};
} }
return { return {
useI18n, useI18n,
ofTypeI18n: Reflect<GenericI18n<MessageKey | ExtraMessageKey>>() ofTypeI18n: Reflect<I18n>()
}; };
} }
function createI18nTranslationFunctions<MessageKey extends string>(params: { /** Note exported only for hypothetical usage in non react framework */
export function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>; fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>; extraFallbackMessages: Record<ExtraMessageKey, string> | undefined;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
const { fallbackMessages, messages } = params; }) {
const { extraMessages } = params;
const fallbackMessages = {
...params.fallbackMessages,
...params.extraFallbackMessages
};
function createI18nTranslationFunctions(params: {
messages: Partial<Record<MessageKey, string>> | undefined;
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const messages = {
...params.messages,
...extraMessages
};
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderAsHtml } = props; const { key, args, doRenderAsHtml } = props;
@ -241,4 +292,7 @@ function createI18nTranslationFunctions<MessageKey extends string>(params: {
doRenderAsHtml: false doRenderAsHtml: false
}) as string }) as string
}; };
}
return { createI18nTranslationFunctions };
} }

View File

@ -1,5 +1,7 @@
import "keycloakify/tools/Object.fromEntries"; import "keycloakify/tools/Object.fromEntries";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useMemo } from "react";
import { useConst } from "keycloakify/tools/useConst";
import { id } from "tsafe/id";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import fallbackMessages from "./baseMessages/en"; import fallbackMessages from "./baseMessages/en";
import { getMessages } from "./baseMessages"; import { getMessages } from "./baseMessages";
@ -87,36 +89,14 @@ export type I18n = GenericI18n<MessageKey>;
export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: { export function createUseI18n<ExtraMessageKey extends string = never>(extraMessages: {
[languageTag: string]: { [key in ExtraMessageKey]: string }; [languageTag: string]: { [key in ExtraMessageKey]: string };
}) { }) {
function useI18n(params: { kcContext: KcContextLike }): GenericI18n<MessageKey | ExtraMessageKey> | null { type I18n = GenericI18n<MessageKey | ExtraMessageKey>;
function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n; isTranslationsDownloadOngoing: boolean } {
const { kcContext } = params; const { kcContext } = params;
const [i18n, setI18n] = useState<GenericI18n<ExtraMessageKey | MessageKey> | undefined>(undefined); const partialI18n = useMemo(
(): Pick<I18n, "currentLanguageTag" | "getChangeLocalUrl" | "labelBySupportedLanguageTag"> => ({
const refHasStartedFetching = useRef(false); currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag,
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
refHasStartedFetching.current = true;
(async () => {
const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {};
setI18n({
...createI18nTranslationFunctions({
fallbackMessages: {
...fallbackMessages,
...(extraMessages[fallbackLanguageTag] ?? {})
} as any,
messages: {
...(await getMessages(currentLanguageTag)),
...(extraMessages[currentLanguageTag] ?? {})
} as any,
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
}),
currentLanguageTag,
getChangeLocalUrl: newLanguageTag => { getChangeLocalUrl: newLanguageTag => {
const { locale } = kcContext; const { locale } = kcContext;
@ -131,25 +111,96 @@ export function createUseI18n<ExtraMessageKey extends string = never>(extraMessa
labelBySupportedLanguageTag: Object.fromEntries( labelBySupportedLanguageTag: Object.fromEntries(
(kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])
) )
}),
[]
);
const { createI18nTranslationFunctions } = useMemo(
() =>
createI18nTranslationFunctionsFactory<MessageKey, ExtraMessageKey>({
fallbackMessages,
extraFallbackMessages: extraMessages[fallbackLanguageTag],
extraMessages: extraMessages[partialI18n.currentLanguageTag],
__localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile
}),
[]
);
const [i18n, setI18n] = useState<I18n | undefined>(undefined);
const refHasStartedFetching = useConst(() => ({ current: false }));
useEffect(() => {
if (refHasStartedFetching.current) {
return;
}
let isActive = true;
refHasStartedFetching.current = true;
(async () => {
const messages = await getMessages(partialI18n.currentLanguageTag);
if (!isActive) {
return;
}
setI18n({
...partialI18n,
...createI18nTranslationFunctions({ messages })
}); });
})(); })();
return () => {
isActive = false;
};
}, []); }, []);
return i18n ?? null; const pendingI18n = useMemo(() => {
if (i18n !== undefined) {
return undefined;
}
return id<I18n>({
...partialI18n,
...createI18nTranslationFunctions({ messages: undefined })
});
}, []);
return {
i18n: i18n ?? (assert(pendingI18n !== undefined), pendingI18n),
isTranslationsDownloadOngoing: i18n === undefined
};
} }
return { return {
useI18n, useI18n,
ofTypeI18n: Reflect<GenericI18n<MessageKey | ExtraMessageKey>>() ofTypeI18n: Reflect<I18n>()
}; };
} }
function createI18nTranslationFunctions<MessageKey extends string>(params: { /** Note exported only for hypothetical usage in non react framework */
export function createI18nTranslationFunctionsFactory<MessageKey extends string, ExtraMessageKey extends string>(params: {
fallbackMessages: Record<MessageKey, string>; fallbackMessages: Record<MessageKey, string>;
messages: Record<MessageKey, string>; extraFallbackMessages: Record<ExtraMessageKey, string> | undefined;
extraMessages: Partial<Record<ExtraMessageKey, string>> | undefined;
__localizationRealmOverridesUserProfile: Record<string, string> | undefined; __localizationRealmOverridesUserProfile: Record<string, string> | undefined;
}): Pick<GenericI18n<MessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { }) {
const { fallbackMessages, messages, __localizationRealmOverridesUserProfile } = params; const { __localizationRealmOverridesUserProfile, extraMessages } = params;
const fallbackMessages = {
...params.fallbackMessages,
...params.extraFallbackMessages
};
function createI18nTranslationFunctions(params: {
messages: Partial<Record<MessageKey, string>> | undefined;
}): Pick<GenericI18n<MessageKey | ExtraMessageKey>, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> {
const messages = {
...params.messages,
...extraMessages
};
function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined {
const { key, args, doRenderAsHtml } = props; const { key, args, doRenderAsHtml } = props;
@ -259,4 +310,7 @@ function createI18nTranslationFunctions<MessageKey extends string>(params: {
doRenderAsHtml: false doRenderAsHtml: false
}) as string }) as string
}; };
}
return { createI18nTranslationFunctions };
} }