diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 1639a80..7cc4a3c 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -1823,22 +1823,36 @@ export class LiveTemplateClient { // state is user input that must survive scan-loop refreshes. Use // data-lvt-force-update to let the server override the user state. // - // Known limitation: force-update on one radio can uncheck a sibling - // that was already processed earlier in the same morphdom pass, since - // browser mutual exclusion fires synchronously mid-loop. To safely - // reset a radio group, send data-lvt-force-update on ALL radios in - // the group, not just the one being checked. + // Exception: a control with an lvt-on:click handler routes its own + // toggle to the server, which echoes back the authoritative state. + // There is no pending local-only selection to protect, so the server's + // rendered checked attribute must win — otherwise a server-driven + // toggle never reflects in the DOM. This makes lvt-on:click checkboxes + // server-authoritative without needing an explicit data-lvt-force-update. + // + // Known limitation: forcing one radio can uncheck a sibling that was + // already processed earlier in the same morphdom pass, since browser + // mutual exclusion fires synchronously mid-loop. To safely reset a + // radio group, make the server send the authoritative state (or + // data-lvt-force-update) on ALL radios in the group, not just one. if ( fromEl instanceof HTMLInputElement && toEl instanceof HTMLInputElement && (fromEl.type === "checkbox" || fromEl.type === "radio") ) { - if (toEl.hasAttribute("data-lvt-force-update")) { + const explicitForce = toEl.hasAttribute("data-lvt-force-update"); + const serverAuthoritative = + explicitForce || toEl.hasAttribute("lvt-on:click"); + if (serverAuthoritative) { fromEl.checked = toEl.checked; if (fromEl.type === "checkbox") { fromEl.indeterminate = toEl.indeterminate; } - fromEl.removeAttribute("data-lvt-force-update"); + // data-lvt-force-update is a one-shot signal; strip it so it does + // not persist. lvt-on:click is a durable handler — leave it. + if (explicitForce) { + fromEl.removeAttribute("data-lvt-force-update"); + } } else { toEl.checked = fromEl.checked; // Align the checked attribute with the property so morphdom's diff --git a/tests/preserve.test.ts b/tests/preserve.test.ts index 8f0e18a..4f92752 100644 --- a/tests/preserve.test.ts +++ b/tests/preserve.test.ts @@ -231,6 +231,92 @@ describe("lvt-ignore and lvt-ignore-attrs", () => { expect(afterUpdate[1].checked).toBe(false); }); + it("server wins for a checkbox with an lvt-on:click handler", () => { + // A checkbox whose toggle is routed to the server via lvt-on:click is + // server-authoritative: the click is dispatched and the server echoes back + // the new state. At morph time the live control's checked is still false + // (the toggle is owned by the server, not local optimistic state), and the + // server's re-render carries the checked attribute. Server must win without + // needing an explicit data-lvt-force-update — issue tinkerdown#292. + // Slot 1 is a co-rendered label that changes each push, so every update is + // a real morphdom pass (an identical tree short-circuits in updateDOM). + const uncheckedTree = { + s: [`