diff --git a/src/lib/usePrepareTemplate.ts b/src/lib/usePrepareTemplate.ts index 940354bb..18b73498 100644 --- a/src/lib/usePrepareTemplate.ts +++ b/src/lib/usePrepareTemplate.ts @@ -1,7 +1,11 @@ -import { useReducer, useEffect } from "react"; -import { clsx } from "keycloakify/tools/clsx"; +import { useEffect } from "react"; import { assert } from "tsafe/assert"; -import { useInsertScriptTags, type ScriptTag } from "keycloakify/tools/useInsertScriptTags"; +import { createUseInsertScriptTags, type ScriptTag } from "keycloakify/tools/useInsertScriptTags"; +import { createUseInsertLinkTags } from "keycloakify/tools/useInsertLinkTags"; +import { useSetClassName } from "keycloakify/tools/useSetClassName"; + +const { useInsertLinkTags } = createUseInsertLinkTags(); +const { useInsertScriptTags } = createUseInsertScriptTags(); export function usePrepareTemplate(params: { styleSheetHrefs: string[]; @@ -33,88 +37,25 @@ export function usePrepareTemplate(params: { const { areAllStyleSheetsLoaded } = useInsertLinkTags({ "hrefs": styleSheetHrefs }); - // NOTE: We want to load the script after the page have been fully rendered. - useInsertScriptTags({ "scriptTags": !areAllStyleSheetsLoaded ? [] : scriptTags }); + const { insertScriptTags } = useInsertScriptTags({ scriptTags }); + + useEffect(() => { + if (!areAllStyleSheetsLoaded) { + return; + } + + insertScriptTags(); + }, [areAllStyleSheetsLoaded]); useSetClassName({ - "target": "html", + "qualifiedName": "html", "className": htmlClassName }); useSetClassName({ - "target": "body", + "qualifiedName": "body", "className": bodyClassName }); return { areAllStyleSheetsLoaded }; } - -function useSetClassName(params: { target: "html" | "body"; className: string | undefined }) { - const { target, className } = params; - - useEffect(() => { - if (className === undefined) { - return; - } - - const htmlClassList = document.getElementsByTagName(target)[0].classList; - - const tokens = clsx(className).split(" "); - - htmlClassList.add(...tokens); - - return () => { - htmlClassList.remove(...tokens); - }; - }, [className]); -} - -const hrefByPrLoaded = new Map>(); - -/** NOTE: The hrefs can't changes. There should be only one one call on this. */ -function useInsertLinkTags(params: { hrefs: string[] }) { - const { hrefs } = params; - - const [areAllStyleSheetsLoaded, setAllStyleSheetLoaded] = useReducer(() => true, hrefs.length === 0); - - useEffect(() => { - let isActive = true; - - let lastMountedHtmlElement: HTMLLinkElement | undefined = undefined; - - for (const href of hrefs) { - if (hrefByPrLoaded.has(href)) { - continue; - } - - const htmlElement = document.createElement("link"); - - hrefByPrLoaded.set(href, new Promise(resolve => htmlElement.addEventListener("load", () => resolve()))); - - htmlElement.rel = "stylesheet"; - - htmlElement.href = href; - - if (lastMountedHtmlElement !== undefined) { - lastMountedHtmlElement.insertAdjacentElement("afterend", htmlElement); - } else { - document.head.prepend(htmlElement); - } - - lastMountedHtmlElement = htmlElement; - } - - Promise.all(Array.from(hrefByPrLoaded.values())).then(() => { - if (!isActive) { - return; - } - setAllStyleSheetLoaded(); - }); - - return () => { - isActive = false; - }; - }, []); - - return { areAllStyleSheetsLoaded }; -} diff --git a/src/login/Template.tsx b/src/login/Template.tsx index eb20a2f2..0c6a2a54 100644 --- a/src/login/Template.tsx +++ b/src/login/Template.tsx @@ -28,8 +28,8 @@ export default function Template(props: TemplateProps) { const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext; - const { isReady } = usePrepareTemplate({ - "styles": !doUseDefaultCss + const { areAllStyleSheetsLoaded } = usePrepareTemplate({ + "styleSheetHrefs": !doUseDefaultCss ? [] : [ `${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`, @@ -38,41 +38,32 @@ export default function Template(props: TemplateProps) { `${url.resourcesCommonPath}/lib/pficon/pficon.css`, `${url.resourcesPath}/css/login.css` ], - "scripts": [ + "scriptTags": [ { - "isModule": true, - "source": { - "type": "url", - "src": `${url.resourcesPath}/js/menu-button-links.js` - } + "type": "module", + "src": `${url.resourcesPath}/js/menu-button-links.js` }, ...(authenticationSession === undefined ? [] : [ { - "isModule": true, - "source": { - "type": "inline" as const, - "code": [ - `import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`, - ``, - `checkCookiesAndSetTimer(`, - ` "${authenticationSession.authSessionId}",`, - ` "${authenticationSession.tabId}",`, - ` "${url.ssoLoginInOtherTabsUrl}"`, - `);` - ].join("\n") - } - } + "type": "module", + "textContent": [ + `import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`, + ``, + `checkCookiesAndSetTimer(`, + ` "${authenticationSession.authSessionId}",`, + ` "${authenticationSession.tabId}",`, + ` "${url.ssoLoginInOtherTabsUrl}"`, + `);` + ].join("\n") + } as const ]), ...scripts.map( script => ({ - "isModule": false, - "source": { - "type": "url", - "src": script - } + "type": "text/javascript", + "src": script } as const) ) ], @@ -82,7 +73,7 @@ export default function Template(props: TemplateProps) { "documentTitle": msgStr("loginTitle", kcContext.realm.displayName) }); - if (!isReady) { + if (!areAllStyleSheetsLoaded) { return null; } diff --git a/src/login/lib/useUserProfileForm.tsx b/src/login/lib/useUserProfileForm.tsx index bd0c7ffe..fb5710da 100644 --- a/src/login/lib/useUserProfileForm.tsx +++ b/src/login/lib/useUserProfileForm.tsx @@ -1,5 +1,5 @@ import "keycloakify/tools/Array.prototype.every"; -import { useMemo, useReducer, Fragment, type Dispatch } from "react"; +import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react"; import { id } from "tsafe/id"; import type { MessageKey } from "keycloakify/login/i18n/i18n"; import type { Attribute, Validators } from "keycloakify/login/kcContext/KcContext"; @@ -7,9 +7,9 @@ import { useConstCallback } from "keycloakify/tools/useConstCallback"; import { emailRegexp } from "keycloakify/tools/emailRegExp"; import type { KcContext, PasswordPolicies } from "keycloakify/login/kcContext/KcContext"; import { assert, type Equals } from "tsafe/assert"; -import type { I18n } from "../i18n"; import { formatNumber } from "keycloakify/tools/formatNumber"; -import { usePrepareTemplate } from "keycloakify/lib/usePrepareTemplate"; +import { createUseInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import type { I18n } from "../i18n"; export type FormFieldError = { errorMessage: JSX.Element; @@ -102,26 +102,24 @@ namespace internal { }; } +const { useInsertScriptTags } = createUseInsertScriptTags(); + export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm { const { kcContext, i18n, doMakeUserConfirmPassword } = params; - usePrepareTemplate({ - "styles": [], - "scripts": Object.keys(kcContext.profile?.html5DataAnnotations ?? {}) + const { insertScriptTags } = useInsertScriptTags({ + "scriptTags": Object.keys(kcContext.profile?.html5DataAnnotations ?? {}) .filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it. .map(key => ({ - "isModule": true, - "source": { - "type": "url", - "src": `${kcContext.url.resourcesPath}/js/${key}.js` - } - })), - "htmlClassName": undefined, - "bodyClassName": undefined, - "htmlLangProperty": undefined, - "documentTitle": undefined + "type": "module", + "src": `${kcContext.url.resourcesPath}/js/${key}.js` + })) }); + useEffect(() => { + insertScriptTags(); + }, []); + const { getErrors } = useGetErrors({ kcContext, i18n diff --git a/src/tools/useInsertLinkTags.ts b/src/tools/useInsertLinkTags.ts new file mode 100644 index 00000000..cfe761b8 --- /dev/null +++ b/src/tools/useInsertLinkTags.ts @@ -0,0 +1,82 @@ +import { useReducer, useEffect } from "react"; + +export function createUseInsertLinkTags() { + let linkTagsContext: + | { + styleSheetHrefs: string[]; + prAreAllStyleSheetsLoaded: Promise; + remove: () => void; + } + | undefined = undefined; + + /** NOTE: The hrefs can't changes. There should be only one one call on this. */ + function useInsertLinkTags(params: { hrefs: string[] }) { + const { hrefs } = params; + + const [areAllStyleSheetsLoaded, setAllStyleSheetLoaded] = useReducer(() => true, hrefs.length === 0); + + useEffect(() => { + let isActive = true; + + mount_link_tags: { + if (linkTagsContext !== undefined) { + if (JSON.stringify(linkTagsContext.styleSheetHrefs) === JSON.stringify(hrefs)) { + break mount_link_tags; + } + + linkTagsContext.remove(); + + linkTagsContext = undefined; + } + + let lastMountedHtmlElement: HTMLLinkElement | undefined = undefined; + + const prs: Promise[] = []; + const removeFns: (() => void)[] = []; + + for (const href of hrefs) { + const htmlElement = document.createElement("link"); + + prs.push(new Promise(resolve => htmlElement.addEventListener("load", () => resolve()))); + + htmlElement.rel = "stylesheet"; + + htmlElement.href = href; + + if (lastMountedHtmlElement !== undefined) { + lastMountedHtmlElement.insertAdjacentElement("afterend", htmlElement); + } else { + document.head.prepend(htmlElement); + } + + removeFns.push(() => { + htmlElement.remove(); + }); + + lastMountedHtmlElement = htmlElement; + } + + linkTagsContext = { + "styleSheetHrefs": hrefs, + "prAreAllStyleSheetsLoaded": Promise.all(prs).then(() => undefined), + "remove": () => removeFns.forEach(fn => fn()) + }; + } + + linkTagsContext.prAreAllStyleSheetsLoaded.then(() => { + if (!isActive) { + return; + } + setAllStyleSheetLoaded(); + }); + + return () => { + isActive = false; + }; + }, []); + + return { areAllStyleSheetsLoaded }; + } + + return { useInsertLinkTags }; +} diff --git a/src/tools/useInsertScriptTags.ts b/src/tools/useInsertScriptTags.ts index 242dd273..08c30326 100644 --- a/src/tools/useInsertScriptTags.ts +++ b/src/tools/useInsertScriptTags.ts @@ -1,4 +1,6 @@ -import { useEffect } from "react"; +import { useCallback } from "react"; +import { useConst } from "keycloakify/tools/useConst"; +import { assert } from "tsafe/assert"; export type ScriptTag = ScriptTag.TextContent | ScriptTag.Src; @@ -8,56 +10,76 @@ export namespace ScriptTag { }; export type TextContent = Common & { - isModule: boolean; - sourceType: "textContent"; - id: string; textContent: string; }; export type Src = Common & { - isModule: boolean; - sourceType: "src"; src: string; }; } -// NOTE: Loaded scripts cannot be unloaded so we need to keep track of them -// to avoid loading them multiple times. -const loadedScripts = new Set(); +export function createUseInsertScriptTags() { + let areScriptsInserted = false; -export function useInsertScriptTags(params: { scriptTags: ScriptTag[] }) { - const { scriptTags } = params; + function useInsertScriptTags(params: { scriptTags: ScriptTag[] }) { + const { scriptTags } = params; - useEffect(() => { - for (const scriptTag of scriptTags) { - const scriptId = (() => { - switch (scriptTag.sourceType) { - case "src": - return scriptTag.src; - case "textContent": - return scriptTag.textContent; + const currentScriptTagsRef = useConst(() => ({ "current": scriptTags })); + + currentScriptTagsRef.current = scriptTags; + + const insertScriptTags = useCallback(() => { + { + const getFingerprint = (scriptTags: ScriptTag[]) => + scriptTags + .map((scriptTag): string => { + if ("textContent" in scriptTag) { + return scriptTag.textContent; + } + if ("src" in scriptTag) { + return scriptTag.src; + } + assert(false); + }) + .join("---"); + + if (getFingerprint(scriptTags) !== getFingerprint(currentScriptTagsRef.current)) { + // NOTE: We can't unload script, in storybook if we switch from one page to another + // and the scripts have changed we must reload. + window.location.reload(); + + return; } - })(); - - if (loadedScripts.has(scriptId)) { - continue; } - const htmlElement = document.createElement("script"); - - htmlElement.type = scriptTag.type; - - switch (scriptTag.sourceType) { - case "src": - htmlElement.src = scriptTag.src; - break; - case "textContent": - htmlElement.textContent = scriptTag.textContent; - break; + if (areScriptsInserted) { + return; } - document.head.appendChild(htmlElement); + scriptTags.forEach(scriptTag => { + const htmlElement = document.createElement("script"); - loadedScripts.add(scriptId); - } - }); + htmlElement.type = scriptTag.type; + + (() => { + if ("textContent" in scriptTag) { + htmlElement.textContent = scriptTag.textContent; + return; + } + if ("src" in scriptTag) { + htmlElement.src = scriptTag.src; + return; + } + assert(false); + })(); + + document.head.appendChild(htmlElement); + }); + + areScriptsInserted = true; + }, []); + + return { insertScriptTags }; + } + + return { useInsertScriptTags }; } diff --git a/src/tools/useSetClassName.ts b/src/tools/useSetClassName.ts new file mode 100644 index 00000000..f1a7a5da --- /dev/null +++ b/src/tools/useSetClassName.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; + +export function useSetClassName(params: { qualifiedName: "html" | "body"; className: string | undefined }) { + const { qualifiedName, className } = params; + + useEffect(() => { + if (className === undefined || className === "") { + return; + } + + const htmlClassList = document.getElementsByTagName(qualifiedName)[0].classList; + + const tokens = className.split(" "); + + htmlClassList.add(...tokens); + + return () => { + htmlClassList.remove(...tokens); + }; + }, [className]); +}