Over engineer ElementOnce web components

This commit is contained in:
2026-03-26 23:25:29 +01:00
parent 3bc4f1300e
commit efdf71d69e
2 changed files with 101 additions and 60 deletions

View File

@@ -1,19 +1,16 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
/** /**
* Web component used to import css files only once * Create an abstract class for ElementOnce types Web Components
* If called multiple times or the file was already imported, it does nothing *
* Those class aren't really abstract because that would be complicated with the
* multiple inheritance involved
* Instead, we just raise an unimplemented error
**/ **/
@registerComponent("link-once") function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
export class LinkOnce extends inheritHtmlElement("link") { return class ElementOnce extends inheritHtmlElement(tagName) {
refresh() { getElementQuerySelector(): string {
this.clearNode(); throw new Error("Unimplemented");
// We get href from node.attributes instead of node.href to avoid getting the domain part
const href = this.node.attributes.getNamedItem("href").nodeValue;
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
this.appendChild(this.node);
}
} }
clearNode() { clearNode() {
@@ -22,19 +19,88 @@ export class LinkOnce extends inheritHtmlElement("link") {
} }
} }
refresh() {
this.clearNode();
if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) {
this.appendChild(this.node);
}
}
connectedCallback() { connectedCallback() {
super.connectedCallback(false); super.connectedCallback(false);
this.refresh(); this.refresh();
} }
disconnectedCallback() { disconnectedCallback() {
// The MutationObserver can't see web components being removed
// It also can't see if something is removed inside after the component gets deleted
// We need to manually clear the containing node to trigger the observer
this.clearNode(); this.clearNode();
// This re-triggers link-once elements that still exists and suppressed
// themeselves once it gets removed from the page
for (const link of document.getElementsByTagName("link-once")) {
(link as LinkOnce).refresh();
} }
};
}
// Set of ElementOnce type components to refresh with the observer
const registeredComponents: Set<string> = new Set();
/**
* Helper to register ElementOnce types Web Components
* It's a wrapper around registerComponent that registers that component on
* a MutationObserver that activates a refresh on them when elements are removed
**/
function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
registeredComponents.add(name);
return registerComponent(name, options);
}
const startObserver = (observer: MutationObserver) => {
observer.observe(document, {
// We want to also listen for elements contained in the header (eg: link)
subtree: true,
childList: true,
});
};
// Refresh *-once components when changes happens
const observer = new MutationObserver((mutations: MutationRecord[]) => {
observer.disconnect();
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node.nodeType !== node.ELEMENT_NODE) {
continue;
}
const refreshElement = (componentName: string, tagName: string) => {
for (const element of document.getElementsByTagName(componentName)) {
// We can't guess if an element is compatible before we get one
// We exit the function completely if it's not compatible
if (
(element as any).inheritedTagName.toUpperCase() !== tagName.toUpperCase()
) {
return;
}
(element as any).refresh();
}
};
for (const registered of registeredComponents) {
refreshElement(registered, (node as HTMLElement).tagName);
}
}
}
startObserver(observer);
});
startObserver(observer);
/**
* Web component used to import css files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerElementOnce("link-once")
export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string {
// We get href from node.attributes instead of node.href to avoid getting the domain part
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
} }
} }
@@ -42,36 +108,10 @@ export class LinkOnce extends inheritHtmlElement("link") {
* Web component used to import javascript files only once * Web component used to import javascript files only once
* If called multiple times or the file was already imported, it does nothing * If called multiple times or the file was already imported, it does nothing
**/ **/
@registerComponent("script-once") @registerElementOnce("script-once")
export class ScriptOnce extends inheritHtmlElement("script") { export class ScriptOnce extends inheritHtmlElement("script") {
refresh() { getElementQuerySelector(): string {
this.clearNode(); // We get href from node.attributes instead of node.src to avoid getting the domain part
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
// We get src from node.attributes instead of node.src to avoid getting the domain part
const src = this.node.attributes.getNamedItem("src").nodeValue;
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
this.appendChild(this.node);
}
}
clearNode() {
while (this.firstChild) {
this.removeChild(this.lastChild);
}
}
connectedCallback() {
super.connectedCallback(false);
this.refresh();
}
disconnectedCallback() {
this.clearNode();
// This re-triggers script-once elements that still exists and suppressed
// themeselves once it gets removed from the page
for (const link of document.getElementsByTagName("script-once")) {
(link as LinkOnce).refresh();
}
} }
} }

View File

@@ -36,6 +36,7 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
**/ **/
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) { export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class Inherited extends HTMLElement { return class Inherited extends HTMLElement {
readonly inheritedTagName = tagName;
protected node: HTMLElementTagNameMap[K]; protected node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) { connectedCallback(autoAddNode?: boolean) {