diff --git a/src/account/Template.tsx b/src/account/Template.tsx index b6edc233..206cebc0 100644 --- a/src/account/Template.tsx +++ b/src/account/Template.tsx @@ -2,14 +2,12 @@ import { useEffect } from "react"; import { clsx } from "keycloakify/tools/clsx"; import { type TemplateProps } from "keycloakify/account/TemplateProps"; import { useGetClassName } from "keycloakify/account/lib/useGetClassName"; -import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags"; +import { useInsertLinkTags } from "keycloakify/tools/useInsertLinkTags"; import { useSetClassName } from "keycloakify/tools/useSetClassName"; import type { KcContext } from "./kcContext"; import type { I18n } from "./i18n"; import { assert } from "keycloakify/tools/assert"; -const { useInsertLinkTags } = createUseInsertLinkTags(); - export default function Template(props: TemplateProps) { const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props; @@ -46,6 +44,7 @@ export default function Template(props: TemplateProps) { }, []); const { areAllStyleSheetsLoaded } = useInsertLinkTags({ + componentOrHookName: "Template", hrefs: !doUseDefaultCss ? [] : [ diff --git a/src/account/kcContext/getKcContextMock.ts b/src/account/kcContext/getKcContextMock.ts index 23b32d03..0735bb81 100644 --- a/src/account/kcContext/getKcContextMock.ts +++ b/src/account/kcContext/getKcContextMock.ts @@ -1,5 +1,5 @@ import type { ExtendKcContext, KcContext as KcContextBase } from "./KcContext"; -import type { LoginThemePageId } from "keycloakify/bin/shared/constants"; +import type { AccountThemePageId } from "keycloakify/bin/shared/constants"; import type { DeepPartial } from "keycloakify/tools/DeepPartial"; import { deepAssign } from "keycloakify/tools/deepAssign"; import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions"; @@ -18,7 +18,7 @@ export function createGetKcContextMock< overrides?: DeepPartial; overridesPerPage?: { [PageId in - | LoginThemePageId + | AccountThemePageId | keyof KcContextExtraPropertiesPerPage]?: DeepPartial< Extract< ExtendKcContext< @@ -43,7 +43,7 @@ export function createGetKcContextMock< >; function getKcContextMock< - PageId extends LoginThemePageId | keyof KcContextExtraPropertiesPerPage + PageId extends AccountThemePageId | keyof KcContextExtraPropertiesPerPage >(params: { pageId: PageId; overrides?: DeepPartial>; diff --git a/src/login/Template.tsx b/src/login/Template.tsx index 4d8ad7b8..8a31fbfe 100644 --- a/src/login/Template.tsx +++ b/src/login/Template.tsx @@ -3,15 +3,12 @@ import { assert } from "keycloakify/tools/assert"; import { clsx } from "keycloakify/tools/clsx"; import { type TemplateProps } from "keycloakify/login/TemplateProps"; import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; -import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; -import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags"; +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"; -const { useInsertLinkTags } = createUseInsertLinkTags(); -const { useInsertScriptTags } = createUseInsertScriptTags(); - export default function Template(props: TemplateProps) { const { displayInfo = false, @@ -63,6 +60,7 @@ export default function Template(props: TemplateProps) { }, []); const { areAllStyleSheetsLoaded } = useInsertLinkTags({ + componentOrHookName: "Template", hrefs: !doUseDefaultCss ? [] : [ @@ -75,6 +73,7 @@ export default function Template(props: TemplateProps) { }); const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "Template", scriptTags: [ { type: "module", diff --git a/src/login/lib/useDownloadTerms.ts b/src/login/lib/useDownloadTerms.ts index a90c46b1..c1b60e0c 100644 --- a/src/login/lib/useDownloadTerms.ts +++ b/src/login/lib/useDownloadTerms.ts @@ -1,14 +1,11 @@ -import { useEffect } from "react"; -import { memoize } from "keycloakify/tools/memoize"; import { fallbackLanguageTag } from "keycloakify/login/i18n/i18n"; -import { useConst } from "keycloakify/tools/useConst"; -import { useConstCallback } from "keycloakify/tools/useConstCallback"; import { assert } from "tsafe/assert"; import { createStatefulObservable, useRerenderOnChange } from "keycloakify/tools/StatefulObservable"; import { KcContext } from "../kcContext"; +import { useOnFistMount } from "keycloakify/tools/useOnFirstMount"; const obsTermsMarkdown = createStatefulObservable(() => undefined); @@ -27,29 +24,18 @@ export function useDownloadTerms(params: { kcContext: KcContextLike; downloadTermMarkdown: (params: { currentLanguageTag: string }) => Promise; }) { - const { kcContext } = params; + const { kcContext, downloadTermMarkdown } = params; - const { downloadTermMarkdownMemoized } = (function useClosure() { - const { downloadTermMarkdown } = params; - - const downloadTermMarkdownConst = useConstCallback(downloadTermMarkdown); - - const downloadTermMarkdownMemoized = useConst(() => - memoize((currentLanguageTag: string) => - downloadTermMarkdownConst({ currentLanguageTag }) - ) - ); - - return { downloadTermMarkdownMemoized }; - })(); - - useEffect(() => { + useOnFistMount(async () => { if (kcContext.pageId === "terms.ftl" || kcContext.termsAcceptanceRequired) { - downloadTermMarkdownMemoized( - kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag - ).then(thermMarkdown => (obsTermsMarkdown.current = thermMarkdown)); + const termsMarkdown = await downloadTermMarkdown({ + currentLanguageTag: + kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag + }); + + obsTermsMarkdown.current = termsMarkdown; } - }, []); + }); } export function useTermsMarkdown() { diff --git a/src/login/lib/useGetClassName.ts b/src/login/lib/useGetClassName.ts index 3a845baa..16aff202 100644 --- a/src/login/lib/useGetClassName.ts +++ b/src/login/lib/useGetClassName.ts @@ -3,6 +3,7 @@ import type { ClassKey } from "keycloakify/login/TemplateProps"; export const { useGetClassName } = createUseClassName({ defaultClasses: { + kcHtmlClass: "login-pf", kcBodyClass: undefined, kcHeaderWrapperClass: undefined, kcLocaleWrapperClass: undefined, @@ -54,7 +55,6 @@ export const { useGetClassName } = createUseClassName({ kcLogoLink: "http://www.keycloak.org", kcContainerClass: "container-fluid", kcSelectAuthListItemTitle: "select-auth-box-paragraph", - kcHtmlClass: "login-pf", kcLoginOTPListItemTitleClass: "pf-c-tile__title", "kcLogoIdP-openshift-v4": "pf-icon pf-icon-openshift", kcWebAuthnUnknownIcon: "pficon pficon-key unknown-transport-class", diff --git a/src/login/lib/useUserProfileForm.tsx b/src/login/lib/useUserProfileForm.tsx index 3ba2570e..f2b35a80 100644 --- a/src/login/lib/useUserProfileForm.tsx +++ b/src/login/lib/useUserProfileForm.tsx @@ -8,7 +8,7 @@ import { emailRegexp } from "keycloakify/tools/emailRegExp"; import type { KcContext, PasswordPolicies } from "keycloakify/login/kcContext/KcContext"; import { assert, type Equals } from "tsafe/assert"; import { formatNumber } from "keycloakify/tools/formatNumber"; -import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions"; import type { I18n } from "../i18n"; @@ -103,12 +103,11 @@ namespace internal { }; } -const { useInsertScriptTags } = createUseInsertScriptTags(); - export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm { const { kcContext, i18n, doMakeUserConfirmPassword } = params; const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "useUserProfileForm", scriptTags: Object.keys(kcContext.profile?.html5DataAnnotations ?? {}) .filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it. .map(key => ({ diff --git a/src/login/pages/LoginRecoveryAuthnCodeConfig.tsx b/src/login/pages/LoginRecoveryAuthnCodeConfig.tsx index 3bc80936..1d4f9c12 100644 --- a/src/login/pages/LoginRecoveryAuthnCodeConfig.tsx +++ b/src/login/pages/LoginRecoveryAuthnCodeConfig.tsx @@ -2,12 +2,10 @@ import { useEffect } from "react"; import { clsx } from "keycloakify/tools/clsx"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import { useGetClassName } from "keycloakify/login/lib/useGetClassName"; -import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; -const { useInsertScriptTags } = createUseInsertScriptTags(); - export default function LoginRecoveryAuthnCodeConfig(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; @@ -21,6 +19,7 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; @@ -31,6 +29,7 @@ export default function WebauthnAuthenticate(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; @@ -35,6 +33,7 @@ export default function WebauthnRegister(props: PageProps): void { +export function useRerenderOnChange(obs: StatefulObservable): void { //NOTE: We use function in case the state is a function - const [, setCurrent] = useState(() => $.current); + const [, setCurrent] = useState(() => obs.current); useObservable( ({ registerSubscription }) => { - const subscription = $.subscribe(current => setCurrent(() => current)); + const subscription = obs.subscribe(current => setCurrent(() => current)); registerSubscription(subscription); }, - [$] + [obs] ); } diff --git a/src/tools/memoize.ts b/src/tools/memoize.ts deleted file mode 100644 index 689720dc..00000000 --- a/src/tools/memoize.ts +++ /dev/null @@ -1,55 +0,0 @@ -type SimpleType = number | string | boolean | null | undefined; -type FuncWithSimpleParams = (...args: T) => R; - -export function memoize( - fn: FuncWithSimpleParams, - options?: { - argsLength?: number; - max?: number; - } -): FuncWithSimpleParams { - const cache = new Map>>(); - - const { argsLength = fn.length, max = Infinity } = options ?? {}; - - return ((...args: Parameters>) => { - const key = JSON.stringify( - args - .slice(0, argsLength) - .map(v => { - if (v === null) { - return "null"; - } - if (v === undefined) { - return "undefined"; - } - switch (typeof v) { - case "number": - return `number-${v}`; - case "string": - return `string-${v}`; - case "boolean": - return `boolean-${v ? "true" : "false"}`; - } - }) - .join("-sIs9sAslOdeWlEdIos3-") - ); - - if (cache.has(key)) { - return cache.get(key); - } - - if (max === cache.size) { - for (const key of cache.keys()) { - cache.delete(key); - break; - } - } - - const value = fn(...args); - - cache.set(key, value); - - return value; - }) as any; -} diff --git a/src/tools/useInsertLinkTags.ts b/src/tools/useInsertLinkTags.ts index dd3d2f2d..d34bf716 100644 --- a/src/tools/useInsertLinkTags.ts +++ b/src/tools/useInsertLinkTags.ts @@ -1,8 +1,9 @@ -import { useEffect, useState } from "react"; -import { - createStatefulObservable, - useRerenderOnChange -} from "keycloakify/tools/StatefulObservable"; +import { useEffect, useReducer } from "react"; +import { useConst } from "keycloakify/tools/useConst"; +import { id } from "tsafe/id"; +import { useOnFistMount } from "keycloakify/tools/useOnFirstMount"; + +const alreadyMountedComponentOrHookNames = new Set(); /** * NOTE: The component that use this hook can only be mounded once! @@ -10,28 +11,37 @@ import { * If it's mounted again the page will be reloaded. * This simulates the behavior of a server rendered page that imports css stylesheet in the head. */ -export function createUseInsertLinkTags() { - let isFistMount = true; +export function useInsertLinkTags(params: { + componentOrHookName: string; + hrefs: string[]; +}) { + const { hrefs, componentOrHookName } = params; - const obsAreAllStyleSheetsLoaded = createStatefulObservable(() => false); + useOnFistMount(() => { + const isAlreadyMounted = + alreadyMountedComponentOrHookNames.has(componentOrHookName); - function useInsertLinkTags(params: { hrefs: string[] }) { - const { hrefs } = params; + if (isAlreadyMounted) { + window.location.reload(); + return; + } - useRerenderOnChange(obsAreAllStyleSheetsLoaded); + alreadyMountedComponentOrHookNames.add(componentOrHookName); + }); - useState(() => { - if (!isFistMount) { - window.location.reload(); - return; - } + const [areAllStyleSheetsLoaded, setAllStyleSheetsLoaded] = useReducer( + () => true, + false + ); - isFistMount = false; - }); + const refPrAllStyleSheetLoaded = useConst(() => ({ + current: id | undefined>(undefined) + })); - useEffect(() => { - let isActive = true; + useEffect(() => { + let isActive = true; + (refPrAllStyleSheetLoaded.current ??= (async () => { let lastMountedHtmlElement: HTMLLinkElement | undefined = undefined; const prs: Promise[] = []; @@ -58,20 +68,19 @@ export function createUseInsertLinkTags() { lastMountedHtmlElement = htmlElement; } - Promise.all(prs).then(() => { - if (!isActive) { - return; - } - obsAreAllStyleSheetsLoaded.current = true; - }); + await Promise.all(prs); + })()).then(() => { + if (!isActive) { + return; + } - return () => { - isActive = false; - }; - }, []); + setAllStyleSheetsLoaded(); + }); - return { areAllStyleSheetsLoaded: obsAreAllStyleSheetsLoaded.current }; - } + return () => { + isActive = false; + }; + }, []); - return { useInsertLinkTags }; + return { areAllStyleSheetsLoaded }; } diff --git a/src/tools/useInsertScriptTags.ts b/src/tools/useInsertScriptTags.ts index 22b4b558..2b6f03da 100644 --- a/src/tools/useInsertScriptTags.ts +++ b/src/tools/useInsertScriptTags.ts @@ -1,5 +1,6 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { assert } from "tsafe/assert"; +import { useOnFistMount } from "keycloakify/tools/useOnFirstMount"; export type ScriptTag = ScriptTag.TextContent | ScriptTag.Src; @@ -16,80 +17,86 @@ export namespace ScriptTag { }; } +const alreadyMountedComponentOrHookNames = new Set(); + /** * NOTE: The component that use this hook can only be mounded once! - * And can'r rerender with different scriptTags. + * And can't rerender with different scriptTags. * If it's mounted again the page will be reloaded. * This simulates the behavior of a server rendered page that imports javascript in the head. + * + * The returned function is supposed to be called in a useEffect and + * will not download the scripts multiple times event if called more than once (react strict mode). + * */ -export function createUseInsertScriptTags() { +export function useInsertScriptTags(params: { + componentOrHookName: string; + scriptTags: ScriptTag[]; +}) { + const { scriptTags, componentOrHookName } = params; + + useOnFistMount(() => { + const isAlreadyMounted = + alreadyMountedComponentOrHookNames.has(componentOrHookName); + + if (isAlreadyMounted) { + window.location.reload(); + return; + } + + alreadyMountedComponentOrHookNames.add(componentOrHookName); + }); + let areScriptsInserted = false; - let isFistMount = true; + const insertScriptTags = useCallback(() => { + if (areScriptsInserted) { + return; + } - function useInsertScriptTags(params: { scriptTags: ScriptTag[] }) { - const { scriptTags } = params; - - useState(() => { - if (!isFistMount) { - window.location.reload(); - return; - } - - isFistMount = false; - }); - - const insertScriptTags = useCallback(() => { - if (areScriptsInserted) { - return; - } - - scriptTags.forEach(scriptTag => { - // NOTE: Avoid loading same script twice. (Like jQuery for example) - { - const scripts = document.getElementsByTagName("script"); - for (let i = 0; i < scripts.length; i++) { - const script = scripts[i]; - if ("textContent" in scriptTag) { - if (script.textContent === scriptTag.textContent) { - return; - } - continue; - } - if ("src" in scriptTag) { - if (script.getAttribute("src") === scriptTag.src) { - return; - } - continue; - } - assert(false); - } - } - - const htmlElement = document.createElement("script"); - - htmlElement.type = scriptTag.type; - - (() => { + scriptTags.forEach(scriptTag => { + // NOTE: Avoid loading same script twice. (Like jQuery for example) + { + const scripts = document.getElementsByTagName("script"); + for (let i = 0; i < scripts.length; i++) { + const script = scripts[i]; if ("textContent" in scriptTag) { - htmlElement.textContent = scriptTag.textContent; - return; + if (script.textContent === scriptTag.textContent) { + return; + } + continue; } if ("src" in scriptTag) { - htmlElement.src = scriptTag.src; - return; + if (script.getAttribute("src") === scriptTag.src) { + return; + } + continue; } assert(false); - })(); + } + } - document.head.appendChild(htmlElement); - }); + const htmlElement = document.createElement("script"); - areScriptsInserted = true; - }, []); + htmlElement.type = scriptTag.type; - return { insertScriptTags }; - } + (() => { + if ("textContent" in scriptTag) { + htmlElement.textContent = scriptTag.textContent; + return; + } + if ("src" in scriptTag) { + htmlElement.src = scriptTag.src; + return; + } + assert(false); + })(); - return { useInsertScriptTags }; + document.head.appendChild(htmlElement); + }); + + areScriptsInserted = true; + }, []); + + return { insertScriptTags }; } diff --git a/src/tools/useOnFirstMount.ts b/src/tools/useOnFirstMount.ts new file mode 100644 index 00000000..0fde941c --- /dev/null +++ b/src/tools/useOnFirstMount.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useConst } from "powerhooks/useConst"; +import { id } from "tsafe/id"; + +/** Callback is guaranteed to be call only once per component mount event in strict mode */ +export function useOnFistMount(callback: () => void) { + const refHasCallbackBeenCalled = useConst(() => ({ current: id(false) })); + + useEffect(() => { + if (refHasCallbackBeenCalled.current) { + return; + } + + callback(); + + refHasCallbackBeenCalled.current = true; + }, []); +} diff --git a/stories/account/KcApp.tsx b/stories/account/KcApp.tsx index 6c9fc4ab..aee7cad3 100644 --- a/stories/account/KcApp.tsx +++ b/stories/account/KcApp.tsx @@ -1,9 +1,8 @@ -import React, { lazy, Suspense } from "react"; +import React from "react"; import Fallback from "../../dist/account"; import type { KcContext } from "./kcContext"; import { useI18n } from "./i18n"; - -const DefaultTemplate = lazy(() => import("../../dist/account/Template")); +import Template from "../../dist/account/Template"; export default function KcApp(props: { kcContext: KcContext }) { const { kcContext } = props; @@ -14,14 +13,5 @@ export default function KcApp(props: { kcContext: KcContext }) { return null; } - return ( - - {(() => { - switch (kcContext.pageId) { - default: - return ; - } - })()} - - ); + return ; } diff --git a/stories/account/createPageStory.tsx b/stories/account/createPageStory.tsx index add3e5f6..faf98f8a 100644 --- a/stories/account/createPageStory.tsx +++ b/stories/account/createPageStory.tsx @@ -15,7 +15,11 @@ export function createPageStory(params: { pa overrides }); - return ; + return ( + + + + ); } return { PageStory }; diff --git a/stories/login/KcApp.tsx b/stories/login/KcApp.tsx index b7c40352..78be89d3 100644 --- a/stories/login/KcApp.tsx +++ b/stories/login/KcApp.tsx @@ -1,4 +1,4 @@ -import React, { lazy, Suspense } from "react"; +import React from "react"; import Fallback from "../../dist/login"; import type { KcContext } from "./kcContext"; import { useI18n } from "./i18n"; @@ -35,23 +35,14 @@ export default function KcApp(props: { kcContext: KcContext }) { } return ( - - {(() => { - switch (kcContext.pageId) { - default: - return ( - - ); - } - })()} - + ); } diff --git a/stories/login/createPageStory.tsx b/stories/login/createPageStory.tsx index add3e5f6..faf98f8a 100644 --- a/stories/login/createPageStory.tsx +++ b/stories/login/createPageStory.tsx @@ -15,7 +15,11 @@ export function createPageStory(params: { pa overrides }); - return ; + return ( + + + + ); } return { PageStory };