From 9b855536a9ed112ae5c7cd8a3cde28a05d3cc2a2 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 01:03:50 +0530 Subject: [PATCH 01/22] chore(release): v0.8.17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release @livetemplate/client v0.8.17 This release follows the core library version: 0.8.x 🤖 Generated with automated release script --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9b4cc..03112ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to @livetemplate/client will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.8.17] - 2026-04-05 + +### Changes + +- fix: form.name DOM shadowing + skip File objects in FormData parsing (58cf0c2) + + + ## [v0.8.16] - 2026-04-04 ### Changes diff --git a/VERSION b/VERSION index ac7dffa..9bba175 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.16 +0.8.17 diff --git a/package-lock.json b/package-lock.json index d8134dd..553db8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@livetemplate/client", - "version": "0.8.16", + "version": "0.8.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@livetemplate/client", - "version": "0.8.16", + "version": "0.8.17", "license": "MIT", "dependencies": { "@types/morphdom": "^2.3.0", diff --git a/package.json b/package.json index a21ac68..e317659 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@livetemplate/client", - "version": "0.8.16", + "version": "0.8.17", "description": "TypeScript client for LiveTemplate tree-based updates", "main": "dist/livetemplate-client.js", "browser": "dist/livetemplate-client.browser.js", From c1dd3c94666094ca12130cf9e3a1ef9a17404493 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 08:57:45 +0530 Subject: [PATCH 02/22] feat: extend lvt-el: to support any native DOM event as interaction trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lvt-el:{method}:on:{event} now works with any native DOM event, not just lifecycle states. This eliminates the need for inline JS onclick/onfocus handlers, making templates CSP-safe and consistent with the declarative lvt-* pattern. Examples: lvt-el:toggleClass:on:click="open" — toggle on click lvt-el:addClass:on:focusin="open" — open on focus lvt-el:removeClass:on:focusout="open" — close on blur lvt-el:addClass:on:mouseenter="visible" — show on hover lvt-el:removeClass:on:mouseleave="visible" — hide on leave The trigger resolution: - Lifecycle states (pending/success/error/done): existing behavior - click-away: synthetic, handled by setupClickAwayDelegation - Everything else: treated as native DOM event, delegated via setupDOMEventTriggerDelegation Non-bubbling events (mouseenter, mouseleave, focus, blur) use direct element attachment. Bubbling events use wrapper-level delegation. Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/event-delegation.ts | 60 ++++++++++++++++++++- dom/reactive-attributes.ts | 46 +++++++++++++--- livetemplate-client.ts | 3 ++ tests/reactive-attributes.test.ts | 89 ++++++++++++++++++++++++++++++- 4 files changed, 190 insertions(+), 8 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 882f4ad..d9360b4 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -1,6 +1,6 @@ import { debounce, throttle } from "../utils/rate-limit"; import { lvtSelector } from "../utils/lvt-selector"; -import { executeAction, type ReactiveAction } from "./reactive-attributes"; +import { executeAction, processElementInteraction, isDOMEventTrigger, SYNTHETIC_TRIGGERS, type ReactiveAction } from "./reactive-attributes"; import type { Logger } from "../utils/logger"; // Methods supported by click-away, derived from ReactiveAction values @@ -581,6 +581,64 @@ export class EventDelegator { document.addEventListener("click", listener); } + /** + * Sets up event listeners for lvt-el:*:on:{event} attributes where {event} + * is a native DOM event (not a lifecycle state or synthetic trigger). + * + * Scans the wrapper for elements with these attributes, attaches direct + * listeners for non-bubbling events (mouseenter, mouseleave) and delegated + * listeners on the wrapper for bubbling events (click, focusin, focusout, etc.). + * + * Called after each render/update to handle new elements. + */ + setupDOMEventTriggerDelegation(): void { + const wrapperElement = this.context.getWrapperElement(); + if (!wrapperElement) return; + + const wrapperId = wrapperElement.getAttribute("data-lvt-id"); + // Non-bubbling events need direct attachment + const NON_BUBBLING = new Set(["mouseenter", "mouseleave", "focus", "blur"]); + // Track which bubbling events we've already delegated at wrapper level + const delegatedKey = `__lvt_el_delegated_${wrapperId}`; + const delegated: Set = (wrapperElement as any)[delegatedKey] || new Set(); + + // Scan all elements for lvt-el:*:on:{event} attributes + wrapperElement.querySelectorAll("*").forEach(el => { + const triggers = new Set(); + for (const attr of el.attributes) { + if (!attr.name.startsWith("lvt-el:")) continue; + const match = attr.name.match(/^lvt-el:\w+:on:([a-z-]+)$/i); + if (!match) continue; + const trigger = match[1].toLowerCase(); + if (!isDOMEventTrigger(trigger)) continue; + triggers.add(trigger); + } + + for (const trigger of triggers) { + if (NON_BUBBLING.has(trigger)) { + // Direct attachment for non-bubbling events + const key = `__lvt_el_${trigger}`; + if ((el as any)[key]) continue; // already attached + const listener = () => processElementInteraction(el, trigger); + el.addEventListener(trigger, listener); + (el as any)[key] = listener; + } else if (!delegated.has(trigger)) { + // Delegated listener on wrapper for bubbling events + wrapperElement.addEventListener(trigger, (e: Event) => { + let target = e.target as Element | null; + while (target && target !== wrapperElement.parentElement) { + processElementInteraction(target, trigger); + target = target.parentElement; + } + }); + delegated.add(trigger); + } + } + }); + + (wrapperElement as any)[delegatedKey] = delegated; + } + /** * Sets up focus trapping for elements with lvt-focus-trap attribute. * Focus is trapped within the element, cycling through focusable elements diff --git a/dom/reactive-attributes.ts b/dom/reactive-attributes.ts index 3b504ae..fb7cbf1 100644 --- a/dom/reactive-attributes.ts +++ b/dom/reactive-attributes.ts @@ -1,16 +1,17 @@ /** - * Reactive Attributes - Declarative DOM actions triggered by LiveTemplate lifecycle events. + * Reactive Attributes - Declarative DOM actions triggered by lifecycle events or interactions. * * Attribute Pattern: lvt-el:{method}:on:[{action}:]{state|interaction}="param" * - * States (lifecycle): + * States (lifecycle — server action request-response cycle): * - pending: Action started, waiting for server response * - success: Action completed successfully * - error: Action completed with validation errors * - done: Action completed (regardless of success/error) * - * Interactions: - * - click-away: Click outside the element (handled by setupClickAwayDelegation) + * Interactions (client-side — no server round-trip): + * - Any native DOM event (click, focusin, focusout, mouseenter, mouseleave, keydown, etc.) + * - click-away: Synthetic — click outside the element (not a native DOM event) * * Trigger Scope: * - Unscoped: lvt-el:reset:on:success (any action) @@ -45,6 +46,13 @@ export interface ReactiveBinding { const LIFECYCLE_EVENTS: LifecycleEvent[] = ["pending", "success", "error", "done"]; const LIFECYCLE_SET = new Set(LIFECYCLE_EVENTS); +/** + * Reserved trigger keywords that are NOT native DOM events. + * click-away is a synthetic interaction handled by setupClickAwayDelegation. + * Everything else that's not a lifecycle state is treated as a native DOM event. + */ +export const SYNTHETIC_TRIGGERS = new Set(["click-away"]); + // Lowercase method names → canonical ReactiveAction const METHOD_MAP: Record = { reset: "reset", @@ -79,8 +87,10 @@ export function parseReactiveAttribute( if (!action) return null; const eventPart = newMatch[2]; - // Skip interaction triggers (click-away) — handled by click-away delegation - if (eventPart === "click-away") return null; + // Skip synthetic triggers (click-away) — handled by setupClickAwayDelegation + if (SYNTHETIC_TRIGGERS.has(eventPart)) return null; + // Skip native DOM event triggers — handled by setupDOMEventTriggerDelegation + if (!LIFECYCLE_SET.has(eventPart) && !eventPart.includes(":")) return null; const segments = eventPart.split(":"); const lastSegment = segments[segments.length - 1]; @@ -223,6 +233,30 @@ export function processReactiveAttributes( }); } +/** + * Process all lvt-el:*:on:{trigger} attributes on an element for a given trigger. + */ +export function processElementInteraction(element: Element, trigger: string): void { + for (const attr of element.attributes) { + const match = attr.name.match(/^lvt-el:(\w+):on:([a-z-]+)$/i); + if (!match) continue; + if (match[2].toLowerCase() !== trigger) continue; + + const methodKey = match[1].toLowerCase(); + const action = METHOD_MAP[methodKey]; + if (!action) continue; + + executeAction(element, action, attr.value); + } +} + +/** + * Checks if a trigger name is a native DOM event (not lifecycle or synthetic). + */ +export function isDOMEventTrigger(trigger: string): boolean { + return !LIFECYCLE_SET.has(trigger) && !SYNTHETIC_TRIGGERS.has(trigger); +} + /** * Set up document-level event listeners for reactive attributes. */ diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 0080a5d..0000323 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -371,6 +371,9 @@ export class LiveTemplateClient { // Set up click-away delegation this.eventDelegator.setupClickAwayDelegation(); + // Set up DOM event trigger delegation for lvt-el:*:on:{event} attributes + this.eventDelegator.setupDOMEventTriggerDelegation(); + // Set up click-outside listener for client-managed toast stack setupToastClickOutside(); diff --git a/tests/reactive-attributes.test.ts b/tests/reactive-attributes.test.ts index 8d51ffa..e81bd27 100644 --- a/tests/reactive-attributes.test.ts +++ b/tests/reactive-attributes.test.ts @@ -4,6 +4,8 @@ import { matchesEvent, processReactiveAttributes, setupReactiveAttributeListeners, + processElementInteraction, + isDOMEventTrigger, type ReactiveBinding, type LifecycleEvent, } from "../dom/reactive-attributes"; @@ -103,10 +105,18 @@ describe("Reactive Attributes", () => { }); }); - describe("click-away interaction keyword", () => { + describe("interaction triggers (non-lifecycle)", () => { it("returns null for click-away (handled by click-away delegation)", () => { expect(parseReactiveAttribute("lvt-el:removeclass:on:click-away", "open")).toBeNull(); }); + + it("returns null for native DOM event triggers (handled by DOM event delegation)", () => { + expect(parseReactiveAttribute("lvt-el:toggleclass:on:click", "open")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:addclass:on:focusin", "open")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:removeclass:on:focusout", "open")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:addclass:on:mouseenter", "visible")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:removeclass:on:mouseleave", "visible")).toBeNull(); + }); }); describe("invalid attribute parsing", () => { @@ -459,4 +469,81 @@ describe("Reactive Attributes", () => { expect(div.classList.contains("success-state")).toBe(true); }); }); + + describe("isDOMEventTrigger", () => { + it("returns false for lifecycle states", () => { + expect(isDOMEventTrigger("pending")).toBe(false); + expect(isDOMEventTrigger("success")).toBe(false); + expect(isDOMEventTrigger("error")).toBe(false); + expect(isDOMEventTrigger("done")).toBe(false); + }); + + it("returns false for synthetic triggers", () => { + expect(isDOMEventTrigger("click-away")).toBe(false); + }); + + it("returns true for native DOM events", () => { + expect(isDOMEventTrigger("click")).toBe(true); + expect(isDOMEventTrigger("focusin")).toBe(true); + expect(isDOMEventTrigger("focusout")).toBe(true); + expect(isDOMEventTrigger("mouseenter")).toBe(true); + expect(isDOMEventTrigger("mouseleave")).toBe(true); + expect(isDOMEventTrigger("keydown")).toBe(true); + expect(isDOMEventTrigger("input")).toBe(true); + }); + }); + + describe("processElementInteraction", () => { + it("adds class on matching trigger", () => { + const div = document.createElement("div"); + div.setAttribute("lvt-el:addClass:on:click", "open"); + document.body.appendChild(div); + + processElementInteraction(div, "click"); + expect(div.classList.contains("open")).toBe(true); + }); + + it("removes class on matching trigger", () => { + const div = document.createElement("div"); + div.classList.add("open"); + div.setAttribute("lvt-el:removeClass:on:focusout", "open"); + document.body.appendChild(div); + + processElementInteraction(div, "focusout"); + expect(div.classList.contains("open")).toBe(false); + }); + + it("toggles class on matching trigger", () => { + const div = document.createElement("div"); + div.setAttribute("lvt-el:toggleClass:on:click", "open"); + document.body.appendChild(div); + + processElementInteraction(div, "click"); + expect(div.classList.contains("open")).toBe(true); + processElementInteraction(div, "click"); + expect(div.classList.contains("open")).toBe(false); + }); + + it("ignores non-matching trigger", () => { + const div = document.createElement("div"); + div.setAttribute("lvt-el:addClass:on:click", "open"); + document.body.appendChild(div); + + processElementInteraction(div, "mouseenter"); + expect(div.classList.contains("open")).toBe(false); + }); + + it("handles multiple triggers on same element", () => { + const div = document.createElement("div"); + div.setAttribute("lvt-el:addClass:on:mouseenter", "visible"); + div.setAttribute("lvt-el:removeClass:on:mouseleave", "visible"); + document.body.appendChild(div); + + processElementInteraction(div, "mouseenter"); + expect(div.classList.contains("visible")).toBe(true); + + processElementInteraction(div, "mouseleave"); + expect(div.classList.contains("visible")).toBe(false); + }); + }); }); From 20b7c11328dab0402357d2a1339ab78760377bb1 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 09:17:08 +0530 Subject: [PATCH 03/22] feat: extend lvt-fx: directives to support DOM event and lifecycle triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lvt-fx:{effect}:on:{trigger} now supports three trigger types: - Implicit (no :on:): fires on every DOM update (existing behavior) - Lifecycle (:on:success, :on:pending, etc.): fires on action lifecycle - DOM event (:on:click, :on:mouseenter, etc.): fires on native DOM event Examples: lvt-fx:highlight:on:click="flash" — highlight on click lvt-fx:highlight:on:mouseenter="flash" — highlight on hover lvt-fx:highlight:on:success="flash" — highlight on action success lvt-fx:animate:on:click="fade" — animate on click Refactored directive handlers to share a common applyFxEffect() function used by all three trigger paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 199 +++++++++++++++++++++++++++++++++++++++ livetemplate-client.ts | 14 ++- tests/directives.test.ts | 51 ++++++++++ 3 files changed, 261 insertions(+), 3 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index 1a89564..e507945 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -1,5 +1,197 @@ +import { isDOMEventTrigger, SYNTHETIC_TRIGGERS } from "./reactive-attributes"; + +// ─── Trigger parsing for lvt-fx: attributes ───────────────────────────────── + +const FX_LIFECYCLE_SET = new Set(["pending", "success", "error", "done"]); + +/** + * Parse a lvt-fx:{effect}[:on:[{action}:]{trigger}] attribute name. + * Returns the trigger type or null for implicit (no :on:). + */ +function parseFxTrigger(attrName: string): { trigger: string | null; actionName?: string } { + // Check for :on: suffix pattern + const onMatch = attrName.match(/^lvt-fx:\w+:on:(.+)$/i); + if (!onMatch) return { trigger: null }; // implicit trigger + + const parts = onMatch[1].split(":"); + if (parts.length === 1) { + return { trigger: parts[0].toLowerCase() }; + } + // action-scoped: lvt-fx:highlight:on:save:success + return { + trigger: parts[parts.length - 1].toLowerCase(), + actionName: parts.slice(0, -1).join(":"), + }; +} + +/** + * Set up DOM event listeners for lvt-fx: attributes with :on:{event} triggers. + * Called after each DOM update to handle new elements. + */ +export function setupFxDOMEventTriggers(rootElement: Element): void { + rootElement.querySelectorAll("*").forEach(el => { + for (const attr of el.attributes) { + if (!attr.name.startsWith("lvt-fx:")) continue; + const parsed = parseFxTrigger(attr.name); + if (!parsed.trigger) continue; // implicit — handled by normal directive flow + if (FX_LIFECYCLE_SET.has(parsed.trigger)) continue; // lifecycle — handled by event listeners + if (SYNTHETIC_TRIGGERS.has(parsed.trigger)) continue; // click-away etc. + + // It's a DOM event trigger + const listenerKey = `__lvt_fx_${attr.name}`; + if ((el as any)[listenerKey]) continue; // already attached + + const effect = attr.name.match(/^lvt-fx:(\w+)/i)?.[1]; + if (!effect) continue; + + const value = attr.value; + const listener = () => { + applyFxEffect(el as HTMLElement, effect, value); + }; + el.addEventListener(parsed.trigger, listener); + (el as any)[listenerKey] = listener; + } + }); +} + +/** + * Process lvt-fx: attributes triggered by a lifecycle event. + */ +export function processFxLifecycleAttributes( + rootElement: Element, + lifecycle: string, + actionName?: string, +): void { + rootElement.querySelectorAll("*").forEach(el => { + for (const attr of el.attributes) { + if (!attr.name.startsWith("lvt-fx:")) continue; + const parsed = parseFxTrigger(attr.name); + if (!parsed.trigger || !FX_LIFECYCLE_SET.has(parsed.trigger)) continue; + if (parsed.trigger !== lifecycle) continue; + if (parsed.actionName && parsed.actionName !== actionName) continue; + + const effect = attr.name.match(/^lvt-fx:(\w+)/i)?.[1]; + if (!effect) continue; + + applyFxEffect(el as HTMLElement, effect, attr.value); + } + }); +} + +/** + * Apply a visual effect to an element. + */ +function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string): void { + const computed = getComputedStyle(htmlElement); + + switch (effect) { + case "highlight": { + const duration = parseInt( + computed.getPropertyValue("--lvt-highlight-duration").trim() || "500", 10 + ); + const color = computed.getPropertyValue("--lvt-highlight-color").trim() || "#ffc107"; + const originalBackground = htmlElement.style.backgroundColor; + const originalTransition = htmlElement.style.transition; + + htmlElement.style.transition = `background-color ${duration}ms ease-out`; + htmlElement.style.backgroundColor = color; + + setTimeout(() => { + htmlElement.style.backgroundColor = originalBackground; + setTimeout(() => { + htmlElement.style.transition = originalTransition; + }, duration); + }, 50); + break; + } + case "animate": { + const duration = parseInt( + computed.getPropertyValue("--lvt-animate-duration").trim() || "300", 10 + ); + const animation = config || "fade"; + htmlElement.style.setProperty("--lvt-animate-duration", `${duration}ms`); + + switch (animation) { + case "fade": + htmlElement.style.animation = `lvt-fade-in var(--lvt-animate-duration) ease-out`; + break; + case "slide": + htmlElement.style.animation = `lvt-slide-in var(--lvt-animate-duration) ease-out`; + break; + case "scale": + htmlElement.style.animation = `lvt-scale-in var(--lvt-animate-duration) ease-out`; + break; + default: + console.warn(`Unknown lvt-fx:animate mode: ${animation}`); + } + htmlElement.addEventListener("animationend", () => { + htmlElement.style.animation = ""; + }, { once: true }); + break; + } + case "scroll": { + const rawBehavior = computed.getPropertyValue("--lvt-scroll-behavior").trim(); + const behavior: ScrollBehavior = VALID_SCROLL_BEHAVIORS.has(rawBehavior) + ? (rawBehavior as ScrollBehavior) : "auto"; + const threshold = parseInt( + computed.getPropertyValue("--lvt-scroll-threshold").trim() || "100", 10 + ); + const mode = config || "bottom"; + + switch (mode) { + case "bottom": + htmlElement.scrollTo({ top: htmlElement.scrollHeight, behavior }); + break; + case "bottom-sticky": { + const isNearBottom = htmlElement.scrollHeight - htmlElement.scrollTop - htmlElement.clientHeight <= threshold; + if (isNearBottom) htmlElement.scrollTo({ top: htmlElement.scrollHeight, behavior }); + break; + } + case "top": + htmlElement.scrollTo({ top: 0, behavior }); + break; + case "preserve": + break; + default: + console.warn(`Unknown lvt-fx:scroll mode: ${mode}`); + } + break; + } + } +} + +/** + * Set up document-level lifecycle listeners for lvt-fx: attributes with :on:{lifecycle}. + * Called once at connect time. Fires the appropriate effect when a lifecycle event occurs. + */ +export function setupFxLifecycleListeners(): void { + const lifecycles = ["pending", "success", "error", "done"]; + lifecycles.forEach(lifecycle => { + document.addEventListener(`lvt:${lifecycle}`, (e: Event) => { + const customEvent = e as CustomEvent; + const actionName = customEvent.detail?.action; + // Process all lvt-fx: elements in the document + document.querySelectorAll("*").forEach(el => { + for (const attr of el.attributes) { + if (!attr.name.startsWith("lvt-fx:")) continue; + const parsed = parseFxTrigger(attr.name); + if (!parsed.trigger || parsed.trigger !== lifecycle) continue; + if (parsed.actionName && parsed.actionName !== actionName) continue; + + const effect = attr.name.match(/^lvt-fx:(\w+)/i)?.[1]; + if (!effect) continue; + applyFxEffect(el as HTMLElement, effect, attr.value); + } + }); + }, true); + }); +} + +// ─── Implicit-trigger directive handlers (fire on every DOM update) ────────── + /** * Apply scroll directives on elements with lvt-fx:scroll attributes. + * Only processes attributes WITHOUT :on: suffix (implicit trigger). * Configuration read from CSS custom properties: * --lvt-scroll-behavior: auto | smooth (default: auto) * --lvt-scroll-threshold: (default: 100) @@ -11,6 +203,9 @@ export function handleScrollDirectives(rootElement: Element): void { scrollElements.forEach((element) => { const htmlElement = element as HTMLElement; + // Only handle implicit triggers (no :on: suffix) — triggered attributes + // are handled by setupFxDOMEventTriggers / processFxLifecycleAttributes + if (!htmlElement.hasAttribute("lvt-fx:scroll")) return; const mode = htmlElement.getAttribute("lvt-fx:scroll"); const computed = getComputedStyle(htmlElement); const rawBehavior = computed.getPropertyValue("--lvt-scroll-behavior").trim(); @@ -73,6 +268,8 @@ export function handleHighlightDirectives(rootElement: Element): void { const highlightElements = rootElement.querySelectorAll("[lvt-fx\\:highlight]"); highlightElements.forEach((element) => { + // Only handle implicit triggers (no :on: suffix) + if (!element.hasAttribute("lvt-fx:highlight")) return; const mode = element.getAttribute("lvt-fx:highlight"); const computed = getComputedStyle(element); const duration = parseInt( @@ -109,6 +306,8 @@ export function handleAnimateDirectives(rootElement: Element): void { const animateElements = rootElement.querySelectorAll("[lvt-fx\\:animate]"); animateElements.forEach((element) => { + // Only handle implicit triggers (no :on: suffix) + if (!element.hasAttribute("lvt-fx:animate")) return; const animation = element.getAttribute("lvt-fx:animate"); const computed = getComputedStyle(element); const duration = parseInt( diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 0000323..b982629 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -13,6 +13,8 @@ import { handleScrollDirectives, handleToastDirectives, setupToastClickOutside, + setupFxDOMEventTriggers, + setupFxLifecycleListeners, } from "./dom/directives"; import { EventDelegator } from "./dom/event-delegation"; import { LinkInterceptor } from "./dom/link-interceptor"; @@ -389,6 +391,9 @@ export class LiveTemplateClient { // Set up reactive attribute listeners for lvt-el:*:on:* attributes setupReactiveAttributeListeners(); + // Set up lifecycle listeners for lvt-fx:*:on:{lifecycle} attributes + setupFxLifecycleListeners(); + // Initialize focus tracking this.focusManager.attach(this.wrapperElement); @@ -799,15 +804,18 @@ export class LiveTemplateClient { // Restore focus to previously focused element this.focusManager.restoreFocusedElement(); - // Handle scroll directives + // Handle scroll directives (implicit trigger only) handleScrollDirectives(element); - // Handle highlight directives + // Handle highlight directives (implicit trigger only) handleHighlightDirectives(element); - // Handle animate directives + // Handle animate directives (implicit trigger only) handleAnimateDirectives(element); + // Set up DOM event triggers for lvt-fx: attributes with :on:{event} + setupFxDOMEventTriggers(element); + // Handle toast trigger directives (ephemeral client-side toasts) handleToastDirectives(element); diff --git a/tests/directives.test.ts b/tests/directives.test.ts index e8dc518..7621a53 100644 --- a/tests/directives.test.ts +++ b/tests/directives.test.ts @@ -2,6 +2,7 @@ import { handleScrollDirectives, handleHighlightDirectives, handleAnimateDirectives, + setupFxDOMEventTriggers, } from "../dom/directives"; describe("handleScrollDirectives", () => { @@ -281,3 +282,53 @@ describe("handleAnimateDirectives", () => { expect(target.style.animation).toBe(""); }); }); + +describe("setupFxDOMEventTriggers", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("attaches highlight effect on click trigger", () => { + const target = document.createElement("div"); + target.setAttribute("lvt-fx:highlight:on:click", "flash"); + document.body.appendChild(target); + + setupFxDOMEventTriggers(document.body); + target.click(); + + expect(target.style.backgroundColor).not.toBe(""); + }); + + it("does not fire for implicit trigger (no :on:)", () => { + const target = document.createElement("div"); + target.setAttribute("lvt-fx:highlight", "flash"); + document.body.appendChild(target); + + setupFxDOMEventTriggers(document.body); + target.click(); + + expect(target.style.backgroundColor).toBe(""); + }); + + it("does not fire for lifecycle trigger", () => { + const target = document.createElement("div"); + target.setAttribute("lvt-fx:highlight:on:success", "flash"); + document.body.appendChild(target); + + setupFxDOMEventTriggers(document.body); + target.click(); + + expect(target.style.backgroundColor).toBe(""); + }); + + it("attaches mouseenter trigger for highlight", () => { + const target = document.createElement("div"); + target.setAttribute("lvt-fx:highlight:on:mouseenter", "flash"); + document.body.appendChild(target); + + setupFxDOMEventTriggers(document.body); + target.dispatchEvent(new MouseEvent("mouseenter")); + + expect(target.style.backgroundColor).not.toBe(""); + }); +}); From 795ffdb1d5b66242f9c4e0aea1b165564079a0ef Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 09:26:00 +0530 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20address=20bot=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20delegation=20boundary,=20dedup,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix bubbling delegation to stop at closest matching element instead of walking all ancestors (was triggering both parent and child) - Add deduplication guard to setupFxLifecycleListeners (prevents listener stacking on reconnect) - Use processFxLifecycleAttributes in lifecycle listener (remove duplication) - Remove unused SYNTHETIC_TRIGGERS import from event-delegation.ts - Call setupDOMEventTriggerDelegation after each DOM update (not just connect) - Clarify doc header: action scoping only applies to lifecycle triggers - Add Unreleased section to CHANGELOG with new features Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 7 +++++++ dom/directives.ts | 19 ++++++------------- dom/event-delegation.ts | 21 ++++++++++++++++----- dom/reactive-attributes.ts | 24 ++++++++++++------------ livetemplate-client.ts | 3 +++ 5 files changed, 44 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03112ce..f1cc1f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to @livetemplate/client will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- feat: `lvt-el:{method}:on:{event}` now supports any native DOM event as trigger (click, focusin, focusout, mouseenter, mouseleave, keydown, etc.) — no server round-trip, CSP-safe +- feat: `lvt-fx:{effect}:on:{event}` supports DOM event triggers (e.g., `lvt-fx:highlight:on:click="flash"`) and lifecycle triggers (e.g., `lvt-fx:highlight:on:success="flash"`) + ## [v0.8.17] - 2026-04-05 ### Changes diff --git a/dom/directives.ts b/dom/directives.ts index e507945..c9367d4 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -163,26 +163,19 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) /** * Set up document-level lifecycle listeners for lvt-fx: attributes with :on:{lifecycle}. * Called once at connect time. Fires the appropriate effect when a lifecycle event occurs. + * Guarded against duplicate registration. */ +let fxLifecycleListenersSetup = false; export function setupFxLifecycleListeners(): void { + if (fxLifecycleListenersSetup) return; + fxLifecycleListenersSetup = true; + const lifecycles = ["pending", "success", "error", "done"]; lifecycles.forEach(lifecycle => { document.addEventListener(`lvt:${lifecycle}`, (e: Event) => { const customEvent = e as CustomEvent; const actionName = customEvent.detail?.action; - // Process all lvt-fx: elements in the document - document.querySelectorAll("*").forEach(el => { - for (const attr of el.attributes) { - if (!attr.name.startsWith("lvt-fx:")) continue; - const parsed = parseFxTrigger(attr.name); - if (!parsed.trigger || parsed.trigger !== lifecycle) continue; - if (parsed.actionName && parsed.actionName !== actionName) continue; - - const effect = attr.name.match(/^lvt-fx:(\w+)/i)?.[1]; - if (!effect) continue; - applyFxEffect(el as HTMLElement, effect, attr.value); - } - }); + processFxLifecycleAttributes(document.documentElement, lifecycle, actionName); }, true); }); } diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index d9360b4..1a1655b 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -1,6 +1,6 @@ import { debounce, throttle } from "../utils/rate-limit"; import { lvtSelector } from "../utils/lvt-selector"; -import { executeAction, processElementInteraction, isDOMEventTrigger, SYNTHETIC_TRIGGERS, type ReactiveAction } from "./reactive-attributes"; +import { executeAction, processElementInteraction, isDOMEventTrigger, type ReactiveAction } from "./reactive-attributes"; import type { Logger } from "../utils/logger"; // Methods supported by click-away, derived from ReactiveAction values @@ -589,7 +589,7 @@ export class EventDelegator { * listeners for non-bubbling events (mouseenter, mouseleave) and delegated * listeners on the wrapper for bubbling events (click, focusin, focusout, etc.). * - * Called after each render/update to handle new elements. + * Called during connect and after each DOM update to handle new elements. */ setupDOMEventTriggerDelegation(): void { const wrapperElement = this.context.getWrapperElement(); @@ -623,13 +623,24 @@ export class EventDelegator { el.addEventListener(trigger, listener); (el as any)[key] = listener; } else if (!delegated.has(trigger)) { - // Delegated listener on wrapper for bubbling events + // Delegated listener on wrapper for bubbling events. + // Walks from target to wrapper, processing only the closest matching element. wrapperElement.addEventListener(trigger, (e: Event) => { let target = e.target as Element | null; - while (target && target !== wrapperElement.parentElement) { - processElementInteraction(target, trigger); + while (target && target !== wrapperElement) { + const hasMatch = Array.from(target.attributes).some( + a => a.name.match(new RegExp(`^lvt-el:\\w+:on:${trigger}$`, "i")) + ); + if (hasMatch) { + processElementInteraction(target, trigger); + return; // Stop at closest match + } target = target.parentElement; } + // Also check wrapper itself + if (target === wrapperElement) { + processElementInteraction(wrapperElement, trigger); + } }); delegated.add(trigger); } diff --git a/dom/reactive-attributes.ts b/dom/reactive-attributes.ts index fb7cbf1..623f663 100644 --- a/dom/reactive-attributes.ts +++ b/dom/reactive-attributes.ts @@ -1,21 +1,21 @@ /** * Reactive Attributes - Declarative DOM actions triggered by lifecycle events or interactions. * - * Attribute Pattern: lvt-el:{method}:on:[{action}:]{state|interaction}="param" + * Attribute Pattern: lvt-el:{method}:on:{trigger}="param" * - * States (lifecycle — server action request-response cycle): - * - pending: Action started, waiting for server response - * - success: Action completed successfully - * - error: Action completed with validation errors - * - done: Action completed (regardless of success/error) + * Trigger types: * - * Interactions (client-side — no server round-trip): - * - Any native DOM event (click, focusin, focusout, mouseenter, mouseleave, keydown, etc.) - * - click-away: Synthetic — click outside the element (not a native DOM event) + * 1. Lifecycle states (server action request-response cycle): + * - pending, success, error, done + * - Supports action scoping: lvt-el:reset:on:create-todo:success * - * Trigger Scope: - * - Unscoped: lvt-el:reset:on:success (any action) - * - Action-scoped: lvt-el:reset:on:create-todo:success (specific action only) + * 2. Native DOM events (client-side, no server round-trip): + * - Any browser event: click, focusin, focusout, mouseenter, mouseleave, keydown, etc. + * - No action scoping (fires on the element's own event) + * + * 3. Synthetic interactions (client-side): + * - click-away: Click outside the element + * - No action scoping * * Methods: * - reset: Calls form.reset() diff --git a/livetemplate-client.ts b/livetemplate-client.ts index b982629..0a84f7f 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -816,6 +816,9 @@ export class LiveTemplateClient { // Set up DOM event triggers for lvt-fx: attributes with :on:{event} setupFxDOMEventTriggers(element); + // Re-scan for lvt-el:*:on:{event} DOM triggers on new/updated elements + this.eventDelegator.setupDOMEventTriggerDelegation(); + // Handle toast trigger directives (ephemeral client-side toasts) handleToastDirectives(element); From d914c47d2ed65a4cea0c2452b5ce9e8bb61b89a7 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 09:33:17 +0530 Subject: [PATCH 05/22] =?UTF-8?q?fix:=20eliminate=20duplicate=20directive?= =?UTF-8?q?=20logic=20=E2=80=94=20thin=20wrappers=20around=20applyFxEffect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 153 +++++----------------------------------------- 1 file changed, 15 insertions(+), 138 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index c9367d4..d38def8 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -192,62 +192,10 @@ export function setupFxLifecycleListeners(): void { const VALID_SCROLL_BEHAVIORS = new Set(["auto", "smooth", "instant"]); export function handleScrollDirectives(rootElement: Element): void { - const scrollElements = rootElement.querySelectorAll("[lvt-fx\\:scroll]"); - - scrollElements.forEach((element) => { - const htmlElement = element as HTMLElement; - // Only handle implicit triggers (no :on: suffix) — triggered attributes - // are handled by setupFxDOMEventTriggers / processFxLifecycleAttributes - if (!htmlElement.hasAttribute("lvt-fx:scroll")) return; - const mode = htmlElement.getAttribute("lvt-fx:scroll"); - const computed = getComputedStyle(htmlElement); - const rawBehavior = computed.getPropertyValue("--lvt-scroll-behavior").trim(); - const behavior: ScrollBehavior = VALID_SCROLL_BEHAVIORS.has(rawBehavior) - ? (rawBehavior as ScrollBehavior) - : "auto"; - const threshold = parseInt( - computed.getPropertyValue("--lvt-scroll-threshold").trim() || "100", - 10 - ); - + rootElement.querySelectorAll("[lvt-fx\\:scroll]").forEach((element) => { + const mode = element.getAttribute("lvt-fx:scroll"); if (!mode) return; - - switch (mode) { - case "bottom": - htmlElement.scrollTo({ - top: htmlElement.scrollHeight, - behavior, - }); - break; - - case "bottom-sticky": { - const isNearBottom = - htmlElement.scrollHeight - - htmlElement.scrollTop - - htmlElement.clientHeight <= - threshold; - if (isNearBottom) { - htmlElement.scrollTo({ - top: htmlElement.scrollHeight, - behavior, - }); - } - break; - } - - case "top": - htmlElement.scrollTo({ - top: 0, - behavior, - }); - break; - - case "preserve": - break; - - default: - console.warn(`Unknown lvt-fx:scroll mode: ${mode}`); - } + applyFxEffect(element as HTMLElement, "scroll", mode); }); } @@ -258,35 +206,10 @@ export function handleScrollDirectives(rootElement: Element): void { * --lvt-highlight-color: (default: #ffc107) */ export function handleHighlightDirectives(rootElement: Element): void { - const highlightElements = rootElement.querySelectorAll("[lvt-fx\\:highlight]"); - - highlightElements.forEach((element) => { - // Only handle implicit triggers (no :on: suffix) - if (!element.hasAttribute("lvt-fx:highlight")) return; + rootElement.querySelectorAll("[lvt-fx\\:highlight]").forEach((element) => { const mode = element.getAttribute("lvt-fx:highlight"); - const computed = getComputedStyle(element); - const duration = parseInt( - computed.getPropertyValue("--lvt-highlight-duration").trim() || "500", - 10 - ); - const color = computed.getPropertyValue("--lvt-highlight-color").trim() || "#ffc107"; - if (!mode) return; - - const htmlElement = element as HTMLElement; - const originalBackground = htmlElement.style.backgroundColor; - const originalTransition = htmlElement.style.transition; - - htmlElement.style.transition = `background-color ${duration}ms ease-out`; - htmlElement.style.backgroundColor = color; - - setTimeout(() => { - htmlElement.style.backgroundColor = originalBackground; - - setTimeout(() => { - htmlElement.style.transition = originalTransition; - }, duration); - }, 50); + applyFxEffect(element as HTMLElement, "highlight", mode); }); } @@ -296,50 +219,16 @@ export function handleHighlightDirectives(rootElement: Element): void { * --lvt-animate-duration: (default: 300) */ export function handleAnimateDirectives(rootElement: Element): void { - const animateElements = rootElement.querySelectorAll("[lvt-fx\\:animate]"); - - animateElements.forEach((element) => { - // Only handle implicit triggers (no :on: suffix) - if (!element.hasAttribute("lvt-fx:animate")) return; + rootElement.querySelectorAll("[lvt-fx\\:animate]").forEach((element) => { const animation = element.getAttribute("lvt-fx:animate"); - const computed = getComputedStyle(element); - const duration = parseInt( - computed.getPropertyValue("--lvt-animate-duration").trim() || "300", - 10 - ); - if (!animation) return; - - const htmlElement = element as HTMLElement; - - htmlElement.style.setProperty("--lvt-animate-duration", `${duration}ms`); - - switch (animation) { - case "fade": - htmlElement.style.animation = `lvt-fade-in var(--lvt-animate-duration) ease-out`; - break; - - case "slide": - htmlElement.style.animation = `lvt-slide-in var(--lvt-animate-duration) ease-out`; - break; - - case "scale": - htmlElement.style.animation = `lvt-scale-in var(--lvt-animate-duration) ease-out`; - break; - - default: - console.warn(`Unknown lvt-fx:animate mode: ${animation}`); - } - - htmlElement.addEventListener( - "animationend", - () => { - htmlElement.style.animation = ""; - }, - { once: true } - ); + applyFxEffect(element as HTMLElement, "animate", animation); }); + ensureAnimateKeyframes(); +} + +function ensureAnimateKeyframes(): void { if (!document.getElementById("lvt-animate-styles")) { const style = document.createElement("style"); style.id = "lvt-animate-styles"; @@ -349,24 +238,12 @@ export function handleAnimateDirectives(rootElement: Element): void { to { opacity: 1; } } @keyframes lvt-slide-in { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } } @keyframes lvt-scale-in { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } } `; document.head.appendChild(style); From 6c173c6f871db91c9c06f4ad114dfe2bcbf62723 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 09:38:31 +0530 Subject: [PATCH 06/22] fix: read fx attribute value at fire time to avoid stale closure Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index d38def8..a316759 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -44,9 +44,10 @@ export function setupFxDOMEventTriggers(rootElement: Element): void { const effect = attr.name.match(/^lvt-fx:(\w+)/i)?.[1]; if (!effect) continue; - const value = attr.value; + const attrNameCapture = attr.name; const listener = () => { - applyFxEffect(el as HTMLElement, effect, value); + const currentValue = el.getAttribute(attrNameCapture) || ""; + applyFxEffect(el as HTMLElement, effect, currentValue); }; el.addEventListener(parsed.trigger, listener); (el as any)[listenerKey] = listener; From 2be3925f88e6478c7f1ddecfa4d12414c30043c3 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 09:43:36 +0530 Subject: [PATCH 07/22] fix: guard overlapping highlights, hoist regex, handle removed attrs - Skip highlight if already mid-animation to prevent stale capture - Hoist RegExp out of event listener closure for performance - Guard against morphdom-removed attributes in fx listeners Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 6 ++++++ dom/event-delegation.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/dom/directives.ts b/dom/directives.ts index a316759..7aaa923 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -46,6 +46,7 @@ export function setupFxDOMEventTriggers(rootElement: Element): void { const attrNameCapture = attr.name; const listener = () => { + if (!el.hasAttribute(attrNameCapture)) return; // attr removed by morphdom const currentValue = el.getAttribute(attrNameCapture) || ""; applyFxEffect(el as HTMLElement, effect, currentValue); }; @@ -87,6 +88,10 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) switch (effect) { case "highlight": { + // Skip if already mid-highlight to prevent stale originalBackground capture + if ((htmlElement as any).__lvtHighlighting) break; + (htmlElement as any).__lvtHighlighting = true; + const duration = parseInt( computed.getPropertyValue("--lvt-highlight-duration").trim() || "500", 10 ); @@ -101,6 +106,7 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) htmlElement.style.backgroundColor = originalBackground; setTimeout(() => { htmlElement.style.transition = originalTransition; + (htmlElement as any).__lvtHighlighting = false; }, duration); }, 50); break; diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 1a1655b..af69272 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -625,11 +625,12 @@ export class EventDelegator { } else if (!delegated.has(trigger)) { // Delegated listener on wrapper for bubbling events. // Walks from target to wrapper, processing only the closest matching element. + const triggerPattern = new RegExp(`^lvt-el:\\w+:on:${trigger}$`, "i"); wrapperElement.addEventListener(trigger, (e: Event) => { let target = e.target as Element | null; while (target && target !== wrapperElement) { const hasMatch = Array.from(target.attributes).some( - a => a.name.match(new RegExp(`^lvt-el:\\w+:on:${trigger}$`, "i")) + a => triggerPattern.test(a.name) ); if (hasMatch) { processElementInteraction(target, trigger); From 58c8961f2743e5179f57b6474d26088ef5736483 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 10:39:18 +0530 Subject: [PATCH 08/22] fix(ci): remove deleted graceful-shutdown example from cross-repo tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cross-repo-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cross-repo-test.yml b/.github/workflows/cross-repo-test.yml index b387439..931eec5 100644 --- a/.github/workflows/cross-repo-test.yml +++ b/.github/workflows/cross-repo-test.yml @@ -123,7 +123,7 @@ jobs: LVT_PATH=$(realpath ../lvt) # Test each working example - for example in counter chat todos graceful-shutdown testing/01_basic; do + for example in counter chat todos testing/01_basic; do echo "Testing: $example" cd "$example" From a9bdba8a4ebe5c89bd7d9d8139311452a962a4b2 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 10:42:58 +0530 Subject: [PATCH 09/22] fix(ci): also remove deleted testing/01_basic from cross-repo tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cross-repo-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cross-repo-test.yml b/.github/workflows/cross-repo-test.yml index 931eec5..f67cba1 100644 --- a/.github/workflows/cross-repo-test.yml +++ b/.github/workflows/cross-repo-test.yml @@ -123,7 +123,7 @@ jobs: LVT_PATH=$(realpath ../lvt) # Test each working example - for example in counter chat todos testing/01_basic; do + for example in counter chat todos; do echo "Testing: $example" cd "$example" From f90b25023b4b3e13f2c32efb2855ad118a99874c Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 10:58:11 +0530 Subject: [PATCH 10/22] fix: scope lifecycle listeners per wrapper, null guard, CI comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setupFxLifecycleListeners now accepts rootElement param and guards per-wrapper instead of module singleton — fixes multi-instance pages - Add null guard for wrapperId in setupDOMEventTriggerDelegation - Add comment explaining removed examples from cross-repo CI Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cross-repo-test.yml | 1 + dom/directives.ts | 14 +++++++------- dom/event-delegation.ts | 1 + livetemplate-client.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cross-repo-test.yml b/.github/workflows/cross-repo-test.yml index f67cba1..2aa99e8 100644 --- a/.github/workflows/cross-repo-test.yml +++ b/.github/workflows/cross-repo-test.yml @@ -123,6 +123,7 @@ jobs: LVT_PATH=$(realpath ../lvt) # Test each working example + # Note: graceful-shutdown and testing/01_basic were removed from examples repo for example in counter chat todos; do echo "Testing: $example" cd "$example" diff --git a/dom/directives.ts b/dom/directives.ts index 7aaa923..c5b49be 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -169,20 +169,20 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) /** * Set up document-level lifecycle listeners for lvt-fx: attributes with :on:{lifecycle}. - * Called once at connect time. Fires the appropriate effect when a lifecycle event occurs. - * Guarded against duplicate registration. + * Called once per wrapper at connect time. Scoped to the provided root element so + * multiple LiveTemplateClient instances on the same page don't cross-fire effects. */ -let fxLifecycleListenersSetup = false; -export function setupFxLifecycleListeners(): void { - if (fxLifecycleListenersSetup) return; - fxLifecycleListenersSetup = true; +export function setupFxLifecycleListeners(rootElement: Element): void { + const guardKey = "__lvtFxLifecycleSetup"; + if ((rootElement as any)[guardKey]) return; + (rootElement as any)[guardKey] = true; const lifecycles = ["pending", "success", "error", "done"]; lifecycles.forEach(lifecycle => { document.addEventListener(`lvt:${lifecycle}`, (e: Event) => { const customEvent = e as CustomEvent; const actionName = customEvent.detail?.action; - processFxLifecycleAttributes(document.documentElement, lifecycle, actionName); + processFxLifecycleAttributes(rootElement, lifecycle, actionName); }, true); }); } diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index af69272..30eb051 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -596,6 +596,7 @@ export class EventDelegator { if (!wrapperElement) return; const wrapperId = wrapperElement.getAttribute("data-lvt-id"); + if (!wrapperId) return; // Non-bubbling events need direct attachment const NON_BUBBLING = new Set(["mouseenter", "mouseleave", "focus", "blur"]); // Track which bubbling events we've already delegated at wrapper level diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 0a84f7f..3bf6b5d 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -392,7 +392,7 @@ export class LiveTemplateClient { setupReactiveAttributeListeners(); // Set up lifecycle listeners for lvt-fx:*:on:{lifecycle} attributes - setupFxLifecycleListeners(); + setupFxLifecycleListeners(this.wrapperElement); // Initialize focus tracking this.focusManager.attach(this.wrapperElement); From 08c51dca02371d9a3e09bcf77274322deace63a2 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:03:24 +0530 Subject: [PATCH 11/22] fix: teardown lifecycle listeners on disconnect, scope DOM scan to subtree - setupFxLifecycleListeners stores listener refs; new teardownFxLifecycleListeners removes them on disconnect to prevent accumulation across reconnects - setupDOMEventTriggerDelegation accepts optional scanRoot param; after DOM updates only the patched subtree is scanned instead of the full wrapper - Document closest-match-only bubbling semantics in JSDoc Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 25 +++++++++++++++++++++++-- dom/event-delegation.ts | 21 +++++++++++++++------ livetemplate-client.ts | 8 ++++++-- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index c5b49be..cd4960e 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -171,20 +171,41 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) * Set up document-level lifecycle listeners for lvt-fx: attributes with :on:{lifecycle}. * Called once per wrapper at connect time. Scoped to the provided root element so * multiple LiveTemplateClient instances on the same page don't cross-fire effects. + * Stores listener references on the element for teardown via teardownFxLifecycleListeners. */ export function setupFxLifecycleListeners(rootElement: Element): void { const guardKey = "__lvtFxLifecycleSetup"; if ((rootElement as any)[guardKey]) return; (rootElement as any)[guardKey] = true; + const listeners: Array<{ event: string; handler: EventListener }> = []; const lifecycles = ["pending", "success", "error", "done"]; lifecycles.forEach(lifecycle => { - document.addEventListener(`lvt:${lifecycle}`, (e: Event) => { + const handler = (e: Event) => { const customEvent = e as CustomEvent; const actionName = customEvent.detail?.action; processFxLifecycleAttributes(rootElement, lifecycle, actionName); - }, true); + }; + document.addEventListener(`lvt:${lifecycle}`, handler, true); + listeners.push({ event: `lvt:${lifecycle}`, handler }); }); + (rootElement as any).__lvtFxLifecycleListeners = listeners; +} + +/** + * Remove document-level lifecycle listeners registered by setupFxLifecycleListeners. + * Call on disconnect to prevent listener accumulation across reconnects. + */ +export function teardownFxLifecycleListeners(rootElement: Element): void { + const listeners: Array<{ event: string; handler: EventListener }> | undefined = + (rootElement as any).__lvtFxLifecycleListeners; + if (listeners) { + listeners.forEach(({ event, handler }) => { + document.removeEventListener(event, handler, true); + }); + delete (rootElement as any).__lvtFxLifecycleListeners; + } + delete (rootElement as any).__lvtFxLifecycleSetup; } // ─── Implicit-trigger directive handlers (fire on every DOM update) ────────── diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 30eb051..2a56f5d 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -585,13 +585,21 @@ export class EventDelegator { * Sets up event listeners for lvt-el:*:on:{event} attributes where {event} * is a native DOM event (not a lifecycle state or synthetic trigger). * - * Scans the wrapper for elements with these attributes, attaches direct - * listeners for non-bubbling events (mouseenter, mouseleave) and delegated - * listeners on the wrapper for bubbling events (click, focusin, focusout, etc.). + * Scans scanRoot (or the full wrapper if omitted) for elements with these + * attributes. Attaches direct listeners for non-bubbling events (mouseenter, + * mouseleave) and delegated listeners on the wrapper for bubbling events + * (click, focusin, focusout, etc.). + * + * Bubbling delegation uses closest-match semantics: if both a child and parent + * have the same trigger, only the child's action fires. This differs from native + * event bubbling and prevents unintended double-firing in nested structures. * * Called during connect and after each DOM update to handle new elements. + * + * @param scanRoot - Subtree to scan for new attributes. Defaults to full wrapper. + * Pass the updated element after a DOM patch to avoid a full rescan. */ - setupDOMEventTriggerDelegation(): void { + setupDOMEventTriggerDelegation(scanRoot?: Element): void { const wrapperElement = this.context.getWrapperElement(); if (!wrapperElement) return; @@ -603,8 +611,9 @@ export class EventDelegator { const delegatedKey = `__lvt_el_delegated_${wrapperId}`; const delegated: Set = (wrapperElement as any)[delegatedKey] || new Set(); - // Scan all elements for lvt-el:*:on:{event} attributes - wrapperElement.querySelectorAll("*").forEach(el => { + // Scan the provided subtree (or full wrapper) for lvt-el:*:on:{event} attributes + const root = scanRoot || wrapperElement; + root.querySelectorAll("*").forEach(el => { const triggers = new Set(); for (const attr of el.attributes) { if (!attr.name.startsWith("lvt-el:")) continue; diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 3bf6b5d..1054000 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -15,6 +15,7 @@ import { setupToastClickOutside, setupFxDOMEventTriggers, setupFxLifecycleListeners, + teardownFxLifecycleListeners, } from "./dom/directives"; import { EventDelegator } from "./dom/event-delegation"; import { LinkInterceptor } from "./dom/link-interceptor"; @@ -414,6 +415,9 @@ export class LiveTemplateClient { this.formLifecycleManager.reset(); this.loadingIndicator.hide(); this.formDisabler.enable(this.wrapperElement); + if (this.wrapperElement) { + teardownFxLifecycleListeners(this.wrapperElement); + } } /** @@ -816,8 +820,8 @@ export class LiveTemplateClient { // Set up DOM event triggers for lvt-fx: attributes with :on:{event} setupFxDOMEventTriggers(element); - // Re-scan for lvt-el:*:on:{event} DOM triggers on new/updated elements - this.eventDelegator.setupDOMEventTriggerDelegation(); + // Re-scan updated subtree for lvt-el:*:on:{event} DOM triggers + this.eventDelegator.setupDOMEventTriggerDelegation(element); // Handle toast trigger directives (ephemeral client-side toasts) handleToastDirectives(element); From d40ea28e0968276d2d586e6cc7a9f06bd158e4cc Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:08:24 +0530 Subject: [PATCH 12/22] fix: teardown delegated DOM event listeners on disconnect - Add teardownDOMEventTriggerDelegation to remove wrapper-level listeners on disconnect, preventing stale handlers across reconnects - Document open event name acceptance in isDOMEventTrigger JSDoc - Clean up CHANGELOG double blank line Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 -- dom/event-delegation.ts | 36 ++++++++++++++++++++++++++++++++++-- dom/reactive-attributes.ts | 5 ++++- livetemplate-client.ts | 1 + 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cc1f6..fd4ef37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix: form.name DOM shadowing + skip File objects in FormData parsing (58cf0c2) - - ## [v0.8.16] - 2026-04-04 ### Changes diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 2a56f5d..7d35ab6 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -636,7 +636,7 @@ export class EventDelegator { // Delegated listener on wrapper for bubbling events. // Walks from target to wrapper, processing only the closest matching element. const triggerPattern = new RegExp(`^lvt-el:\\w+:on:${trigger}$`, "i"); - wrapperElement.addEventListener(trigger, (e: Event) => { + const handler = (e: Event) => { let target = e.target as Element | null; while (target && target !== wrapperElement) { const hasMatch = Array.from(target.attributes).some( @@ -652,8 +652,15 @@ export class EventDelegator { if (target === wrapperElement) { processElementInteraction(wrapperElement, trigger); } - }); + }; + wrapperElement.addEventListener(trigger, handler); delegated.add(trigger); + // Store for teardown + const listenersKey = `__lvt_el_listeners_${wrapperId}`; + const listeners: Array<{ event: string; handler: EventListener }> = + (wrapperElement as any)[listenersKey] || []; + listeners.push({ event: trigger, handler }); + (wrapperElement as any)[listenersKey] = listeners; } } }); @@ -661,6 +668,31 @@ export class EventDelegator { (wrapperElement as any)[delegatedKey] = delegated; } + /** + * Remove delegated DOM event trigger listeners added by setupDOMEventTriggerDelegation. + * Call on disconnect to prevent stale listeners firing on a disconnected component. + */ + teardownDOMEventTriggerDelegation(): void { + const wrapperElement = this.context.getWrapperElement(); + if (!wrapperElement) return; + + const wrapperId = wrapperElement.getAttribute("data-lvt-id"); + if (!wrapperId) return; + + const listenersKey = `__lvt_el_listeners_${wrapperId}`; + const listeners: Array<{ event: string; handler: EventListener }> | undefined = + (wrapperElement as any)[listenersKey]; + if (listeners) { + listeners.forEach(({ event, handler }) => { + wrapperElement.removeEventListener(event, handler); + }); + delete (wrapperElement as any)[listenersKey]; + } + + const delegatedKey = `__lvt_el_delegated_${wrapperId}`; + delete (wrapperElement as any)[delegatedKey]; + } + /** * Sets up focus trapping for elements with lvt-focus-trap attribute. * Focus is trapped within the element, cycling through focusable elements diff --git a/dom/reactive-attributes.ts b/dom/reactive-attributes.ts index 623f663..565bc37 100644 --- a/dom/reactive-attributes.ts +++ b/dom/reactive-attributes.ts @@ -251,7 +251,10 @@ export function processElementInteraction(element: Element, trigger: string): vo } /** - * Checks if a trigger name is a native DOM event (not lifecycle or synthetic). + * Checks if a trigger name is a DOM event (not lifecycle or synthetic). + * Intentionally open — accepts any string to support both native DOM events + * and custom events (e.g., lvt-el:addClass:on:my-custom-event). A typo + * silently registers a listener that never fires; no allowlist is enforced. */ export function isDOMEventTrigger(trigger: string): boolean { return !LIFECYCLE_SET.has(trigger) && !SYNTHETIC_TRIGGERS.has(trigger); diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 1054000..2b591cb 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -415,6 +415,7 @@ export class LiveTemplateClient { this.formLifecycleManager.reset(); this.loadingIndicator.hide(); this.formDisabler.enable(this.wrapperElement); + this.eventDelegator.teardownDOMEventTriggerDelegation(); if (this.wrapperElement) { teardownFxLifecycleListeners(this.wrapperElement); } From 2d61d47afe56c31335c08145d287cdfda4050ab7 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:13:49 +0530 Subject: [PATCH 13/22] fix: teardown non-bubbling direct listeners, guard disconnected highlight - Track both direct (non-bubbling) and delegated listeners in unified array on wrapper for complete cleanup in teardownDOMEventTriggerDelegation - Guard highlight timeout with isConnected check to prevent style writes on removed elements and ensure __lvtHighlighting flag is always cleared Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 4 ++++ dom/event-delegation.ts | 21 ++++++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index cd4960e..cae1916 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -103,6 +103,10 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) htmlElement.style.backgroundColor = color; setTimeout(() => { + if (!htmlElement.isConnected) { + (htmlElement as any).__lvtHighlighting = false; + return; + } htmlElement.style.backgroundColor = originalBackground; setTimeout(() => { htmlElement.style.transition = originalTransition; diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 7d35ab6..492b0e0 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -624,6 +624,11 @@ export class EventDelegator { triggers.add(trigger); } + // Store all listeners (direct + delegated) on wrapper for teardown + const listenersKey = `__lvt_el_listeners_${wrapperId}`; + const allListeners: Array<{ el: Element; event: string; handler: EventListener }> = + (wrapperElement as any)[listenersKey] || []; + for (const trigger of triggers) { if (NON_BUBBLING.has(trigger)) { // Direct attachment for non-bubbling events @@ -632,6 +637,7 @@ export class EventDelegator { const listener = () => processElementInteraction(el, trigger); el.addEventListener(trigger, listener); (el as any)[key] = listener; + allListeners.push({ el, event: trigger, handler: listener }); } else if (!delegated.has(trigger)) { // Delegated listener on wrapper for bubbling events. // Walks from target to wrapper, processing only the closest matching element. @@ -655,14 +661,11 @@ export class EventDelegator { }; wrapperElement.addEventListener(trigger, handler); delegated.add(trigger); - // Store for teardown - const listenersKey = `__lvt_el_listeners_${wrapperId}`; - const listeners: Array<{ event: string; handler: EventListener }> = - (wrapperElement as any)[listenersKey] || []; - listeners.push({ event: trigger, handler }); - (wrapperElement as any)[listenersKey] = listeners; + allListeners.push({ el: wrapperElement, event: trigger, handler }); } } + + (wrapperElement as any)[listenersKey] = allListeners; }); (wrapperElement as any)[delegatedKey] = delegated; @@ -680,11 +683,11 @@ export class EventDelegator { if (!wrapperId) return; const listenersKey = `__lvt_el_listeners_${wrapperId}`; - const listeners: Array<{ event: string; handler: EventListener }> | undefined = + const listeners: Array<{ el: Element; event: string; handler: EventListener }> | undefined = (wrapperElement as any)[listenersKey]; if (listeners) { - listeners.forEach(({ event, handler }) => { - wrapperElement.removeEventListener(event, handler); + listeners.forEach(({ el, event, handler }) => { + el.removeEventListener(event, handler); }); delete (wrapperElement as any)[listenersKey]; } From c2c6e8c5b7941bfdcb6ac59f709fc2a3a8a2e595 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:19:20 +0530 Subject: [PATCH 14/22] fix: add fx listener teardown, warn on unknown effects, hoist allListeners - Add teardownFxDOMEventTriggers to remove direct lvt-fx listeners on disconnect, wired up alongside existing lifecycle/delegation teardowns - Add default case to applyFxEffect warning on unknown effect names - Hoist allListeners read/write outside forEach loop in delegation setup Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 26 ++++++++++++++++++++++++++ dom/event-delegation.ts | 13 ++++++------- livetemplate-client.ts | 2 ++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index cae1916..0c51c2c 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -27,8 +27,13 @@ function parseFxTrigger(attrName: string): { trigger: string | null; actionName? /** * Set up DOM event listeners for lvt-fx: attributes with :on:{event} triggers. * Called after each DOM update to handle new elements. + * Stores listener references on rootElement for teardown via teardownFxDOMEventTriggers. */ export function setupFxDOMEventTriggers(rootElement: Element): void { + const fxListenersKey = "__lvtFxDirectListeners"; + const fxListeners: Array<{ el: Element; event: string; handler: EventListener }> = + (rootElement as any)[fxListenersKey] || []; + rootElement.querySelectorAll("*").forEach(el => { for (const attr of el.attributes) { if (!attr.name.startsWith("lvt-fx:")) continue; @@ -52,8 +57,27 @@ export function setupFxDOMEventTriggers(rootElement: Element): void { }; el.addEventListener(parsed.trigger, listener); (el as any)[listenerKey] = listener; + fxListeners.push({ el, event: parsed.trigger, handler: listener }); } }); + + (rootElement as any)[fxListenersKey] = fxListeners; +} + +/** + * Remove direct DOM event listeners registered by setupFxDOMEventTriggers. + * Call on disconnect to prevent stale listeners across reconnects. + */ +export function teardownFxDOMEventTriggers(rootElement: Element): void { + const fxListenersKey = "__lvtFxDirectListeners"; + const listeners: Array<{ el: Element; event: string; handler: EventListener }> | undefined = + (rootElement as any)[fxListenersKey]; + if (listeners) { + listeners.forEach(({ el, event, handler }) => { + el.removeEventListener(event, handler); + }); + delete (rootElement as any)[fxListenersKey]; + } } /** @@ -168,6 +192,8 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) } break; } + default: + console.warn(`Unknown lvt-fx effect: ${effect}`); } } diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 492b0e0..c24aba5 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -611,6 +611,11 @@ export class EventDelegator { const delegatedKey = `__lvt_el_delegated_${wrapperId}`; const delegated: Set = (wrapperElement as any)[delegatedKey] || new Set(); + // Track all listeners (direct + delegated) on wrapper for teardown + const listenersKey = `__lvt_el_listeners_${wrapperId}`; + const allListeners: Array<{ el: Element; event: string; handler: EventListener }> = + (wrapperElement as any)[listenersKey] || []; + // Scan the provided subtree (or full wrapper) for lvt-el:*:on:{event} attributes const root = scanRoot || wrapperElement; root.querySelectorAll("*").forEach(el => { @@ -624,11 +629,6 @@ export class EventDelegator { triggers.add(trigger); } - // Store all listeners (direct + delegated) on wrapper for teardown - const listenersKey = `__lvt_el_listeners_${wrapperId}`; - const allListeners: Array<{ el: Element; event: string; handler: EventListener }> = - (wrapperElement as any)[listenersKey] || []; - for (const trigger of triggers) { if (NON_BUBBLING.has(trigger)) { // Direct attachment for non-bubbling events @@ -664,10 +664,9 @@ export class EventDelegator { allListeners.push({ el: wrapperElement, event: trigger, handler }); } } - - (wrapperElement as any)[listenersKey] = allListeners; }); + (wrapperElement as any)[listenersKey] = allListeners; (wrapperElement as any)[delegatedKey] = delegated; } diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 2b591cb..c69f6ba 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -14,6 +14,7 @@ import { handleToastDirectives, setupToastClickOutside, setupFxDOMEventTriggers, + teardownFxDOMEventTriggers, setupFxLifecycleListeners, teardownFxLifecycleListeners, } from "./dom/directives"; @@ -417,6 +418,7 @@ export class LiveTemplateClient { this.formDisabler.enable(this.wrapperElement); this.eventDelegator.teardownDOMEventTriggerDelegation(); if (this.wrapperElement) { + teardownFxDOMEventTriggers(this.wrapperElement); teardownFxLifecycleListeners(this.wrapperElement); } } From 89c545f645f03357773e34e6c8dc363c5168eced Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:23:38 +0530 Subject: [PATCH 15/22] fix: prune stale element references from listener arrays on each scan Filter out disconnected elements before appending new entries to prevent unbounded growth in __lvtFxDirectListeners and __lvt_el_listeners arrays when morphdom replaces DOM elements across frequent server pushes. Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 5 ++++- dom/event-delegation.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index 0c51c2c..f96b747 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -31,8 +31,11 @@ function parseFxTrigger(attrName: string): { trigger: string | null; actionName? */ export function setupFxDOMEventTriggers(rootElement: Element): void { const fxListenersKey = "__lvtFxDirectListeners"; + // Prune stale entries from elements replaced by morphdom const fxListeners: Array<{ el: Element; event: string; handler: EventListener }> = - (rootElement as any)[fxListenersKey] || []; + ((rootElement as any)[fxListenersKey] || []).filter( + (entry: { el: Element }) => entry.el.isConnected + ); rootElement.querySelectorAll("*").forEach(el => { for (const attr of el.attributes) { diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index c24aba5..a8ef75e 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -612,9 +612,12 @@ export class EventDelegator { const delegated: Set = (wrapperElement as any)[delegatedKey] || new Set(); // Track all listeners (direct + delegated) on wrapper for teardown + // Prune stale entries from elements replaced by morphdom const listenersKey = `__lvt_el_listeners_${wrapperId}`; const allListeners: Array<{ el: Element; event: string; handler: EventListener }> = - (wrapperElement as any)[listenersKey] || []; + ((wrapperElement as any)[listenersKey] || []).filter( + (entry: { el: Element }) => entry.el.isConnected + ); // Scan the provided subtree (or full wrapper) for lvt-el:*:on:{event} attributes const root = scanRoot || wrapperElement; From 71e6ef432632ca287801fd45745ca68c80bf9b21 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:28:33 +0530 Subject: [PATCH 16/22] fix: clear per-element guard keys on teardown, scan root element itself - teardownFxDOMEventTriggers now deletes per-element __lvt_fx_* markers so surviving elements re-attach listeners correctly on reconnect - setupDOMEventTriggerDelegation and setupFxDOMEventTriggers now include the root element in the scan (querySelectorAll only returns descendants) Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 12 +++++++----- dom/event-delegation.ts | 7 +++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index f96b747..2a87af7 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -32,12 +32,13 @@ function parseFxTrigger(attrName: string): { trigger: string | null; actionName? export function setupFxDOMEventTriggers(rootElement: Element): void { const fxListenersKey = "__lvtFxDirectListeners"; // Prune stale entries from elements replaced by morphdom - const fxListeners: Array<{ el: Element; event: string; handler: EventListener }> = + const fxListeners: Array<{ el: Element; event: string; handler: EventListener; guardKey: string }> = ((rootElement as any)[fxListenersKey] || []).filter( (entry: { el: Element }) => entry.el.isConnected ); - rootElement.querySelectorAll("*").forEach(el => { + // Include rootElement itself — querySelectorAll only returns descendants + [rootElement, ...rootElement.querySelectorAll("*")].forEach(el => { for (const attr of el.attributes) { if (!attr.name.startsWith("lvt-fx:")) continue; const parsed = parseFxTrigger(attr.name); @@ -60,7 +61,7 @@ export function setupFxDOMEventTriggers(rootElement: Element): void { }; el.addEventListener(parsed.trigger, listener); (el as any)[listenerKey] = listener; - fxListeners.push({ el, event: parsed.trigger, handler: listener }); + fxListeners.push({ el, event: parsed.trigger, handler: listener, guardKey: listenerKey }); } }); @@ -73,11 +74,12 @@ export function setupFxDOMEventTriggers(rootElement: Element): void { */ export function teardownFxDOMEventTriggers(rootElement: Element): void { const fxListenersKey = "__lvtFxDirectListeners"; - const listeners: Array<{ el: Element; event: string; handler: EventListener }> | undefined = + const listeners: Array<{ el: Element; event: string; handler: EventListener; guardKey: string }> | undefined = (rootElement as any)[fxListenersKey]; if (listeners) { - listeners.forEach(({ el, event, handler }) => { + listeners.forEach(({ el, event, handler, guardKey }) => { el.removeEventListener(event, handler); + delete (el as any)[guardKey]; // Clear per-element marker so re-attach works on reconnect }); delete (rootElement as any)[fxListenersKey]; } diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index a8ef75e..f5fe10d 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -619,9 +619,12 @@ export class EventDelegator { (entry: { el: Element }) => entry.el.isConnected ); - // Scan the provided subtree (or full wrapper) for lvt-el:*:on:{event} attributes + // Scan the provided subtree (or full wrapper) for lvt-el:*:on:{event} attributes. + // Include root itself since querySelectorAll only returns descendants — + // non-bubbling triggers on the root element need direct attachment too. const root = scanRoot || wrapperElement; - root.querySelectorAll("*").forEach(el => { + const elements = [root, ...root.querySelectorAll("*")]; + elements.forEach(el => { const triggers = new Set(); for (const attr of el.attributes) { if (!attr.name.startsWith("lvt-el:")) continue; From b715a8b9223a8a97b6c7dd152eb0ae9564f158d0 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:33:02 +0530 Subject: [PATCH 17/22] fix: include root in lifecycle scan, avoid allocation in event handler - processFxLifecycleAttributes now includes rootElement itself in scan - Replace Array.from().some() with for..of loop in delegation handler to avoid per-event allocation on high-frequency triggers - Document intentional highlight rate-limiting behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 6 ++++-- dom/event-delegation.ts | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index 2a87af7..d84e96a 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -93,7 +93,7 @@ export function processFxLifecycleAttributes( lifecycle: string, actionName?: string, ): void { - rootElement.querySelectorAll("*").forEach(el => { + [rootElement, ...rootElement.querySelectorAll("*")].forEach(el => { for (const attr of el.attributes) { if (!attr.name.startsWith("lvt-fx:")) continue; const parsed = parseFxTrigger(attr.name); @@ -117,7 +117,9 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) switch (effect) { case "highlight": { - // Skip if already mid-highlight to prevent stale originalBackground capture + // Skip if already mid-highlight to prevent stale originalBackground capture. + // Intentionally rate-limits to one highlight per element — overlapping triggers + // (rapid clicks, DOM updates during animation) are coalesced rather than stacked. if ((htmlElement as any).__lvtHighlighting) break; (htmlElement as any).__lvtHighlighting = true; diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index f5fe10d..b984c20 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -651,9 +651,10 @@ export class EventDelegator { const handler = (e: Event) => { let target = e.target as Element | null; while (target && target !== wrapperElement) { - const hasMatch = Array.from(target.attributes).some( - a => triggerPattern.test(a.name) - ); + let hasMatch = false; + for (const a of target.attributes) { + if (triggerPattern.test(a.name)) { hasMatch = true; break; } + } if (hasMatch) { processElementInteraction(target, trigger); return; // Stop at closest match From b398f920d59d454cd7769b5197af6bf669ed41ef Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:38:09 +0530 Subject: [PATCH 18/22] fix: avoid NodeList spread, lift NON_BUBBLING, guard inner highlight timeout - Replace [root, ...querySelectorAll] with processEl(root) + forEach to avoid heap-allocating an array on every DOM update - Lift NON_BUBBLING set to module-level const - Add isConnected guard in inner highlight setTimeout Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 17 +++++++++++------ dom/event-delegation.ts | 15 ++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index d84e96a..38d2a60 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -37,8 +37,7 @@ export function setupFxDOMEventTriggers(rootElement: Element): void { (entry: { el: Element }) => entry.el.isConnected ); - // Include rootElement itself — querySelectorAll only returns descendants - [rootElement, ...rootElement.querySelectorAll("*")].forEach(el => { + const processEl = (el: Element) => { for (const attr of el.attributes) { if (!attr.name.startsWith("lvt-fx:")) continue; const parsed = parseFxTrigger(attr.name); @@ -63,7 +62,11 @@ export function setupFxDOMEventTriggers(rootElement: Element): void { (el as any)[listenerKey] = listener; fxListeners.push({ el, event: parsed.trigger, handler: listener, guardKey: listenerKey }); } - }); + }; + + // Process root element itself then descendants (avoids spreading NodeList) + processEl(rootElement); + rootElement.querySelectorAll("*").forEach(processEl); (rootElement as any)[fxListenersKey] = fxListeners; } @@ -93,7 +96,7 @@ export function processFxLifecycleAttributes( lifecycle: string, actionName?: string, ): void { - [rootElement, ...rootElement.querySelectorAll("*")].forEach(el => { + const processEl = (el: Element) => { for (const attr of el.attributes) { if (!attr.name.startsWith("lvt-fx:")) continue; const parsed = parseFxTrigger(attr.name); @@ -106,7 +109,9 @@ export function processFxLifecycleAttributes( applyFxEffect(el as HTMLElement, effect, attr.value); } - }); + }; + processEl(rootElement); + rootElement.querySelectorAll("*").forEach(processEl); } /** @@ -140,7 +145,7 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) } htmlElement.style.backgroundColor = originalBackground; setTimeout(() => { - htmlElement.style.transition = originalTransition; + if (htmlElement.isConnected) htmlElement.style.transition = originalTransition; (htmlElement as any).__lvtHighlighting = false; }, duration); }, 50); diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index b984c20..2652780 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -14,6 +14,9 @@ const CLICK_AWAY_METHOD_MAP: Record = { }; const CLICK_AWAY_METHODS = Object.keys(CLICK_AWAY_METHOD_MAP); +// Non-bubbling events need direct attachment rather than wrapper delegation +const NON_BUBBLING = new Set(["mouseenter", "mouseleave", "focus", "blur"]); + export interface EventDelegationContext { getWrapperElement(): Element | null; getRateLimitedHandlers(): WeakMap>; @@ -605,8 +608,6 @@ export class EventDelegator { const wrapperId = wrapperElement.getAttribute("data-lvt-id"); if (!wrapperId) return; - // Non-bubbling events need direct attachment - const NON_BUBBLING = new Set(["mouseenter", "mouseleave", "focus", "blur"]); // Track which bubbling events we've already delegated at wrapper level const delegatedKey = `__lvt_el_delegated_${wrapperId}`; const delegated: Set = (wrapperElement as any)[delegatedKey] || new Set(); @@ -620,11 +621,9 @@ export class EventDelegator { ); // Scan the provided subtree (or full wrapper) for lvt-el:*:on:{event} attributes. - // Include root itself since querySelectorAll only returns descendants — - // non-bubbling triggers on the root element need direct attachment too. + // Process root then descendants (avoids spreading NodeList into array). const root = scanRoot || wrapperElement; - const elements = [root, ...root.querySelectorAll("*")]; - elements.forEach(el => { + const processEl = (el: Element) => { const triggers = new Set(); for (const attr of el.attributes) { if (!attr.name.startsWith("lvt-el:")) continue; @@ -671,7 +670,9 @@ export class EventDelegator { allListeners.push({ el: wrapperElement, event: trigger, handler }); } } - }); + }; + processEl(root); + root.querySelectorAll("*").forEach(processEl); (wrapperElement as any)[listenersKey] = allListeners; (wrapperElement as any)[delegatedKey] = delegated; From 03666f4a9588c722952bb1f3e95b88bda76e2530 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:42:10 +0530 Subject: [PATCH 19/22] fix: restore inline styles before early return on disconnected highlight Reset backgroundColor and transition to original values when element disconnects mid-highlight to prevent stale styles on morphdom reuse. Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dom/directives.ts b/dom/directives.ts index 38d2a60..fb0d604 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -140,6 +140,8 @@ function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string) setTimeout(() => { if (!htmlElement.isConnected) { + htmlElement.style.backgroundColor = originalBackground; + htmlElement.style.transition = originalTransition; (htmlElement as any).__lvtHighlighting = false; return; } From 50b029c31a1b5b9d8074316f185c7116bc937da6 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:46:51 +0530 Subject: [PATCH 20/22] fix: clear non-bubbling guard keys on lvt-el teardown Store guardKey in listener entries for non-bubbling events so teardownDOMEventTriggerDelegation can delete per-element markers, allowing re-attachment on reconnect with reused DOM elements. Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/event-delegation.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 2652780..d65678f 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -615,7 +615,7 @@ export class EventDelegator { // Track all listeners (direct + delegated) on wrapper for teardown // Prune stale entries from elements replaced by morphdom const listenersKey = `__lvt_el_listeners_${wrapperId}`; - const allListeners: Array<{ el: Element; event: string; handler: EventListener }> = + const allListeners: Array<{ el: Element; event: string; handler: EventListener; guardKey?: string }> = ((wrapperElement as any)[listenersKey] || []).filter( (entry: { el: Element }) => entry.el.isConnected ); @@ -642,7 +642,7 @@ export class EventDelegator { const listener = () => processElementInteraction(el, trigger); el.addEventListener(trigger, listener); (el as any)[key] = listener; - allListeners.push({ el, event: trigger, handler: listener }); + allListeners.push({ el, event: trigger, handler: listener, guardKey: key }); } else if (!delegated.has(trigger)) { // Delegated listener on wrapper for bubbling events. // Walks from target to wrapper, processing only the closest matching element. @@ -690,11 +690,12 @@ export class EventDelegator { if (!wrapperId) return; const listenersKey = `__lvt_el_listeners_${wrapperId}`; - const listeners: Array<{ el: Element; event: string; handler: EventListener }> | undefined = + const listeners: Array<{ el: Element; event: string; handler: EventListener; guardKey?: string }> | undefined = (wrapperElement as any)[listenersKey]; if (listeners) { - listeners.forEach(({ el, event, handler }) => { + listeners.forEach(({ el, event, handler, guardKey }) => { el.removeEventListener(event, handler); + if (guardKey) delete (el as any)[guardKey]; }); delete (wrapperElement as any)[listenersKey]; } From 23d373a81b52bcdbabad478d74296b3dbb4ed899 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:52:17 +0530 Subject: [PATCH 21/22] fix: centralize fx listener registry on wrapper, not scan root setupFxDOMEventTriggers now accepts a separate registryRoot param so listener entries always live on the wrapper element regardless of which subtree is scanned. Ensures teardownFxDOMEventTriggers(wrapper) finds all entries including those registered during DOM-patch scans. Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/directives.ts | 18 +++++++++++------- livetemplate-client.ts | 3 ++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index fb0d604..257f12d 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -27,13 +27,17 @@ function parseFxTrigger(attrName: string): { trigger: string | null; actionName? /** * Set up DOM event listeners for lvt-fx: attributes with :on:{event} triggers. * Called after each DOM update to handle new elements. - * Stores listener references on rootElement for teardown via teardownFxDOMEventTriggers. + * + * @param scanRoot - Element subtree to scan for new fx attributes. + * @param registryRoot - Element to store listener registry on (always the wrapper). + * Defaults to scanRoot for backwards compatibility. */ -export function setupFxDOMEventTriggers(rootElement: Element): void { +export function setupFxDOMEventTriggers(scanRoot: Element, registryRoot?: Element): void { + const registry = registryRoot || scanRoot; const fxListenersKey = "__lvtFxDirectListeners"; // Prune stale entries from elements replaced by morphdom const fxListeners: Array<{ el: Element; event: string; handler: EventListener; guardKey: string }> = - ((rootElement as any)[fxListenersKey] || []).filter( + ((registry as any)[fxListenersKey] || []).filter( (entry: { el: Element }) => entry.el.isConnected ); @@ -64,11 +68,11 @@ export function setupFxDOMEventTriggers(rootElement: Element): void { } }; - // Process root element itself then descendants (avoids spreading NodeList) - processEl(rootElement); - rootElement.querySelectorAll("*").forEach(processEl); + // Process scan root element itself then descendants (avoids spreading NodeList) + processEl(scanRoot); + scanRoot.querySelectorAll("*").forEach(processEl); - (rootElement as any)[fxListenersKey] = fxListeners; + (registry as any)[fxListenersKey] = fxListeners; } /** diff --git a/livetemplate-client.ts b/livetemplate-client.ts index c69f6ba..d52633a 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -821,7 +821,8 @@ export class LiveTemplateClient { handleAnimateDirectives(element); // Set up DOM event triggers for lvt-fx: attributes with :on:{event} - setupFxDOMEventTriggers(element); + // Registry always lives on wrapperElement so teardown can find all entries + setupFxDOMEventTriggers(element, this.wrapperElement || undefined); // Re-scan updated subtree for lvt-el:*:on:{event} DOM triggers this.eventDelegator.setupDOMEventTriggerDelegation(element); From 50fb7dd332419ac00d3cfbc34ce0a7e8782ff236 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 11:56:09 +0530 Subject: [PATCH 22/22] fix: escape trigger name in delegated handler regex pattern Prevent regex metacharacters in custom event names from producing incorrect match patterns in the bubbling delegation handler. Co-Authored-By: Claude Opus 4.6 (1M context) --- dom/event-delegation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index d65678f..16311c2 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -646,7 +646,8 @@ export class EventDelegator { } else if (!delegated.has(trigger)) { // Delegated listener on wrapper for bubbling events. // Walks from target to wrapper, processing only the closest matching element. - const triggerPattern = new RegExp(`^lvt-el:\\w+:on:${trigger}$`, "i"); + const escaped = trigger.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const triggerPattern = new RegExp(`^lvt-el:\\w+:on:${escaped}$`, "i"); const handler = (e: Event) => { let target = e.target as Element | null; while (target && target !== wrapperElement) {