Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions livetemplate-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions tests/preserve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [`<div>`, ``, `</div>`],
0: `<input type="checkbox" data-key="t1" lvt-on:click="Toggle">`,
1: `<span>v1</span>`,
};
client.updateDOM(wrapper, uncheckedTree);

const cb = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
expect(cb.checked).toBe(false);

// Server responds to the click with the toggled (checked) state.
client.updateDOM(wrapper, {
0: `<input type="checkbox" data-key="t1" lvt-on:click="Toggle" checked>`,
1: `<span>v2</span>`,
});
expect(
wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!.checked
).toBe(true);

// Inverse: the user optimistically unchecks it (live state now stale), but
// the server's next render re-affirms checked. Server authority must win in
// both directions — the local control never overrides the server value.
wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!.checked = false;
client.updateDOM(wrapper, {
0: `<input type="checkbox" data-key="t1" lvt-on:click="Toggle" checked>`,
1: `<span>v3</span>`,
});
expect(
wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!.checked
).toBe(true);
});

it("server wins for a radio with an lvt-on:click handler", () => {
// The server-authoritative path also applies to radios. When every radio in
// the group is server-bound, the server sends authoritative state for ALL of
// them (here: r1 unchecked, r2 checked), which sidesteps the documented
// mid-pass mutual-exclusion limitation.
const uncheckedTree = {
s: [`<form>`, `</form>`],
0: `<input type="radio" name="opt" data-key="r1" lvt-on:click="Pick" value="a">` +
`<input type="radio" name="opt" data-key="r2" lvt-on:click="Pick" value="b">`,
};
client.updateDOM(wrapper, uncheckedTree);

const radios = wrapper.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(radios[0].checked).toBe(false);
expect(radios[1].checked).toBe(false);

const checkedTree = {
0: `<input type="radio" name="opt" data-key="r1" lvt-on:click="Pick" value="a">` +
`<input type="radio" name="opt" data-key="r2" lvt-on:click="Pick" value="b" checked>`,
};
client.updateDOM(wrapper, checkedTree);

const after = wrapper.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(after[0].checked).toBe(false);
expect(after[1].checked).toBe(true);
});

it("still preserves user state for a checkbox with NO server-bound handler", () => {
// Control: a plain form checkbox (no lvt-on:click) keeps the user's
// selection across a server refresh — the default must not regress.
const tree = {
s: [`<form>`, `</form>`],
0: `<input type="checkbox" data-key="p1" value="a">`,
};
client.updateDOM(wrapper, tree);

const cb = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
cb.checked = true; // user checks it

client.updateDOM(wrapper, tree); // server refresh, no checked attribute

const cbAfter = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
expect(cbAfter.checked).toBe(true);
});

it("preserves radio button checked state across morphdom updates", () => {
const initialTree = {
s: [
Expand Down
Loading