diff --git a/club/templates/club/club_members.jinja b/club/templates/club/club_members.jinja
index 3aa43d56..7e37b04e 100644
--- a/club/templates/club/club_members.jinja
+++ b/club/templates/club/club_members.jinja
@@ -1,11 +1,7 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
-{% block additional_js %}
-
-{% endblock %}
{% block additional_css %}
-
{% endblock %}
diff --git a/core/static/bundled/core/components/include-index.ts b/core/static/bundled/core/components/include-index.ts
index c1989f4b..9ee44bb8 100644
--- a/core/static/bundled/core/components/include-index.ts
+++ b/core/static/bundled/core/components/include-index.ts
@@ -1,18 +1,136 @@
-import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
+import {
+ type InheritedHtmlElement,
+ inheritHtmlElement,
+ registerComponent,
+} from "#core:utils/web-components.ts";
+
+/**
+ * ElementOnce web components
+ *
+ * Those elements ensures that their content is always included only once on a document
+ * They are compatible with elements that are not managed with our Web Components
+ **/
+export interface ElementOnce
+ extends InheritedHtmlElement {
+ getElementQuerySelector(): string;
+ refresh(): void;
+}
+
+/**
+ * Create an abstract class for ElementOnce types Web Components
+ **/
+export function elementOnce(tagName: K) {
+ abstract class ElementOnceImpl
+ extends inheritHtmlElement(tagName)
+ implements ElementOnce
+ {
+ abstract getElementQuerySelector(): string;
+
+ clearNode() {
+ while (this.firstChild) {
+ this.removeChild(this.lastChild);
+ }
+ }
+
+ refresh() {
+ this.clearNode();
+ if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) {
+ this.appendChild(this.node);
+ }
+ }
+
+ connectedCallback() {
+ super.connectedCallback(false);
+ this.refresh();
+ }
+
+ 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();
+ }
+ }
+ return ElementOnceImpl;
+}
+
+// Set of ElementOnce type components to refresh with the observer
+const registeredComponents: Set = 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
+ *
+ * You are not supposed to unregister an element
+ **/
+export function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
+ registeredComponents.add(name);
+ return registerComponent(name, options);
+}
+
+// Refresh all ElementOnce components on the document based on the tag name of the removed element
+const refreshElement = <
+ T extends keyof HTMLElementTagNameMap,
+ K extends keyof HTMLElementTagNameMap,
+>(
+ components: HTMLCollectionOf>,
+ removedTagName: K,
+) => {
+ for (const element of components) {
+ // 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.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) {
+ return;
+ }
+
+ element.refresh();
+ }
+};
+
+// Since we need to pause the observer, we make an helper to start it with consistent arguments
+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 ElementOnce components when changes happens
+const observer = new MutationObserver((mutations: MutationRecord[]) => {
+ // To avoid infinite recursion, we need to pause the observer while manipulation nodes
+ observer.disconnect();
+ for (const mutation of mutations) {
+ for (const node of mutation.removedNodes) {
+ if (node.nodeType !== node.ELEMENT_NODE) {
+ continue;
+ }
+ for (const registered of registeredComponents) {
+ refreshElement(
+ document.getElementsByTagName(registered) as HTMLCollectionOf<
+ ElementOnce<"html"> // The specific tag doesn't really matter
+ >,
+ (node as HTMLElement).tagName as keyof HTMLElementTagNameMap,
+ );
+ }
+ }
+ }
+ // We then resume the observer
+ 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
**/
-@registerComponent("link-once")
-export class LinkOnce extends inheritHtmlElement("link") {
- connectedCallback() {
- super.connectedCallback(false);
+@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
- const href = this.node.attributes.getNamedItem("href").nodeValue;
- if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
- this.appendChild(this.node);
- }
+ return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
}
}
@@ -20,14 +138,10 @@ export class LinkOnce extends inheritHtmlElement("link") {
* Web component used to import javascript files only once
* 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") {
- connectedCallback() {
- super.connectedCallback(false);
- // 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);
- }
+ getElementQuerySelector(): string {
+ // 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}']`;
}
}
diff --git a/core/static/bundled/utils/web-components.ts b/core/static/bundled/utils/web-components.ts
index 8d1dbc47..d7792e76 100644
--- a/core/static/bundled/utils/web-components.ts
+++ b/core/static/bundled/utils/web-components.ts
@@ -23,10 +23,17 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
* The technique is to:
* create a new web component
* create the desired type inside
- * pass all attributes to the child component
+ * move all attributes to the child component
* store is at as `node` inside the parent
- *
- * Since we can't use the generic type to instantiate the node, we create a generator function
+ **/
+export interface InheritedHtmlElement
+ extends HTMLElement {
+ readonly inheritedTagName: K;
+ node: HTMLElementTagNameMap[K];
+}
+
+/**
+ * Generator function that creates an InheritedHtmlElement compatible class
*
* ```js
* class MyClass extends inheritHtmlElement("select") {
@@ -35,11 +42,15 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
* ```
**/
export function inheritHtmlElement(tagName: K) {
- return class Inherited extends HTMLElement {
- protected node: HTMLElementTagNameMap[K];
+ return class InheritedHtmlElementImpl
+ extends HTMLElement
+ implements InheritedHtmlElement
+ {
+ readonly inheritedTagName = tagName;
+ node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
- this.node = document.createElement(tagName);
+ this.node = document.createElement(this.inheritedTagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
if (attr.name in this.node) {
@@ -47,6 +58,10 @@ export function inheritHtmlElement(tagNam
}
}
+ // We move compatible attributes to the child element
+ // This avoids weird inconsistencies between attributes
+ // when we manipulate the dom in the future
+ // This is especially important when using attribute based reactivity
for (const attr of attributes) {
this.removeAttributeNode(attr);
this.node.setAttributeNode(attr);
diff --git a/core/templates/core/user_preferences.jinja b/core/templates/core/user_preferences.jinja
index 1aed4d94..f3694ff0 100644
--- a/core/templates/core/user_preferences.jinja
+++ b/core/templates/core/user_preferences.jinja
@@ -1,14 +1,7 @@
{% extends "core/base.jinja" %}
-{%- block additional_js -%}
-
-{%- endblock -%}
-
{%- block additional_css -%}
- {# importing ajax-select-index is necessary for it to be applied after HTMX reload #}
-
-
{%- endblock -%}
{% block title %}
diff --git a/subscription/templates/subscription/subscription.jinja b/subscription/templates/subscription/subscription.jinja
index 9046c296..45e88e18 100644
--- a/subscription/templates/subscription/subscription.jinja
+++ b/subscription/templates/subscription/subscription.jinja
@@ -6,14 +6,8 @@
{% trans %}New subscription{% endtrans %}
{% endblock %}
-{# The following statics are bundled with our autocomplete select.
- However, if one tries to swap a form by another, then the urls in script-once
- and link-once disappear.
- So we give them here.
- If the aforementioned bug is resolved, you can remove this. #}
{% block additional_js %}
-