New mechanism for dynamically loading css and js (checkpoint)

This commit is contained in:
Joseph Garrone 2024-05-09 18:04:31 +02:00
parent a1db79ff47
commit 88efe4a523
3 changed files with 121 additions and 156 deletions

View File

@ -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<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

@ -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<void> } {
const htmlElement = document.createElement(
(() => {
switch (params.type) {
case "css":
return "link";
case "javascript":
return "script";
}
})()
);
const dLoaded = new Deferred<void>();
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()
};
}

View File

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