fix(lvt-el): preserve client-applied class/attr state across morphs (#147) - #148
Conversation
…147) A class applied by lvt-el:toggleClass/addClass was stripped by the next morph. The server never emits it, so the incoming toEl carried the server's original class and morphdom's attribute diff overwrote the live one. A dropdown held open with lvt-el:toggleClass:on:click="open" closed under the user on any unrelated re-render — in prereview's --agent mode, a background status render slammed the View menu shut with no user action at all. There was no escape hatch: lvt-ignore-attrs only copies attributes toEl LACKS, and any element using toggleClass has a server-emitted base class, so class was always skipped. lvt-ignore freezes the whole subtree and stops content updates. setAttr/toggleAttr had the identical bug — same directive, same executeAction switch, no protection — so all five state-mutating actions are fixed together through one record rather than three ad-hoc guards. dom/client-owned-state.ts records what the client applied, in a WeakMap (the morph-immune pattern already used by animatedElements/scrollResetPriors). The record is required, not just tidy: at morph time fromEl.classList is the UNION of the server's tokens and the client's, so copying it onto toEl would resurrect classes the server deliberately dropped. The record answers the one question morphdom cannot — which tokens does the client own? onBeforeElUpdated re-applies that state onto toEl, which morphdom then stamps onto the live element (the hook runs before morphAttrs, and class has no special handler). The shared options object covers both morph call sites. Semantics: the client overlay wins on CONFLICT (so removeClass of a class the server still emits also survives — click-away works), and the server reowns on AGREEMENT (the entry self-cleans, which is also what retires an entry when the client toggles back). Deliberately NOT gated on the directive still being present, unlike the one-shot iv-done/autofocused guards: this is persistent state, not a latch, and a data-lvt-target'd binding lands on an element that carries no lvt-el:* attribute at all. Out of scope by design: replaceWith/replaceChildren paths destroy element identity, so client state legitimately dies with the node. Verified in a real browser (chromedp + the built bundle): a real click opens the menu, a background render lands, and the menu stays open while the server's update still applies. Against the unfixed bundle the same run closes the menu. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DBWL3SvGmFQTLH7ezW4xTz
data-lvt-scroll-sticky is a runtime-only guard the server never emits, and the preservation block in onBeforeElUpdated copied only its two siblings (data-lvt-iv-done, data-lvt-autofocused). So morphdom stripped it on every render: `initialized` was false every time, and lvt-fx:scroll="bottom-sticky" re-ran its first-encounter branch — an unconditional scrollTo(bottom, behavior:"instant") — yanking back any user who had scrolled up to read history and making the near-bottom threshold check dead code. Copy it onto toEl exactly like the other two, gated on lvt-fx:scroll still being present. Unlike the lvt-el overlay in the previous commit, the directive gate IS correct here: this is a one-shot latch that must re-arm when the directive is re-added. Found while fixing #147 — same family of bug (client-created state with no protection from the morph), different directive. The existing bottom-sticky tests call handleScrollDirectives in isolation, so they never saw a morph, which is how this survived. The new test drives a real morph via updateDOM. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DBWL3SvGmFQTLH7ezW4xTz
ReviewSolid fix for a real bug, with unusually thorough justification and test coverage (deterministic jest morph tests + a real-browser chromedp run against both the broken and fixed bundle). The Possible issue: one-shot guards match on directive presence, not valueIn Minor:
|
Two real edge cases the bot caught, both now pinned by tests that fail without the fix: 1. One-shot guards matched on directive PRESENCE, not the mode its value selects. data-lvt-iv-done and data-lvt-scroll-sticky both gate on lvt-fx:scroll, but the mode lives in that attribute's VALUE (directives.ts: `const mode = config`). So if a node switched into-view -> bottom-sticky, the stale into-view latch survived, and when the node came back to into-view it never scrolled again. Guard rows now carry an optional directiveValue and match on it. The bot is right that the presence-match predates this branch for iv-done — but the guard table doubled the surface, so it gets fixed here. 2. lvt-el:setAttr with name `class` stomped the class overlay: setAttribute writes the class list wholesale, and the attrs loop ran AFTER the classes loop, so it clobbered tokens the overlay had just restored. Attributes are now applied BEFORE classes, so a wholesale write lands first and per-token classes layer on top. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DBWL3SvGmFQTLH7ezW4xTz
ReviewReviewed the diff ( What it doesRecords client-applied Correctness
Minor / non-blocking
Test coverageGood — the new jest suite drives the real SecurityNo concerns — this only manipulates class/attribute state already under the client's control via existing directives; no new input surface. Nice writeup identifying the sibling bugs ( |
Fixes #147.
The bug
lvt-el:toggleClass/addClassexists to hold client-side UI state — but that state is destroyed by the next morph. The server never emits the toggled class, so the incomingtoElcarries the server's originalclassand morphdom's attribute diff overwrites the live one. A dropdown held open withlvt-el:toggleClass:on:click="open"closes under the user's cursor on any unrelated re-render; in prereview's--agentmode a background status render slams the View menu shut with no user action at all.There was no escape hatch.
lvt-ignore-attrs(which the issue callslvt-preserve-attrs— that name doesn't exist in this repo) only copies attributestoEllacks, and any element usingtoggleClasshas a server-emitted base class, soclasswas always skipped.lvt-ignorefreezes the whole subtree and stops content updates entirely.Two siblings of the same bug, also fixed
The issue asked whether this affects other attributes. It does — the root cause is structural (morphdom makes the server authoritative, so any client-created DOM state needs protection), and the codebase only protected the cases someone remembered:
lvt-el:setAttr/toggleAttrexecuteActionswitch, unreported. Broken both ways: a toggle-on is stripped becausetoEllacks the attr; a toggle-off is undone becausetoElstill has it.data-lvt-scroll-stickydata-lvt-iv-doneanddata-lvt-autofocused). Solvt-fx:scroll="bottom-sticky"re-initialized on every render and force-jumped to the bottom (behavior:"instant"), yanking back any user who had scrolled up to read history. The near-bottom threshold check was dead code. Needs no user interaction to trigger.The fix
dom/client-owned-state.tsrecords what the client applied in aWeakMap(the morph-immune pattern already used byanimatedElements/scrollResetPriors), andonBeforeElUpdatedre-applies it ontotoEl— which morphdom then stamps onto the live element (the hook runs beforemorphAttrs, andclasshas no special handler). The shared options object covers both morph call sites.A record is necessary, not just tidy: at morph time
fromEl.classListis the union of the server's tokens and the client's, so copying it ontotoElwould resurrect classes the server deliberately dropped. The record answers the one question morphdom can't — which tokens does the client own?Recording lives inside
executeActionbecause all three call sites (reactive-attributes.ts:261,:280,event-delegation.ts:932) already passresolveTarget(element), so the record keys on the resolved target for free; and because onlyexecuteActiongets the post-mutation state for free (classList.toggle()returns the resulting presence), keeping the record atomic with the mutation.Semantics: a client-owned overlay on server state
This is deliberately a superset of what the issue proposes. An add-only record fixes the reported repro but silently leaves
removeClassbroken for any class the server emits — so click-away on a server-rendered-open panel would still fail. Recording removals too costs a few lines and closes that hole.Deliberately not gated on the directive still being present (unlike the one-shot guards): this is persistent state, not a latch, and a
data-lvt-target'd binding lands on an element carrying nolvt-el:*attribute at all. Divergence from the issue's proposal, called out so a reviewer cross-checking doesn't read it as an oversight — the server reclaims by emitting the value or replacing the element.The guard block is now table-driven
bottom-stickyshipped broken because the preservation logic was a hand-maintained if-chain that enumerated two of three guards. Fixing only the instance would have left the structure that produced it. The three latches are now rows inONE_SHOT_GUARDS, so a new one-shot directive registers a row instead of relying on its author knowing that block exists — and the morph orchestrator no longer names any directive.Out of scope by design:
replaceWith/replaceChildrenpaths destroy element identity, so client state legitimately dies with the node.Verification
The e2e in the issue only reproduces ~1-in-4, so the gate is a jest test driving a real morph via
updateDOM— and I confirmed it catches the bug by disabling the fix: the four "state survives" tests fail without it, pass with it.Then a real-browser chromedp run (real Chrome, the built minified bundle, a real mouse click through real event delegation):
open:false,panelVisible:false);sibgoesa→b), proving the element wasn't merely frozen.Full suite: 812 passing, up from 804 on main (8 new tests). Pre-commit hook green on both commits.
Follow-ups (not this PR)
docs/is a separate repo;client-attributes.mdshould document the new overlay semantics.form-disabler/form-lifecycle-managersetinput.disabledmid-submit, and morphdom special-casesINPUTto re-syncdisabledfromtoEl— likely re-enables inputs mid-submit (double-submit risk). Alsolvt-fx:highlightinlinestylewiped mid-flash, andscroll-away's.visible(self-heals on next scroll).🤖 Generated with Claude Code
https://claude.ai/code/session_01DBWL3SvGmFQTLH7ezW4xTz