From df595f22192ded45862f819caba7c09ae3662b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Thu, 1 Jan 2026 01:08:54 +0100 Subject: [PATCH] Fix web component initialization timing --- .../frontend/components/bookmark-page.js | 18 +++++----- bookmarks/frontend/components/clear-button.js | 33 +++++++------------ bookmarks/frontend/components/dropdown.js | 28 +++++++--------- .../frontend/components/filter-drawer.js | 13 +++----- bookmarks/frontend/components/form.js | 24 +++++++------- bookmarks/frontend/components/modal.js | 32 ++++++++---------- .../frontend/components/upload-button.js | 21 +++++------- bookmarks/frontend/utils/element.js | 29 ++++++++++++++++ 8 files changed, 99 insertions(+), 99 deletions(-) diff --git a/bookmarks/frontend/components/bookmark-page.js b/bookmarks/frontend/components/bookmark-page.js index d2c3c60..d1677eb 100644 --- a/bookmarks/frontend/components/bookmark-page.js +++ b/bookmarks/frontend/components/bookmark-page.js @@ -1,5 +1,7 @@ -class BookmarkPage extends HTMLElement { - connectedCallback() { +import { HeadlessElement } from "../utils/element.js"; + +class BookmarkPage extends HeadlessElement { + init() { this.update = this.update.bind(this); this.onToggleNotes = this.onToggleNotes.bind(this); this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this); @@ -17,13 +19,11 @@ class BookmarkPage extends HTMLElement { } update() { - requestAnimationFrame(() => { - const items = this.querySelectorAll("ul.bookmark-list > li"); - this.updateTooltips(items); - this.updateNotesToggles(items, this.oldItems); - this.updateBulkEdit(items, this.oldItems); - this.oldItems = items; - }); + const items = this.querySelectorAll("ul.bookmark-list > li"); + this.updateTooltips(items); + this.updateNotesToggles(items, this.oldItems); + this.updateBulkEdit(items, this.oldItems); + this.oldItems = items; } updateTooltips(items) { diff --git a/bookmarks/frontend/components/clear-button.js b/bookmarks/frontend/components/clear-button.js index 34f8fe3..4e7572f 100644 --- a/bookmarks/frontend/components/clear-button.js +++ b/bookmarks/frontend/components/clear-button.js @@ -1,28 +1,19 @@ -class ClearButton extends HTMLElement { - connectedCallback() { - requestAnimationFrame(() => { - this.field = document.getElementById(this.dataset.for); - if (!this.field) { - console.error(`Field with ID ${this.dataset.for} not found`); - return; - } - this.update = this.update.bind(this); - this.clear = this.clear.bind(this); +import { HeadlessElement } from "../utils/element"; - this.addEventListener("click", this.clear); - this.field.addEventListener("input", this.update); - this.field.addEventListener("value-changed", this.update); - this.update(); - }); - } - - disconnectedCallback() { +class ClearButton extends HeadlessElement { + init() { + this.field = document.getElementById(this.dataset.for); if (!this.field) { + console.error(`Field with ID ${this.dataset.for} not found`); return; } - this.removeEventListener("click", this.clear); - this.field.removeEventListener("input", this.update); - this.field.removeEventListener("value-changed", this.update); + this.update = this.update.bind(this); + this.clear = this.clear.bind(this); + + this.addEventListener("click", this.clear); + this.field.addEventListener("input", this.update); + this.field.addEventListener("value-changed", this.update); + this.update(); } update() { diff --git a/bookmarks/frontend/components/dropdown.js b/bookmarks/frontend/components/dropdown.js index 86bdf16..c8e53a5 100644 --- a/bookmarks/frontend/components/dropdown.js +++ b/bookmarks/frontend/components/dropdown.js @@ -1,4 +1,6 @@ -class Dropdown extends HTMLElement { +import { HeadlessElement } from "../utils/element.js"; + +class Dropdown extends HeadlessElement { constructor() { super(); this.opened = false; @@ -8,26 +10,20 @@ class Dropdown extends HTMLElement { this.onFocusOut = this.onFocusOut.bind(this); } - connectedCallback() { - // Defer setup to next frame when children are available in the DOM - requestAnimationFrame(() => { - // Prevent opening the dropdown automatically on focus, so that it only - // opens on click when JS is enabled - this.style.setProperty("--dropdown-focus-display", "none"); - this.addEventListener("keydown", this.onEscape); - this.addEventListener("focusout", this.onFocusOut); + init() { + // Prevent opening the dropdown automatically on focus, so that it only + // opens on click when JS is enabled + this.style.setProperty("--dropdown-focus-display", "none"); + this.addEventListener("keydown", this.onEscape); + this.addEventListener("focusout", this.onFocusOut); - this.toggle = this.querySelector(".dropdown-toggle"); - this.toggle.setAttribute("aria-expanded", "false"); - this.toggle.addEventListener("click", this.onClick); - }); + this.toggle = this.querySelector(".dropdown-toggle"); + this.toggle.setAttribute("aria-expanded", "false"); + this.toggle.addEventListener("click", this.onClick); } disconnectedCallback() { this.close(); - this.toggle?.removeEventListener("click", this.onClick); - this.removeEventListener("keydown", this.onEscape); - this.removeEventListener("focusout", this.onFocusOut); } open() { diff --git a/bookmarks/frontend/components/filter-drawer.js b/bookmarks/frontend/components/filter-drawer.js index ec81c09..1b7e8dd 100644 --- a/bookmarks/frontend/components/filter-drawer.js +++ b/bookmarks/frontend/components/filter-drawer.js @@ -1,17 +1,14 @@ import { html, render } from "lit"; import { Modal } from "./modal.js"; +import { HeadlessElement } from "../utils/element.js"; import { isKeyboardActive } from "../utils/focus.js"; -class FilterDrawerTrigger extends HTMLElement { - connectedCallback() { +class FilterDrawerTrigger extends HeadlessElement { + init() { this.onClick = this.onClick.bind(this); this.addEventListener("click", this.onClick.bind(this)); } - disconnectedCallback() { - this.removeEventListener("click", this.onClick.bind(this)); - } - onClick() { const modal = document.createElement("ld-filter-drawer"); document.body.querySelector(".modals").appendChild(modal); @@ -67,8 +64,8 @@ class FilterDrawer extends Modal { this.getBoundingClientRect(); // Add active class to start slide-in animation requestAnimationFrame(() => this.classList.add("active")); - // Call super after rendering to ensure elements are available - super.connectedCallback(); + // Call super.init() after rendering to ensure elements are available + super.init(); } disconnectedCallback() { diff --git a/bookmarks/frontend/components/form.js b/bookmarks/frontend/components/form.js index 212d3a3..ef44942 100644 --- a/bookmarks/frontend/components/form.js +++ b/bookmarks/frontend/components/form.js @@ -1,28 +1,26 @@ -class Form extends HTMLElement { +import { HeadlessElement } from "../utils/element.js"; + +class Form extends HeadlessElement { constructor() { super(); this.onKeyDown = this.onKeyDown.bind(this); this.onChange = this.onChange.bind(this); } - connectedCallback() { + init() { this.addEventListener("keydown", this.onKeyDown); this.addEventListener("change", this.onChange); - requestAnimationFrame(() => { - if (this.hasAttribute("data-form-reset")) { - // Resets form controls to their initial values before Turbo caches the DOM. - // Useful for filter forms where navigating back would otherwise still show - // values from after the form submission, which means the filters would be out - // of sync with the URL. - this.initFormReset(); - } - }); + if (this.hasAttribute("data-form-reset")) { + // Resets form controls to their initial values before Turbo caches the DOM. + // Useful for filter forms where navigating back would otherwise still show + // values from after the form submission, which means the filters would be out + // of sync with the URL. + this.initFormReset(); + } } disconnectedCallback() { - this.removeEventListener("keydown", this.onKeyDown); - this.removeEventListener("change", this.onChange); if (this.hasAttribute("data-form-reset")) { this.resetForm(); } diff --git a/bookmarks/frontend/components/modal.js b/bookmarks/frontend/components/modal.js index a5fada2..aafb89b 100644 --- a/bookmarks/frontend/components/modal.js +++ b/bookmarks/frontend/components/modal.js @@ -1,29 +1,23 @@ import { FocusTrapController } from "../utils/focus.js"; +import { HeadlessElement } from "../utils/element.js"; -export class Modal extends HTMLElement { - connectedCallback() { - requestAnimationFrame(() => { - this.onClose = this.onClose.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); +export class Modal extends HeadlessElement { + init() { + this.onClose = this.onClose.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); - this.querySelectorAll("[data-close-modal]").forEach((btn) => { - btn.addEventListener("click", this.onClose); - }); - document.addEventListener("keydown", this.onKeyDown); - - this.setupScrollLock(); - this.focusTrap = new FocusTrapController( - this.querySelector(".modal-container"), - ); + this.querySelectorAll("[data-close-modal]").forEach((btn) => { + btn.addEventListener("click", this.onClose); }); + this.addEventListener("keydown", this.onKeyDown); + + this.setupScrollLock(); + this.focusTrap = new FocusTrapController( + this.querySelector(".modal-container"), + ); } disconnectedCallback() { - this.querySelectorAll("[data-close-modal]").forEach((btn) => { - btn.removeEventListener("click", this.onClose); - }); - document.removeEventListener("keydown", this.onKeyDown); - this.removeScrollLock(); this.focusTrap.destroy(); } diff --git a/bookmarks/frontend/components/upload-button.js b/bookmarks/frontend/components/upload-button.js index 035a707..264a3a9 100644 --- a/bookmarks/frontend/components/upload-button.js +++ b/bookmarks/frontend/components/upload-button.js @@ -1,20 +1,15 @@ -class UploadButton extends HTMLElement { - connectedCallback() { +import { HeadlessElement } from "../utils/element.js"; + +class UploadButton extends HeadlessElement { + init() { this.onClick = this.onClick.bind(this); this.onChange = this.onChange.bind(this); - requestAnimationFrame(() => { - this.button = this.querySelector('button[type="submit"]'); - this.button.addEventListener("click", this.onClick); + this.button = this.querySelector('button[type="submit"]'); + this.button.addEventListener("click", this.onClick); - this.fileInput = this.querySelector('input[type="file"]'); - this.fileInput.addEventListener("change", this.onChange); - }); - } - - disconnectedCallback() { - this.button.removeEventListener("click", this.onClick); - this.fileInput.removeEventListener("change", this.onChange); + this.fileInput = this.querySelector('input[type="file"]'); + this.fileInput.addEventListener("change", this.onChange); } onClick(event) { diff --git a/bookmarks/frontend/utils/element.js b/bookmarks/frontend/utils/element.js index 0b6c078..e2bcb72 100644 --- a/bookmarks/frontend/utils/element.js +++ b/bookmarks/frontend/utils/element.js @@ -1,5 +1,34 @@ import { LitElement } from "lit"; +/** + * Base class for custom elements that wrap existing server-rendered DOM. + * + * Handles timing issues where connectedCallback fires before child elements + * are parsed during initial page load. With Turbo navigation, children are + * always available, but on fresh page loads they may not be. + * + * Subclasses should override init() instead of connectedCallback(). + */ +export class HeadlessElement extends HTMLElement { + connectedCallback() { + if (this.__initialized) { + return; + } + this.__initialized = true; + if (document.readyState === "loading") { + document.addEventListener("turbo:load", () => this.init(), { + once: true, + }); + } else { + this.init(); + } + } + + init() { + // Override in subclass + } +} + let isTopFrameVisit = false; document.addEventListener("turbo:visit", (event) => {