Skip to content

Phase 1A: Client attribute reduction — generic event router + removals#44

Merged
adnaan merged 6 commits intomainfrom
attr-reduction
Apr 4, 2026
Merged

Phase 1A: Client attribute reduction — generic event router + removals#44
adnaan merged 6 commits intomainfrom
attr-reduction

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Apr 3, 2026

Summary

Implements Phase 1A of the attribute-reduction-proposal — all client-side TypeScript changes.

Changes

Generic event router — Replace 16 individual event attrs (lvt-click, lvt-keydown, etc.) with lvt-on[:{scope}]:{event} pattern.

Prefix consolidation:

  • lvt-scroll/highlight/animatelvt-fx:*
  • lvt-debounce/throttlelvt-mod:*
  • lvt-preserve/disable-with/no-interceptlvt-form:*
  • lvt-{action}-on:{event}lvt-el:{method}:on:{event}

Removals:

  • modal-manager.ts — replaced by native <dialog> + command/commandfor
  • utils/confirm.ts — removed lvt-confirm and lvt-data-*
  • lvt-data-*/lvt-value-* extraction, lvt-change fallback, disable/enable reactive attrs

New:

  • lvt-el:*:on:click-away — client-side DOM manipulation
  • CSS custom properties for directive config
  • lvtSelector() utility for CSS colon escaping
  • livetemplate.css with defaults

Stats

15 files changed, 324 insertions(+), 703 deletions(-)

Tests

18 suites, 283 tests — all passing ✅

⚠️ Do NOT merge

Phase 1B (server-side changes) must be completed first.

Implement the attribute-reduction-proposal Phase 1A changes:

Generic event router:
- Replace 16 individual event attrs (lvt-click, lvt-keydown, etc.) with
  lvt-on[:{scope}]:{event} pattern (scopes: window, document)
- Add parseLvtOn() parser with ordered greedy scope consumption

Prefix consolidation:
- lvt-scroll/highlight/animate → lvt-fx:scroll/highlight/animate
- lvt-debounce/throttle → lvt-mod:debounce/throttle
- lvt-preserve → lvt-form:preserve
- lvt-disable-with → lvt-form:disable-with
- lvt-no-intercept → lvt-form:no-intercept (link-interceptor.ts)
- lvt-{action}-on:{event} → lvt-el:{method}:on:{event}

Removals:
- Delete modal-manager.ts — replaced by native <dialog> + command/commandfor
- Delete utils/confirm.ts — remove lvt-confirm and lvt-data-* extraction
- Remove lvt-data-*/lvt-value-* extraction loops
- Remove lvt-change fallback logic
- Remove disable/enable from reactive attributes

New features:
- lvt-el:*:on:click-away — client-side DOM manipulation (not server action)
- CSS custom properties for directive config (--lvt-scroll-behavior, etc.)
- lvtSelector() utility for CSS colon escaping
- livetemplate.css with custom property defaults

FormLifecycleManager:
- Remove ModalManager dependency, use native dialog.close()
- Rename lvt-preserve → lvt-form:preserve

Stats: 15 files changed, -379 net lines

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 3, 2026

Review: Phase 1A Client Attribute Reduction

Overall this is clean and well-structured. A few things worth flagging:

Bug: lvt-data-* removal leaves main event handler with no custom data extraction

The lvt-data-* / lvt-value-* attribute loops were removed from the main element event handler (~line 293) but not replaced with the data-* equivalent used in the window event handler. The window handler now correctly reads data-* attrs, but element-triggered actions will send messages with no custom data at all. This is an asymmetry — either the main handler should also read data-*, or this is an intentional breaking removal that should be called out explicitly.

Performance: querySelectorAll("*") on every document click

In the new setupClickAwayDelegation, every click anywhere on the page runs currentWrapper.querySelectorAll("*") and iterates all elements plus their attributes. For large DOMs (tables, lists) this will be noticeably slow. Consider scoping the scan to only elements with [class*="lvt-el"] or a more targeted attribute selector, or pre-indexing them when directives are set up.

Breaking: lvt-confirm removed silently

checkLvtConfirm and the confirm() guard are gone with no client-side replacement. If any app relied on this for destructive action confirmation, it now fires without prompting. Worth documenting a migration path, or noting whether Phase 1B covers this.

Minor: disable/enable reactive action removal

These are useful for accessibility (actually disabling buttons during pending state). The replacement via lvt-el:setattr:on:pending="disabled:" works but requires a paired lvt-el:removeattr:on:done and is more error-prone. Not blocking but worth noting in the proposal as a regression.


lvtSelector, parseLvtOn, and the CSS custom property approach for directive config are all solid.

@adnaan adnaan marked this pull request as ready for review April 4, 2026 03:15
Copilot AI review requested due to automatic review settings April 4, 2026 03:15
- Add data-* attribute extraction for non-form element events
  (fixes asymmetry where window handler extracted data-* but main
  handler did not after lvt-data-* removal)
- Scope click-away delegation to targeted attribute selectors instead
  of querySelectorAll('*') for better performance on large DOMs
- Restore test for data-* extraction on click events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Phase 1A of the client-side “attribute reduction” effort by consolidating many directive/event attributes into fewer generic patterns, removing legacy client behaviors (modal manager, confirm/data extraction), and updating tests accordingly.

Changes:

  • Introduces a generic lvt-on[:{scope}]:{event} event routing pattern and updates delegation logic/tests.
  • Refactors reactive attributes to the lvt-el:{method}:on:* pattern and removes disable/enable reactive actions.
  • Migrates UI directives to consolidated prefixes (lvt-fx:*, lvt-mod:*, lvt-form:*), adds CSS custom property defaults, and removes modal-manager + confirm utilities.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
utils/lvt-selector.ts Adds a helper for building CSS selectors for colon-containing attribute names.
utils/confirm.ts Removes lvt-confirm and lvt-data-* utilities.
dom/event-delegation.ts Switches to lvt-on:* routing, updates data extraction strategy, adds click-away client-side DOM manipulation.
dom/reactive-attributes.ts Updates reactive attribute grammar to lvt-el:*:on:* and removes disable/enable actions.
state/form-lifecycle-manager.ts Stops using ModalManager; closes native <dialog> on success; switches preserve attr to lvt-form:preserve.
dom/link-interceptor.ts Renames intercept opt-out attribute to lvt-form:no-intercept.
dom/directives.ts Migrates directives to lvt-fx:* and reads configuration from CSS custom properties.
livetemplate-client.ts Removes modal manager wiring and confirm/data exports; updates init comments accordingly.
livetemplate.css Adds default CSS custom properties for lvt-fx:* directives.
tests/reactive-attributes.test.ts Updates tests for new reactive attribute pattern and removed actions.
tests/event-delegation.test.ts Updates tests for lvt-on:* and consolidated modifier/form attributes.
tests/directives.test.ts Updates tests for lvt-fx:* and CSS custom property configuration.
tests/modal-manager.test.ts Removes tests for deleted ModalManager.
tests/form-lifecycle-manager.test.ts Updates tests to use <dialog> behavior and renamed preserve attribute.
dom/modal-manager.ts Removes the legacy modal manager implementation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +61 to +62
* New pattern: lvt-el:{method}:on:[{action}:]{state}
* Also supports legacy: lvt-{method}-on:[{action}:]{state}
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says parseReactiveAttribute also supports the legacy lvt-{method}-on: pattern, but the implementation now only matches lvt-el:*:on:* and will always return null for legacy attributes. Please either add the legacy parsing back or update the comment to avoid misleading consumers/tests.

Suggested change
* New pattern: lvt-el:{method}:on:[{action}:]{state}
* Also supports legacy: lvt-{method}-on:[{action}:]{state}
* Supported pattern: lvt-el:{method}:on:[{action}:]{state}

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +21
const SCOPE_KEYWORDS = new Set(["window", "document"]);

/**
* Parse an lvt-on:* attribute name into scope and event.
* Grammar: lvt-on[:{scope}]:{event}
* - scope: "window" | "document" | (omitted = "element")
* - event: any native DOM event name
*/
export function parseLvtOn(attr: string): { scope: string; event: string } | null {
if (!attr.startsWith("lvt-on:")) return null;
const segs = attr.slice(7).split(":");
if (segs.length === 0 || segs[0] === "") return null;
let scope = "element";
if (SCOPE_KEYWORDS.has(segs[0])) scope = segs.shift()!;
const event = segs.join(":");
if (!event) return null;
return { scope, event };
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseLvtOn (and SCOPE_KEYWORDS) are currently unused in this file. Since they’re exported, this can confuse API consumers about which parsing logic is actually supported; consider either wiring this into the event router or removing it until it’s needed.

Copilot uses AI. Check for mistakes.
Comment on lines 545 to 552
if (existingListener) {
document.removeEventListener("click", existingListener);
}

const listener = (e: Event) => {
const currentWrapper = this.context.getWrapperElement();
if (!currentWrapper) return;

Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setupClickAwayDelegation runs querySelectorAll('*') and then inspects every element’s attributes on every click. In large wrappers this is O(N) per click and can become a noticeable hotspot. Consider maintaining a small registry of click-away-enabled elements (e.g., populate once + update via MutationObserver) so clicks only iterate the relevant elements.

Copilot uses AI. Check for mistakes.
Comment on lines +553 to +580
const target = e.target as Element;
const elements = currentWrapper.querySelectorAll("[lvt-click-away]");

elements.forEach((element) => {
if (!element.contains(target)) {
const action = element.getAttribute("lvt-click-away");
if (!action) return;

const message: any = { action, data: {} };

Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith("lvt-data-")) {
const key = attr.name.replace("lvt-data-", "");
message.data[key] = this.context.parseValue(attr.value);
}
});

Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith("lvt-value-")) {
const key = attr.name.replace("lvt-value-", "");
message.data[key] = this.context.parseValue(attr.value);
}
});

this.context.send(message);
}
// Pre-filter: only scan elements that have lvt-el: attributes with click-away
// Use attribute substring selector to avoid scanning all DOM elements
const clickAwayElements = currentWrapper.querySelectorAll("[lvt-el\\:addclass\\:on\\:click-away], [lvt-el\\:removeclass\\:on\\:click-away], [lvt-el\\:toggleclass\\:on\\:click-away], [lvt-el\\:setattr\\:on\\:click-away], [lvt-el\\:toggleattr\\:on\\:click-away], [lvt-el\\:reset\\:on\\:click-away]");
clickAwayElements.forEach((element) => {
if (element.contains(target)) return; // Click was inside, not away

Array.from(element.attributes).forEach((attr) => {
if (!attr.name.includes(":on:click-away")) return;
const match = attr.name.match(/^lvt-el:(\w+):on:click-away$/);
if (!match) return;
const method = match[1].toLowerCase();
const param = attr.value;

switch (method) {
case "addclass":
if (param) element.classList.add(...param.split(/\s+/).filter(Boolean));
break;
case "removeclass":
if (param) element.classList.remove(...param.split(/\s+/).filter(Boolean));
break;
case "toggleclass":
if (param) param.split(/\s+/).filter(Boolean).forEach(c => element.classList.toggle(c));
break;
case "setattr":
if (param) {
const ci = param.indexOf(":");
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The click-away DOM manipulation switch duplicates the action logic already implemented in dom/reactive-attributes.ts (parsing method names, splitting class lists, setAttr parsing, etc.). To avoid divergence (e.g., behavior differences between lifecycle-triggered and click-away-triggered actions), consider reusing the shared METHOD_MAP/executeAction logic or extracting a common helper used by both paths.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +4
/** Escapes colons in attribute names for use in CSS attribute selectors. */
export function lvtSelector(attr: string, value?: string): string {
const escaped = attr.replace(/:/g, "\\:");
return value !== undefined ? `[${escaped}="${value}"]` : `[${escaped}]`;
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lvtSelector() supports an optional value, but the implementation interpolates it directly into a quoted CSS attribute selector without escaping. A value containing " or \ will produce an invalid selector (or select unexpectedly). Consider using CSS.escape (with a jsdom fallback like the existing pattern in state/change-auto-wirer.ts) and/or emitting an unquoted selector form to avoid string-escaping issues.

Suggested change
/** Escapes colons in attribute names for use in CSS attribute selectors. */
export function lvtSelector(attr: string, value?: string): string {
const escaped = attr.replace(/:/g, "\\:");
return value !== undefined ? `[${escaped}="${value}"]` : `[${escaped}]`;
function escapeCssAttributeValue(value: string): string {
if (typeof globalThis !== "undefined" && globalThis.CSS?.escape) {
return globalThis.CSS.escape(value);
}
return value.replace(/(^-?\d)|^-$|[^\u0080-\uFFFF\w-]/g, (match, leadingDigit) => {
if (leadingDigit) {
return `\\${leadingDigit} `;
}
return `\\${match}`;
});
}
/** Escapes colons in attribute names for use in CSS attribute selectors. */
export function lvtSelector(attr: string, value?: string): string {
const escaped = attr.replace(/:/g, "\\:");
return value !== undefined
? `[${escaped}=${escapeCssAttributeValue(value)}]`
: `[${escaped}]`;

Copilot uses AI. Check for mistakes.
@claude
Copy link
Copy Markdown

claude bot commented Apr 4, 2026

Good cleanup overall — the namespace consolidation is coherent. A few things worth addressing before Phase 1B ships.

Data leakage risk

The switch from lvt-data-/lvt-value- to standard data-* means all data-* attributes on action elements (except data-key and data-lvt-id) now get forwarded to the server. Third-party libraries, analytics scripts, and UI frameworks routinely write data-* attributes onto DOM elements. This could inadvertently send data that was never intended for the server. Consider a more explicit opt-in — e.g., only forward data-lvt-* — rather than a broad allowlist with a short exclusion list.

lvt-confirm removed without replacement

checkLvtConfirm is deleted with no migration path. Any template using lvt-confirm on destructive actions will silently skip the confirmation dialog after this ships. Phase 1B should either provide an equivalent or the migration guide needs to call this out explicitly as a hard breaking change.

disable/enable reactive actions removed

lvt-el:addclass:on:pending is not equivalent to disabling a form control — disabled inputs are excluded from form submission, are keyboard-inaccessible, and skip constraint validation. If the intent is double-submission prevention during pending, a CSS class alone will not achieve that. Recommend keeping disable/enable in ACTION_MAP or documenting a concrete replacement.

Hardcoded click-away selector list is fragile

setupClickAwayDelegation hardcodes 6 method names in the querySelector string. If a new method is added to ACTION_MAP, the click-away variant silently does nothing. Generating the selector from Object.keys(ACTION_MAP) would remove the maintenance burden — and lvtSelector is already available for the escaping, though it is inconsistently unused here while being used in the window handler.

- Remove dead code: parseLvtOn() and SCOPE_KEYWORDS (unused exports)
- Fix misleading docstring in reactive-attributes.ts (no legacy pattern)
- Refactor click-away to reuse executeAction() from reactive-attributes
- Generate click-away selectors from method map instead of hardcoding
- Escape CSS attribute values in lvtSelector() to prevent injection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 4, 2026

Review: Phase 1A Attribute Reduction

Good overall structure. A few things worth addressing before Phase 1B lands:

Behavioral breaking changes (silent)

lvt-change removal — The entire block handling input/search/change events via lvt-change is gone with no migration path. Live search/filter patterns that relied on this attribute will silently stop firing. What is the Phase 1B replacement?

lvt-confirm removal — Removed from event-delegation.ts with no replacement mentioned anywhere. This was a UX guard against destructive actions. If it is not coming back in Phase 1B, that needs to be called out explicitly since callers currently import checkLvtConfirm from utils/confirm.ts (which is also deleted).

click-away semantic change — Old lvt-click-away sent server actions. New lvt-el:*:on:click-away is client-side DOM only. Any existing use of click-away to close server-managed state silently breaks with no migration path.

Bug: data-* extraction scope narrowed

Previously lvt-data-*/lvt-value-* were extracted from targetElement. Now data-* is only read from actionElement, and only when it is not a form or orphan button (event-delegation.ts ~L286-294). Forms that passed extra data via attributes on the form element itself will silently drop that data.

Consistency: lvtSelector not used in click-away

The click-away selector is hand-rolled in setupClickAwayDelegation using manual colon escaping, but lvtSelector() exists exactly for this. Should use it for consistency and to centralize escaping logic.

Performance note

processReactiveAttributes still does document.querySelectorAll("*") on every lifecycle event. The new lvt-el: prefix is more selectable now — a targeted attribute selector or a cached MutationObserver approach would avoid the full DOM walk on busy pages.

- Replace querySelectorAll('*') in processReactiveAttributes with targeted
  attribute selectors built from known method names and lifecycle events
- Use lvtSelector() in click-away delegation for consistent colon escaping

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 4, 2026

Code Review

Overall this is a well-structured refactor — good test coverage, clean separation of concerns, and the lvtSelector CSS injection escaping is correct.

Issues to address before Phase 1B merge

1. Click-away no longer sends server actions (behavioral regression)
The old lvt-click-away sent a server message; the new lvt-el:*:on:click-away only triggers client-side DOM manipulation. If any templates use click-away for server-side actions (e.g. closing a server-rendered popover), they will silently break. Document this intent or confirm no server-side click-away was in use.

2. lvt-change fallback removal drops input/search auto-submit
The block handling input/search/change events with the lvt-change attribute shorthand was removed (~lines 180-195 in the old file). Using lvt-on:change is equivalent for change events, but input and search events reading from lvt-change as a fallback no longer work. Worth calling out explicitly in migration notes.

3. disable/enable removed with no replacement
No direct equivalent exists in the new attribute scheme. If a submit button needs to be disabled during pending state, users must now combine setattr with aria-disabled or use CSS. Fine if intentional, but should be documented since it's a semantic capability loss.

4. CSS ScrollBehavior cast is unvalidated
dom/directives.ts: the value from --lvt-scroll-behavior is cast directly to ScrollBehavior without checking it's valid (auto/smooth/instant). An invalid CSS property value will silently pass through. Low risk but worth a guard or at least a console.warn.

Minor notes

  • data-key and data-lvt-id are explicitly excluded from the data-* extraction loop — a comment explaining why would help future readers.
  • The modal delegation removal is clean given the native <dialog> replacement, but the escape-key handler is also gone. Confirm native <dialog> handles Escape natively for all target browser versions.

- Validate --lvt-scroll-behavior CSS property against known values
  (auto, smooth, instant) instead of raw cast
- Add clarifying comments for data-key/data-lvt-id exclusion

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 4, 2026

PR Review

This is a large, coherent attribute-namespace refactor with some good improvements (CSS custom props for FX config, native <dialog> API). A few issues worth flagging:

Bugs / Breaking Changes Without Migration

1. lvt-form:no-intercept applied to <a> links (dom/link-interceptor.ts)
The attribute was renamed from the neutral lvt-no-intercept to lvt-form:no-intercept, but it's still checked on <a> anchor tags. Using a form: namespace on a link is semantically incorrect and will confuse users.

2. lvt-change removed with no replacement
The special handling for lvt-change on inputs/forms is gone. Users relying on lvt-change="search" to trigger server actions on typing/clear events are silently broken. No replacement or migration guidance provided.

3. lvt-confirm removed with no replacement
checkLvtConfirm and its call site in the event delegation loop are removed. Users relying on lvt-confirm for confirmation dialogs before server actions silently lose this behavior.

4. disable/enable reactive actions removed
lvt-el:disable:on:pending and lvt-el:enable:on:... patterns are gone. Any templates using these will silently stop working.

Performance Concern

5. Catch-all fallback in processReactiveAttributes (dom/reactive-attributes.ts)

} catch {
  candidates = document.querySelectorAll("*");
}

If an actionName containing CSS-unsafe characters (e.g. /, .) makes the selector invalid, this falls back to scanning every element in the document on every lifecycle event. actionName.replace(/:/g, "\\:") only escapes colons — other special CSS characters are unhandled. Consider sanitizing actionName more broadly, or skipping rather than doing a full DOM scan on error.

Minor

6. VALID_SCROLL_BEHAVIORS Set recreated per element (dom/directives.ts)
Move it outside the forEach callback (or hoist to module level) to avoid re-allocation on each element.

- Fix lvt-form:no-intercept on links: use generic lvt-no-intercept for
  link interceptor since form: prefix is semantically wrong for <a> tags
- Properly escape CSS-special chars in actionName for attribute selectors
  and avoid querySelectorAll('*') fallback on invalid selectors
- Hoist VALID_SCROLL_BEHAVIORS Set to module level to avoid re-allocation
- Validate ScrollBehavior against known values

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@claude
Copy link
Copy Markdown

claude bot commented Apr 4, 2026

Review: Phase 1A Attribute Reduction

Overall this is a clean, well-scoped refactor. A few issues worth addressing:

Bug: Unintended data leakage via data-* attributes

The switch from lvt-data-*/lvt-value-* to standard data-* is much broader. Any data-* attribute on an action element (e.g. data-testid, data-analytics-id, third-party library attrs) will now be sent to the server as action payload, except the two hardcoded exclusions (data-key, data-lvt-id). This could silently leak attributes that were never meant to be server data.

Consider a more targeted namespace (data-lvt-* or lvt-param:*), or at minimum document prominently that data-* is now the input channel so users know to be careful with those attributes on action elements.

Modal removal is incomplete

ModalManager and setupModalDelegation are removed, but the open/close delegation (previously lvt-modal-open/lvt-modal-close, backdrop click, Escape key) is gone with no replacement in this PR. FormLifecycleManager now calls dialogParent.close() on success, but nothing handles opening. If server-rendered HTML still emits lvt-modal-open, clicks will silently do nothing.

lvt-confirm removed with no replacement

Any element with lvt-confirm will now fire its action immediately without prompting. If this is intentional or moved to another PR, worth noting in the description.

lvt-change fallback removed

Input/search live-action patterns relying on lvt-change will silently stop working. Same ask — if intentional, call it out explicitly as a breaking removal.

Minor: data-* extraction skips orphan buttons but not the condition check

In the new data extraction block (event-delegation.ts): if (!(targetElement instanceof HTMLFormElement) && !isOrphanButton) — verify isOrphanButton is always in scope at that point; the diff doesn't show its declaration context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants