Re implement asset fetching

This commit is contained in:
Joseph Garrone 2024-06-05 06:10:11 +02:00
parent 89fb6de2d5
commit b1da684008
18 changed files with 182 additions and 234 deletions

View File

@ -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<KcContext, I18n>) {
const { kcContext, i18n, doUseDefaultCss, active, classes, children } = props;
@ -46,6 +44,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [

View File

@ -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<KcContextExtraProperties & KcContextBase.Common>;
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<Extract<KcContext, { pageId: PageId }>>;

View File

@ -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<KcContext, I18n>) {
const {
displayInfo = false,
@ -63,6 +60,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}, []);
const { areAllStyleSheetsLoaded } = useInsertLinkTags({
componentOrHookName: "Template",
hrefs: !doUseDefaultCss
? []
: [
@ -75,6 +73,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
});
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "Template",
scriptTags: [
{
type: "module",

View File

@ -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<string | undefined>(() => undefined);
@ -27,29 +24,18 @@ export function useDownloadTerms(params: {
kcContext: KcContextLike;
downloadTermMarkdown: (params: { currentLanguageTag: string }) => Promise<string>;
}) {
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() {

View File

@ -3,6 +3,7 @@ import type { ClassKey } from "keycloakify/login/TemplateProps";
export const { useGetClassName } = createUseClassName<ClassKey>({
defaultClasses: {
kcHtmlClass: "login-pf",
kcBodyClass: undefined,
kcHeaderWrapperClass: undefined,
kcLocaleWrapperClass: undefined,
@ -54,7 +55,6 @@ export const { useGetClassName } = createUseClassName<ClassKey>({
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",

View File

@ -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 => ({

View File

@ -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<Extract<KcContext, { pageId: "login-recovery-authn-code-config.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -21,6 +19,7 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<Kc
const { msg, msgStr } = i18n;
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "LoginRecoveryAuthnCodeConfig",
scriptTags: [
{
type: "text/javascript",

View File

@ -3,12 +3,10 @@ import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { assert } from "tsafe/assert";
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 WebauthnAuthenticate(props: PageProps<Extract<KcContext, { pageId: "webauthn-authenticate.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -31,6 +29,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
const { msg, msgStr, advancedMsg } = i18n;
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "WebauthnAuthenticate",
scriptTags: [
{
type: "text/javascript",

View File

@ -3,12 +3,10 @@ import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { useGetClassName } from "keycloakify/login/lib/useGetClassName";
import { assert } from "tsafe/assert";
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 WebauthnRegister(props: PageProps<Extract<KcContext, { pageId: "webauthn-register.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -35,6 +33,7 @@ export default function WebauthnRegister(props: PageProps<Extract<KcContext, { p
const { msg, msgStr } = i18n;
const { insertScriptTags } = useInsertScriptTags({
componentOrHookName: "WebauthnRegister",
scriptTags: [
{
type: "text/javascript",

View File

@ -5,15 +5,15 @@ import type { StatefulObservable } from "../StatefulObservable";
/**
* Equivalent of https://docs.evt.land/api/react-hooks
* */
export function useRerenderOnChange($: StatefulObservable<unknown>): void {
export function useRerenderOnChange(obs: StatefulObservable<unknown>): 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]
);
}

View File

@ -1,55 +0,0 @@
type SimpleType = number | string | boolean | null | undefined;
type FuncWithSimpleParams<T extends SimpleType[], R> = (...args: T) => R;
export function memoize<T extends SimpleType[], R>(
fn: FuncWithSimpleParams<T, R>,
options?: {
argsLength?: number;
max?: number;
}
): FuncWithSimpleParams<T, R> {
const cache = new Map<string, ReturnType<FuncWithSimpleParams<T, R>>>();
const { argsLength = fn.length, max = Infinity } = options ?? {};
return ((...args: Parameters<FuncWithSimpleParams<T, R>>) => {
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;
}

View File

@ -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<string>();
/**
* 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<Promise<void> | undefined>(undefined)
}));
useEffect(() => {
let isActive = true;
useEffect(() => {
let isActive = true;
(refPrAllStyleSheetLoaded.current ??= (async () => {
let lastMountedHtmlElement: HTMLLinkElement | undefined = undefined;
const prs: Promise<void>[] = [];
@ -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 };
}

View File

@ -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<string>();
/**
* 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 };
}

View File

@ -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<boolean>(false) }));
useEffect(() => {
if (refHasCallbackBeenCalled.current) {
return;
}
callback();
refHasCallbackBeenCalled.current = true;
}, []);
}

View File

@ -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 (
<Suspense>
{(() => {
switch (kcContext.pageId) {
default:
return <Fallback {...{ kcContext, i18n }} Template={DefaultTemplate} doUseDefaultCss={true} />;
}
})()}
</Suspense>
);
return <Fallback {...{ kcContext, i18n }} Template={Template} doUseDefaultCss={true} />;
}

View File

@ -15,7 +15,11 @@ export function createPageStory<PageId extends KcContext["pageId"]>(params: { pa
overrides
});
return <KcApp kcContext={kcContextMock} />;
return (
<React.StrictMode>
<KcApp kcContext={kcContextMock} />
</React.StrictMode>
);
}
return { PageStory };

View File

@ -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 (
<Suspense>
{(() => {
switch (kcContext.pageId) {
default:
return (
<Fallback
{...{
kcContext,
i18n,
Template,
UserProfileFormFields
}}
doUseDefaultCss={true}
/>
);
}
})()}
</Suspense>
<Fallback
{...{
kcContext,
i18n,
Template,
UserProfileFormFields
}}
doUseDefaultCss={true}
/>
);
}

View File

@ -15,7 +15,11 @@ export function createPageStory<PageId extends KcContext["pageId"]>(params: { pa
overrides
});
return <KcApp kcContext={kcContextMock} />;
return (
<React.StrictMode>
<KcApp kcContext={kcContextMock} />
</React.StrictMode>
);
}
return { PageStory };