diff --git a/src/account/Template.tsx b/src/account/Template.tsx index 41305926..63ce6e34 100644 --- a/src/account/Template.tsx +++ b/src/account/Template.tsx @@ -6,14 +6,14 @@ import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags"; import { useSetClassName } from "keycloakify/tools/useSetClassName"; import type { TemplateProps } from "keycloakify/account/TemplateProps"; import type { KcContext } from "./KcContext"; -import type { I18n } from "./i18n"; +import { useI18n } from "./i18n"; -export default function Template(props: TemplateProps) { - const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props; +export default function Template(props: TemplateProps) { + const { kcContext, doUseDefaultCss, active, classes, children } = props; const { getClassName } = useGetClassName({ doUseDefaultCss, classes }); - const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n; + const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = useI18n({ kcContext }); const { locale, url, features, realm, message, referrer } = kcContext; diff --git a/src/account/TemplateProps.ts b/src/account/TemplateProps.ts index 5a96dc3d..470f0a3a 100644 --- a/src/account/TemplateProps.ts +++ b/src/account/TemplateProps.ts @@ -1,13 +1,8 @@ import type { ReactNode } from "react"; import type { KcContext } from "./KcContext"; -import type { I18n } from "./i18n"; -export type TemplateProps< - KcContext extends KcContext.Common, - I18nExtended extends I18n -> = { +export type TemplateProps = { kcContext: KcContext; - i18n: I18nExtended; doUseDefaultCss: boolean; active: string; classes?: Partial>; diff --git a/src/account/i18n/i18n.tsx b/src/account/i18n/i18n.tsx index 3cd9d451..759533b4 100644 --- a/src/account/i18n/i18n.tsx +++ b/src/account/i18n/i18n.tsx @@ -1,8 +1,7 @@ import "keycloakify/tools/Object.fromEntries"; -import { useEffect, useState, useMemo } from "react"; -import { useConst } from "keycloakify/tools/useConst"; +import { useEffect, useState } from "react"; import { assert } from "tsafe/assert"; -import fallbackMessages from "./baseMessages/en"; +import messages_fallbackLanguage from "./baseMessages/en"; import { getMessages } from "./baseMessages"; import type { KcContext } from "../KcContext"; import { Reflect } from "tsafe/Reflect"; @@ -18,7 +17,7 @@ export type KcContextLike = { assert(); -export type MessageKey = keyof typeof fallbackMessages; +export type MessageKey = keyof typeof messages_fallbackLanguage; export type GenericI18n = { /** @@ -89,74 +88,109 @@ export type GenericI18n = { isFetchingTranslations: boolean; }; -export type I18n = GenericI18n; +function createGetI18n(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string } }) { + type I18n = GenericI18n; + + type Result = { i18n: I18n; prI18n_currentLanguage: Promise | undefined }; + + const cachedResultByKcContext = new WeakMap(); + + function getI18n(params: { kcContext: KcContextLike }): Result { + const { kcContext } = params; + + use_cache: { + const cachedResult = cachedResultByKcContext.get(kcContext); + + if (cachedResult === undefined) { + break use_cache; + } + + return cachedResult; + } + + const partialI18n: Pick = { + currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag, + getChangeLocalUrl: newLanguageTag => { + const { locale } = kcContext; + + assert(locale !== undefined, "Internationalization not enabled"); + + const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); + + assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); + + return targetSupportedLocale.url; + }, + labelBySupportedLanguageTag: Object.fromEntries((kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label])) + }; + + const { createI18nTranslationFunctions } = createI18nTranslationFunctionsFactory({ + messages_fallbackLanguage, + extraMessages_fallbackLanguage: extraMessages[fallbackLanguageTag], + extraMessages: extraMessages[partialI18n.currentLanguageTag] + }); + + const isCurrentLanguageFallbackLanguage = partialI18n.currentLanguageTag !== fallbackLanguageTag; + + const result: Result = { + i18n: { + ...partialI18n, + ...createI18nTranslationFunctions({ messages: undefined }), + isFetchingTranslations: !isCurrentLanguageFallbackLanguage + }, + prI18n_currentLanguage: isCurrentLanguageFallbackLanguage + ? undefined + : (async () => { + const messages = await getMessages(partialI18n.currentLanguageTag); + + const i18n_currentLanguage: I18n = { + ...partialI18n, + ...createI18nTranslationFunctions({ messages }), + isFetchingTranslations: false + }; + + // NOTE: This promise.resolve is just because without it we TypeScript + // gives a Variable 'result' is used before being assigned. error + await Promise.resolve().then(() => { + result.i18n = i18n_currentLanguage; + result.prI18n_currentLanguage = undefined; + }); + + return i18n_currentLanguage; + })() + }; + + cachedResultByKcContext.set(kcContext, result); + + return result; + } + + return { getI18n }; +} export function createUseI18n(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string }; }) { type I18n = GenericI18n; + const { getI18n } = createGetI18n(extraMessages); + function useI18n(params: { kcContext: KcContextLike }): I18n { const { kcContext } = params; - const partialI18n = useMemo( - (): Pick => ({ - currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag, - getChangeLocalUrl: newLanguageTag => { - const { locale } = kcContext; + const { i18n, prI18n_currentLanguage } = getI18n({ kcContext }); - assert(locale !== undefined, "Internationalization not enabled"); - - const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); - - assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); - - return targetSupportedLocale.url; - }, - labelBySupportedLanguageTag: Object.fromEntries( - (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) - ) - }), - [] - ); - - const { createI18nTranslationFunctions } = useMemo( - () => - createI18nTranslationFunctionsFactory({ - fallbackMessages, - extraFallbackMessages: extraMessages[fallbackLanguageTag], - extraMessages: extraMessages[partialI18n.currentLanguageTag] - }), - [] - ); - - const [i18n, setI18n] = useState(undefined); - - const refHasStartedFetching = useConst(() => ({ current: false })); + const [i18n_toReturn, setI18n_toReturn] = useState(i18n); useEffect(() => { - if (partialI18n.currentLanguageTag === fallbackLanguageTag) { - return; - } - - if (refHasStartedFetching.current) { - return; - } - let isActive = true; - refHasStartedFetching.current = true; - - getMessages(partialI18n.currentLanguageTag).then(messages => { + prI18n_currentLanguage?.then(i18n => { if (!isActive) { return; } - setI18n({ - ...partialI18n, - ...createI18nTranslationFunctions({ messages }), - isFetchingTranslations: false - }); + setI18n_toReturn(i18n); }); return () => { @@ -164,35 +198,22 @@ export function createUseI18n(extraMessa }; }, []); - const fallbackI18n = useMemo( - (): I18n => ({ - ...partialI18n, - ...createI18nTranslationFunctions({ messages: undefined }), - isFetchingTranslations: partialI18n.currentLanguageTag !== fallbackLanguageTag - }), - [] - ); - - return i18n ?? fallbackI18n; + return i18n_toReturn; } - return { - useI18n, - ofTypeI18n: Reflect() - }; + return { useI18n, ofTypeI18n: Reflect() }; } -/** Note exported only for hypothetical usage in non react framework */ -export function createI18nTranslationFunctionsFactory(params: { - fallbackMessages: Record; - extraFallbackMessages: Record | undefined; +function createI18nTranslationFunctionsFactory(params: { + messages_fallbackLanguage: Record; + extraMessages_fallbackLanguage: Record | undefined; extraMessages: Partial> | undefined; }) { const { extraMessages } = params; - const fallbackMessages = { - ...params.fallbackMessages, - ...params.extraFallbackMessages + const messages_fallbackLanguage = { + ...params.messages_fallbackLanguage, + ...params.extraMessages_fallbackLanguage }; function createI18nTranslationFunctions(params: { @@ -206,7 +227,7 @@ export function createI18nTranslationFunctionsFactory, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; +export default function Account(props: PageProps>) { + const { kcContext, doUseDefaultCss, Template, classes } = props; const { getClassName } = useGetClassName({ doUseDefaultCss, @@ -17,10 +17,10 @@ export default function Account(props: PageProps +