Summary
A class applied by lvt-el:toggleClass / addClass — a first-party client directive — is stripped by the next morph of that element. The server never emits the class, so toEl always carries the server's original class value and morphdom overwrites the live one.
The result: the framework ships a directive whose entire purpose is to hold client-side UI state, and the framework's own render pipeline destroys that state on any unrelated re-render. There is currently no working escape hatch (see "Why lvt-preserve-attrs doesn't save you" below).
This is not theoretical. It is a live, user-visible bug in prereview today.
Repro
<div class="tb-dropdown"
lvt-el:toggleClass:on:click="open"
lvt-el:removeClass:on:click-away="open">
<button class="tb-dropdown-trigger">View</button>
<div class="tb-dropdown-panel">…</div>
</div>
- Click the trigger → client adds
.open → the panel shows. Correct.
- Now cause any server render that morphs this element's subtree — it does not have to be related to the dropdown; in the real case it's a sibling action whose data feeds items inside the panel.
.open is gone. The menu closes by itself, under the user's cursor.
Reproduced at roughly 1 run in 4 in a browser e2e; the class disappears ~50ms after opening. It is timing-dependent only because it depends on whether a render happens to land while the menu is open — the underlying behavior is deterministic.
Mechanism
lvt-el class actions are a bare classList mutation with no record kept — dom/reactive-attributes.ts:161-166:
case "toggleClass":
if (param) {
const classes = param.split(/\s+/).filter(Boolean);
classes.forEach((c) => element.classList.toggle(c));
}
break;
Nothing marks the class as client-owned, so on the next morph toEl.class (the server's) wins and the class is silently dropped.
The framework already solves this — twice — just not here
livetemplate-client.ts:1861-1886 in onBeforeElUpdated explicitly preserves runtime-only attributes the server never emits, precisely because morphdom would otherwise strip them:
if (f.getAttribute("data-lvt-iv-done") === "1" && t.hasAttribute("lvt-fx:scroll")) {
t.setAttribute("data-lvt-iv-done", "1");
}
if (f.getAttribute("data-lvt-autofocused") === "true" && t.hasAttribute("lvt-autofocus")) {
t.setAttribute("data-lvt-autofocused", "true");
}
And that block's own comment names the general principle:
"This is why lvt-fx:animate is immune — it guards via a WeakSet that survives morphdom; these two guard via attributes, which do not."
So there are two established precedents for surviving a morph (copy-onto-toEl, and a WeakSet). lvt-el's class state has neither. It is the one category of framework-created client state with no protection.
Why lvt-preserve-attrs doesn't save you
lvt-preserve-attrs only protects attributes the server template does not emit at all. Every element using lvt-el:toggleClass has a server-emitted class (it needs a base class to be styled and selected), so the copy loop skips it and the toggled class is lost anyway. #75 tracks documenting that limit.
That makes #75 and this issue complementary, not duplicates: #75 says "warn users that lvt-preserve-attrs can't protect class"; this one says "a first-party directive needs class protected, and no user-reachable mechanism can provide it." Documenting the footgun does not fix lvt-el.
Proposed fix
Record classes applied by lvt-el and re-apply them in onBeforeElUpdated, mirroring the existing guard-preservation block:
- On an
addClass / toggleClass action, record the element → client-applied classes (a WeakMap<Element, Set<string>> follows the lvt-fx:animate precedent and leaves no attribute residue; a data-lvt-el-classes attribute would also work and is easier to debug).
- In
onBeforeElUpdated, for each recorded class still owned by the client, toEl.classList.add(cls) — but only while the element still carries its lvt-el:*Class directive, so that if the server drops the directive the state falls away naturally. This is exactly the conditional the scroll/autofocus guards already use.
removeClass / a toggleClass that turns the class off clears it from the record, so click-away and the item-click close still work.
Open question worth deciding explicitly: if the server starts emitting the same class, the server should presumably win (it has become server state). Worth a test either way.
Impact
In prereview, the toolbar "View ▾" menu (lvt-el:toggleClass:on:click="open") closes under the user. Its panel holds the resolved-comment filters, so an action as ordinary as resolving a comment re-renders the panel and slams the menu shut. In --agent mode it is worse: the agent's own background status renders can close a menu the user is deliberately holding open, with no user action at all.
It also caused intermittent hangs in prereview's browser e2e (a helper waiting on .tb-dropdown.open, which never comes back). That is worked around there with a retry, but the workaround is in the test — real users have no retry.
Any consumer using lvt-el:toggleClass to hold UI state (dropdowns, disclosure panels, popovers, "expanded" rows) is exposed to the same bug the moment their app gains a background render or a render whose data overlaps the toggled subtree.
Summary
A class applied by
lvt-el:toggleClass/addClass— a first-party client directive — is stripped by the next morph of that element. The server never emits the class, sotoElalways carries the server's originalclassvalue and morphdom overwrites the live one.The result: the framework ships a directive whose entire purpose is to hold client-side UI state, and the framework's own render pipeline destroys that state on any unrelated re-render. There is currently no working escape hatch (see "Why
lvt-preserve-attrsdoesn't save you" below).This is not theoretical. It is a live, user-visible bug in prereview today.
Repro
.open→ the panel shows. Correct..openis gone. The menu closes by itself, under the user's cursor.Reproduced at roughly 1 run in 4 in a browser e2e; the class disappears ~50ms after opening. It is timing-dependent only because it depends on whether a render happens to land while the menu is open — the underlying behavior is deterministic.
Mechanism
lvt-elclass actions are a bareclassListmutation with no record kept —dom/reactive-attributes.ts:161-166:Nothing marks the class as client-owned, so on the next morph
toEl.class(the server's) wins and the class is silently dropped.The framework already solves this — twice — just not here
livetemplate-client.ts:1861-1886inonBeforeElUpdatedexplicitly preserves runtime-only attributes the server never emits, precisely because morphdom would otherwise strip them:And that block's own comment names the general principle:
So there are two established precedents for surviving a morph (copy-onto-
toEl, and a WeakSet).lvt-el's class state has neither. It is the one category of framework-created client state with no protection.Why
lvt-preserve-attrsdoesn't save youlvt-preserve-attrsonly protects attributes the server template does not emit at all. Every element usinglvt-el:toggleClasshas a server-emittedclass(it needs a base class to be styled and selected), so the copy loop skips it and the toggled class is lost anyway. #75 tracks documenting that limit.That makes #75 and this issue complementary, not duplicates: #75 says "warn users that
lvt-preserve-attrscan't protectclass"; this one says "a first-party directive needsclassprotected, and no user-reachable mechanism can provide it." Documenting the footgun does not fixlvt-el.Proposed fix
Record classes applied by
lvt-eland re-apply them inonBeforeElUpdated, mirroring the existing guard-preservation block:addClass/toggleClassaction, record the element → client-applied classes (aWeakMap<Element, Set<string>>follows thelvt-fx:animateprecedent and leaves no attribute residue; adata-lvt-el-classesattribute would also work and is easier to debug).onBeforeElUpdated, for each recorded class still owned by the client,toEl.classList.add(cls)— but only while the element still carries itslvt-el:*Classdirective, so that if the server drops the directive the state falls away naturally. This is exactly the conditional the scroll/autofocus guards already use.removeClass/ atoggleClassthat turns the class off clears it from the record, so click-away and the item-click close still work.Open question worth deciding explicitly: if the server starts emitting the same class, the server should presumably win (it has become server state). Worth a test either way.
Impact
In prereview, the toolbar "View ▾" menu (
lvt-el:toggleClass:on:click="open") closes under the user. Its panel holds the resolved-comment filters, so an action as ordinary as resolving a comment re-renders the panel and slams the menu shut. In--agentmode it is worse: the agent's own background status renders can close a menu the user is deliberately holding open, with no user action at all.It also caused intermittent hangs in prereview's browser e2e (a helper waiting on
.tb-dropdown.open, which never comes back). That is worked around there with a retry, but the workaround is in the test — real users have no retry.Any consumer using
lvt-el:toggleClassto hold UI state (dropdowns, disclosure panels, popovers, "expanded" rows) is exposed to the same bug the moment their app gains a background render or a render whose data overlaps the toggled subtree.