From 8d365dae5367819fe398fed3ecaf5d22f4fb6ec7 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 8 Jun 2024 17:55:05 +0200 Subject: [PATCH] Refactor i18n, make component use the hook directly --- src/account/i18n/i18n.tsx | 44 ++++--- src/account/i18n/index.ts | 12 +- src/login/Fallback.tsx | 3 +- src/login/Template.tsx | 7 +- src/login/TemplateProps.ts | 7 +- src/login/UserProfileFormFields.tsx | 3 +- src/login/i18n/i18n.tsx | 189 ++++++++++++++++------------ src/login/i18n/index.ts | 12 +- src/login/pages/Code.tsx | 10 +- src/login/pages/PageProps.ts | 4 +- 10 files changed, 163 insertions(+), 128 deletions(-) diff --git a/src/account/i18n/i18n.tsx b/src/account/i18n/i18n.tsx index b05d19b4..3cd9d451 100644 --- a/src/account/i18n/i18n.tsx +++ b/src/account/i18n/i18n.tsx @@ -1,7 +1,6 @@ import "keycloakify/tools/Object.fromEntries"; 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 { getMessages } from "./baseMessages"; @@ -81,6 +80,13 @@ export type GenericI18n = { * See advancedMsg() but instead of returning a JSX.Element it returns a string. */ advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; + + /** + * Initially the messages are in english (fallback language). + * The translations in the current language are being fetched dynamically. + * This property is true while the translations are being fetched. + */ + isFetchingTranslations: boolean; }; export type I18n = GenericI18n; @@ -90,7 +96,7 @@ export function createUseI18n(extraMessa }) { type I18n = GenericI18n; - function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n; isTranslationsDownloadOngoing: boolean } { + function useI18n(params: { kcContext: KcContextLike }): I18n { const { kcContext } = params; const partialI18n = useMemo( @@ -129,6 +135,10 @@ export function createUseI18n(extraMessa const refHasStartedFetching = useConst(() => ({ current: false })); useEffect(() => { + if (partialI18n.currentLanguageTag === fallbackLanguageTag) { + return; + } + if (refHasStartedFetching.current) { return; } @@ -137,39 +147,33 @@ export function createUseI18n(extraMessa refHasStartedFetching.current = true; - (async () => { - const messages = await getMessages(partialI18n.currentLanguageTag); - + getMessages(partialI18n.currentLanguageTag).then(messages => { if (!isActive) { return; } setI18n({ ...partialI18n, - ...createI18nTranslationFunctions({ messages }) + ...createI18nTranslationFunctions({ messages }), + isFetchingTranslations: false }); - })(); + }); return () => { isActive = false; }; }, []); - const pendingI18n = useMemo(() => { - if (i18n !== undefined) { - return undefined; - } - - return id({ + const fallbackI18n = useMemo( + (): I18n => ({ ...partialI18n, - ...createI18nTranslationFunctions({ messages: undefined }) - }); - }, []); + ...createI18nTranslationFunctions({ messages: undefined }), + isFetchingTranslations: partialI18n.currentLanguageTag !== fallbackLanguageTag + }), + [] + ); - return { - i18n: i18n ?? (assert(pendingI18n !== undefined), pendingI18n), - isTranslationsDownloadOngoing: i18n === undefined - }; + return i18n ?? fallbackI18n; } return { diff --git a/src/account/i18n/index.ts b/src/account/i18n/index.ts index 98e315a1..4d326433 100644 --- a/src/account/i18n/index.ts +++ b/src/account/i18n/index.ts @@ -1,2 +1,10 @@ -export type { I18n } from "./i18n"; -export { createUseI18n } from "./i18n"; +export type { MessageKey } from "./i18n"; +import { createUseI18n } from "./i18n"; +export { createUseI18n }; +export { fallbackLanguageTag } from "./i18n"; + +const { useI18n, ofTypeI18n } = createUseI18n({}); + +export type I18n = typeof ofTypeI18n; + +export { useI18n }; diff --git a/src/login/Fallback.tsx b/src/login/Fallback.tsx index 0dc6e6f1..8b395502 100644 --- a/src/login/Fallback.tsx +++ b/src/login/Fallback.tsx @@ -3,7 +3,6 @@ import { assert, type Equals } from "tsafe/assert"; import type { LazyOrNot } from "keycloakify/tools/LazyOrNot"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "./KcContext"; -import type { I18n } from "./i18n"; import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFields"; const Login = lazy(() => import("keycloakify/login/pages/Login")); @@ -41,7 +40,7 @@ const LoginResetOtp = lazy(() => import("keycloakify/login/pages/LoginResetOtp") const LoginX509Info = lazy(() => import("keycloakify/login/pages/LoginX509Info")); const WebauthnError = lazy(() => import("keycloakify/login/pages/WebauthnError")); -type FallbackProps = PageProps & { +type FallbackProps = PageProps & { UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>; }; diff --git a/src/login/Template.tsx b/src/login/Template.tsx index 04572f71..189b5ef7 100644 --- a/src/login/Template.tsx +++ b/src/login/Template.tsx @@ -7,9 +7,9 @@ import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags"; import { useSetClassName } from "keycloakify/tools/useSetClassName"; import type { KcContext } from "./KcContext"; -import type { I18n } from "./i18n"; +import { useI18n } from "./i18n"; -export default function Template(props: TemplateProps) { +export default function Template(props: TemplateProps) { const { displayInfo = false, displayMessage = true, @@ -21,7 +21,6 @@ export default function Template(props: TemplateProps) { documentTitle, bodyClassName, kcContext, - i18n, doUseDefaultCss, classes, children @@ -29,7 +28,7 @@ export default function Template(props: TemplateProps) { const { getClassName } = useGetClassName({ doUseDefaultCss, classes }); - const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = i18n; + const { msg, msgStr, getChangeLocalUrl, labelBySupportedLanguageTag, currentLanguageTag } = useI18n({ kcContext }); const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext; diff --git a/src/login/TemplateProps.ts b/src/login/TemplateProps.ts index 2837cf0e..4ebb6226 100644 --- a/src/login/TemplateProps.ts +++ b/src/login/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; classes?: Partial>; diff --git a/src/login/UserProfileFormFields.tsx b/src/login/UserProfileFormFields.tsx index 3d9137af..bb5e3c7d 100644 --- a/src/login/UserProfileFormFields.tsx +++ b/src/login/UserProfileFormFields.tsx @@ -9,11 +9,10 @@ import { type FormFieldError } from "keycloakify/login/lib/useUserProfileForm"; import type { Attribute } from "keycloakify/login/KcContext"; -import type { I18n } from "./i18n"; +import { useI18n } from "./i18n"; export type UserProfileFormFieldsProps = { kcContext: KcContextLike; - i18n: I18n; getClassName: (classKey: ClassKey) => string; onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void; BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null; diff --git a/src/login/i18n/i18n.tsx b/src/login/i18n/i18n.tsx index 1450624c..ed10a04e 100644 --- a/src/login/i18n/i18n.tsx +++ b/src/login/i18n/i18n.tsx @@ -1,9 +1,7 @@ import "keycloakify/tools/Object.fromEntries"; -import { useEffect, useState, useMemo } from "react"; -import { useConst } from "keycloakify/tools/useConst"; -import { id } from "tsafe/id"; +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"; @@ -20,7 +18,7 @@ export type KcContextLike = { assert(); -export type MessageKey = keyof typeof fallbackMessages; +export type MessageKey = keyof typeof messages_fallbackLanguage; export type GenericI18n = { /** @@ -82,116 +80,143 @@ export type GenericI18n = { * See advancedMsg() but instead of returning a JSX.Element it returns a string. */ advancedMsgStr: (key: string, ...args: (string | undefined)[]) => string; + + /** + * Initially the messages are in english (fallback language). + * The translations in the current language are being fetched dynamically. + * This property is true while the translations are being fetched. + */ + 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], + __localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile + }); + + 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; - function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n; isTranslationsDownloadOngoing: boolean } { + 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], - __localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile - }), - [] - ); - - const [i18n, setI18n] = useState(undefined); - - const refHasStartedFetching = useConst(() => ({ current: false })); + const [i18n_toReturn, setI18n_toReturn] = useState(i18n); useEffect(() => { - if (refHasStartedFetching.current) { - return; - } - let isActive = true; - refHasStartedFetching.current = true; - - (async () => { - const messages = await getMessages(partialI18n.currentLanguageTag); - + prI18n_currentLanguage?.then(i18n => { if (!isActive) { return; } - setI18n({ - ...partialI18n, - ...createI18nTranslationFunctions({ messages }) - }); - })(); + setI18n_toReturn(i18n); + }); return () => { isActive = false; }; }, []); - const pendingI18n = useMemo(() => { - if (i18n !== undefined) { - return undefined; - } - - return id({ - ...partialI18n, - ...createI18nTranslationFunctions({ messages: undefined }) - }); - }, []); - - return { - i18n: i18n ?? (assert(pendingI18n !== undefined), pendingI18n), - isTranslationsDownloadOngoing: i18n === undefined - }; + 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; __localizationRealmOverridesUserProfile: Record | undefined; }) { const { __localizationRealmOverridesUserProfile, extraMessages } = params; - const fallbackMessages = { - ...params.fallbackMessages, - ...params.extraFallbackMessages + const messages_fallbackLanguage = { + ...params.messages_fallbackLanguage, + ...params.extraMessages_fallbackLanguage }; function createI18nTranslationFunctions(params: { @@ -205,7 +230,7 @@ export function createI18nTranslationFunctionsFactory, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; +export default function Code(props: PageProps>) { + const { kcContext, doUseDefaultCss, Template, classes } = props; const { getClassName } = useGetClassName({ doUseDefaultCss, @@ -13,11 +13,11 @@ export default function Code(props: PageProps
diff --git a/src/login/pages/PageProps.ts b/src/login/pages/PageProps.ts index 8c8a30b1..88a3d736 100644 --- a/src/login/pages/PageProps.ts +++ b/src/login/pages/PageProps.ts @@ -1,12 +1,10 @@ -import type { I18n } from "keycloakify/login/i18n"; import { type TemplateProps, type ClassKey } from "keycloakify/login/TemplateProps"; import type { LazyOrNot } from "keycloakify/tools/LazyOrNot"; import type { KcContext } from "keycloakify/account/KcContext"; -export type PageProps = { +export type PageProps = { Template: LazyOrNot<(props: TemplateProps) => JSX.Element | null>; kcContext: NarowedKcContext; - i18n: I18nExtended; doUseDefaultCss: boolean; classes?: Partial>; };