Effort toward reconsiliating the server templating and the react world

This commit is contained in:
Joseph Garrone 2024-05-10 02:45:01 +02:00
parent 9e21b5cb93
commit f8bf54835d
6 changed files with 214 additions and 159 deletions

View File

@ -1,7 +1,11 @@
import { useReducer, useEffect } from "react"; import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { assert } from "tsafe/assert"; 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: { export function usePrepareTemplate(params: {
styleSheetHrefs: string[]; styleSheetHrefs: string[];
@ -33,88 +37,25 @@ export function usePrepareTemplate(params: {
const { areAllStyleSheetsLoaded } = useInsertLinkTags({ "hrefs": styleSheetHrefs }); const { areAllStyleSheetsLoaded } = useInsertLinkTags({ "hrefs": styleSheetHrefs });
// NOTE: We want to load the script after the page have been fully rendered. const { insertScriptTags } = useInsertScriptTags({ scriptTags });
useInsertScriptTags({ "scriptTags": !areAllStyleSheetsLoaded ? [] : scriptTags });
useEffect(() => {
if (!areAllStyleSheetsLoaded) {
return;
}
insertScriptTags();
}, [areAllStyleSheetsLoaded]);
useSetClassName({ useSetClassName({
"target": "html", "qualifiedName": "html",
"className": htmlClassName "className": htmlClassName
}); });
useSetClassName({ useSetClassName({
"target": "body", "qualifiedName": "body",
"className": bodyClassName "className": bodyClassName
}); });
return { areAllStyleSheetsLoaded }; 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<string, Promise<void>>();
/** 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<void>(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 };
}

View File

@ -28,8 +28,8 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext; const { realm, locale, auth, url, message, isAppInitiatedAction, authenticationSession, scripts } = kcContext;
const { isReady } = usePrepareTemplate({ const { areAllStyleSheetsLoaded } = usePrepareTemplate({
"styles": !doUseDefaultCss "styleSheetHrefs": !doUseDefaultCss
? [] ? []
: [ : [
`${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`, `${url.resourcesCommonPath}/node_modules/@patternfly/patternfly/patternfly.min.css`,
@ -38,41 +38,32 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
`${url.resourcesCommonPath}/lib/pficon/pficon.css`, `${url.resourcesCommonPath}/lib/pficon/pficon.css`,
`${url.resourcesPath}/css/login.css` `${url.resourcesPath}/css/login.css`
], ],
"scripts": [ "scriptTags": [
{ {
"isModule": true, "type": "module",
"source": { "src": `${url.resourcesPath}/js/menu-button-links.js`
"type": "url",
"src": `${url.resourcesPath}/js/menu-button-links.js`
}
}, },
...(authenticationSession === undefined ...(authenticationSession === undefined
? [] ? []
: [ : [
{ {
"isModule": true, "type": "module",
"source": { "textContent": [
"type": "inline" as const, `import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`,
"code": [ ``,
`import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";`, `checkCookiesAndSetTimer(`,
``, ` "${authenticationSession.authSessionId}",`,
`checkCookiesAndSetTimer(`, ` "${authenticationSession.tabId}",`,
` "${authenticationSession.authSessionId}",`, ` "${url.ssoLoginInOtherTabsUrl}"`,
` "${authenticationSession.tabId}",`, `);`
` "${url.ssoLoginInOtherTabsUrl}"`, ].join("\n")
`);` } as const
].join("\n")
}
}
]), ]),
...scripts.map( ...scripts.map(
script => script =>
({ ({
"isModule": false, "type": "text/javascript",
"source": { "src": script
"type": "url",
"src": script
}
} as const) } as const)
) )
], ],
@ -82,7 +73,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
"documentTitle": msgStr("loginTitle", kcContext.realm.displayName) "documentTitle": msgStr("loginTitle", kcContext.realm.displayName)
}); });
if (!isReady) { if (!areAllStyleSheetsLoaded) {
return null; return null;
} }

View File

@ -1,5 +1,5 @@
import "keycloakify/tools/Array.prototype.every"; 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 { id } from "tsafe/id";
import type { MessageKey } from "keycloakify/login/i18n/i18n"; import type { MessageKey } from "keycloakify/login/i18n/i18n";
import type { Attribute, Validators } from "keycloakify/login/kcContext/KcContext"; 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 { emailRegexp } from "keycloakify/tools/emailRegExp";
import type { KcContext, PasswordPolicies } from "keycloakify/login/kcContext/KcContext"; import type { KcContext, PasswordPolicies } from "keycloakify/login/kcContext/KcContext";
import { assert, type Equals } from "tsafe/assert"; import { assert, type Equals } from "tsafe/assert";
import type { I18n } from "../i18n";
import { formatNumber } from "keycloakify/tools/formatNumber"; 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 = { export type FormFieldError = {
errorMessage: JSX.Element; errorMessage: JSX.Element;
@ -102,26 +102,24 @@ namespace internal {
}; };
} }
const { useInsertScriptTags } = createUseInsertScriptTags();
export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm { export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm {
const { kcContext, i18n, doMakeUserConfirmPassword } = params; const { kcContext, i18n, doMakeUserConfirmPassword } = params;
usePrepareTemplate({ const { insertScriptTags } = useInsertScriptTags({
"styles": [], "scriptTags": Object.keys(kcContext.profile?.html5DataAnnotations ?? {})
"scripts": Object.keys(kcContext.profile?.html5DataAnnotations ?? {})
.filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it. .filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it.
.map(key => ({ .map(key => ({
"isModule": true, "type": "module",
"source": { "src": `${kcContext.url.resourcesPath}/js/${key}.js`
"type": "url", }))
"src": `${kcContext.url.resourcesPath}/js/${key}.js`
}
})),
"htmlClassName": undefined,
"bodyClassName": undefined,
"htmlLangProperty": undefined,
"documentTitle": undefined
}); });
useEffect(() => {
insertScriptTags();
}, []);
const { getErrors } = useGetErrors({ const { getErrors } = useGetErrors({
kcContext, kcContext,
i18n i18n

View File

@ -0,0 +1,82 @@
import { useReducer, useEffect } from "react";
export function createUseInsertLinkTags() {
let linkTagsContext:
| {
styleSheetHrefs: string[];
prAreAllStyleSheetsLoaded: Promise<void>;
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<void>[] = [];
const removeFns: (() => void)[] = [];
for (const href of hrefs) {
const htmlElement = document.createElement("link");
prs.push(new Promise<void>(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 };
}

View File

@ -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; export type ScriptTag = ScriptTag.TextContent | ScriptTag.Src;
@ -8,56 +10,76 @@ export namespace ScriptTag {
}; };
export type TextContent = Common & { export type TextContent = Common & {
isModule: boolean;
sourceType: "textContent";
id: string;
textContent: string; textContent: string;
}; };
export type Src = Common & { export type Src = Common & {
isModule: boolean;
sourceType: "src";
src: string; src: string;
}; };
} }
// NOTE: Loaded scripts cannot be unloaded so we need to keep track of them export function createUseInsertScriptTags() {
// to avoid loading them multiple times. let areScriptsInserted = false;
const loadedScripts = new Set<string>();
export function useInsertScriptTags(params: { scriptTags: ScriptTag[] }) { function useInsertScriptTags(params: { scriptTags: ScriptTag[] }) {
const { scriptTags } = params; const { scriptTags } = params;
useEffect(() => { const currentScriptTagsRef = useConst(() => ({ "current": scriptTags }));
for (const scriptTag of scriptTags) {
const scriptId = (() => { currentScriptTagsRef.current = scriptTags;
switch (scriptTag.sourceType) {
case "src": const insertScriptTags = useCallback(() => {
return scriptTag.src; {
case "textContent": const getFingerprint = (scriptTags: ScriptTag[]) =>
return scriptTag.textContent; 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"); if (areScriptsInserted) {
return;
htmlElement.type = scriptTag.type;
switch (scriptTag.sourceType) {
case "src":
htmlElement.src = scriptTag.src;
break;
case "textContent":
htmlElement.textContent = scriptTag.textContent;
break;
} }
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 };
} }

View File

@ -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]);
}