diff --git a/src/lib/usePrepareTemplate.ts b/src/lib/usePrepareTemplate.ts index 12fc0f37..940354bb 100644 --- a/src/lib/usePrepareTemplate.ts +++ b/src/lib/usePrepareTemplate.ts @@ -1,30 +1,17 @@ import { useReducer, useEffect } from "react"; -import { headInsert } from "keycloakify/tools/headInsert"; import { clsx } from "keycloakify/tools/clsx"; import { assert } from "tsafe/assert"; +import { useInsertScriptTags, type ScriptTag } from "keycloakify/tools/useInsertScriptTags"; export function usePrepareTemplate(params: { - styles: string[]; - scripts: { - isModule: boolean; - source: - | { - type: "url"; - src: string; - } - | { - type: "inline"; - code: string; - }; - }[]; + styleSheetHrefs: string[]; + scriptTags: ScriptTag[]; htmlClassName: string | undefined; bodyClassName: string | undefined; htmlLangProperty: string | undefined; documentTitle: string | undefined; }) { - const { styles, scripts, htmlClassName, bodyClassName, htmlLangProperty, documentTitle } = params; - - const [isReady, setReady] = useReducer(() => true, styles.length === 0 && scripts.length === 0); + const { styleSheetHrefs, scriptTags, htmlClassName, bodyClassName, htmlLangProperty, documentTitle } = params; useEffect(() => { if (htmlLangProperty === undefined) { @@ -44,58 +31,10 @@ export function usePrepareTemplate(params: { document.title = documentTitle; }, [documentTitle]); - useEffect(() => { - let isUnmounted = false; + const { areAllStyleSheetsLoaded } = useInsertLinkTags({ "hrefs": styleSheetHrefs }); - const removeArray: (() => void)[] = []; - - (async () => { - for (const style of [...styles].reverse()) { - const { prLoaded, remove } = headInsert({ - "type": "css", - "position": "prepend", - "href": style - }); - - removeArray.push(remove); - - // TODO: Find a way to do that in parallel (without breaking the order) - await prLoaded; - - if (isUnmounted) { - return; - } - } - - setReady(); - })(); - - return () => { - isUnmounted = true; - removeArray.forEach(remove => remove()); - }; - }, []); - - useEffect(() => { - if (!isReady) { - return; - } - - const removeArray: (() => void)[] = []; - - scripts.forEach(script => { - const { remove } = headInsert({ - "type": "javascript", - ...script - }); - - removeArray.push(remove); - }); - - return () => { - removeArray.forEach(remove => remove()); - }; - }, [isReady]); + // NOTE: We want to load the script after the page have been fully rendered. + useInsertScriptTags({ "scriptTags": !areAllStyleSheetsLoaded ? [] : scriptTags }); useSetClassName({ "target": "html", @@ -107,7 +46,7 @@ export function usePrepareTemplate(params: { "className": bodyClassName }); - return { isReady }; + return { areAllStyleSheetsLoaded }; } function useSetClassName(params: { target: "html" | "body"; className: string | undefined }) { @@ -129,3 +68,53 @@ function useSetClassName(params: { target: "html" | "body"; className: string | }; }, [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/tools/headInsert.ts b/src/tools/headInsert.ts deleted file mode 100644 index e103904c..00000000 --- a/src/tools/headInsert.ts +++ /dev/null @@ -1,87 +0,0 @@ -import "./HTMLElement.prototype.prepend"; -import { Deferred } from "evt/tools/Deferred"; - -export function headInsert( - params: - | { - type: "css"; - href: string; - position: "append" | "prepend"; - } - | { - type: "javascript"; - isModule: boolean; - source: - | { - type: "url"; - src: string; - } - | { - type: "inline"; - code: string; - }; - } -): { remove: () => void; prLoaded: Promise } { - const htmlElement = document.createElement( - (() => { - switch (params.type) { - case "css": - return "link"; - case "javascript": - return "script"; - } - })() - ); - - const dLoaded = new Deferred(); - - htmlElement.addEventListener("load", () => dLoaded.resolve()); - - Object.assign( - htmlElement, - (() => { - switch (params.type) { - case "css": - return { - "href": params.href, - "rel": "stylesheet" - }; - case "javascript": - return { - ...(() => { - switch (params.source.type) { - case "inline": - return { "textContent": params.source.code }; - case "url": - return { "src": params.source.src }; - } - })(), - "type": params.isModule ? "module" : "text/javascript" - }; - } - })() - ); - - document.getElementsByTagName("head")[0][ - (() => { - switch (params.type) { - case "javascript": - return "appendChild"; - case "css": - return (() => { - switch (params.position) { - case "append": - return "appendChild"; - case "prepend": - return "prepend"; - } - })(); - } - })() - ](htmlElement); - - return { - "prLoaded": dLoaded.pr, - "remove": () => htmlElement.remove() - }; -} diff --git a/src/tools/useInsertScriptTags.ts b/src/tools/useInsertScriptTags.ts new file mode 100644 index 00000000..242dd273 --- /dev/null +++ b/src/tools/useInsertScriptTags.ts @@ -0,0 +1,63 @@ +import { useEffect } from "react"; + +export type ScriptTag = ScriptTag.TextContent | ScriptTag.Src; + +export namespace ScriptTag { + type Common = { + type: "text/javascript" | "module"; + }; + + 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 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; + } + })(); + + 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; + } + + document.head.appendChild(htmlElement); + + loadedScripts.add(scriptId); + } + }); +}