`;
+ });
+
+ const el = makeEl();
+ const unmount = Island.mount(el);
+ const before = Array.from(el.querySelectorAll("li[data-key]"));
+
+ // An UNKEYED sibling lands at position 0 — the keyed survivors must be
+ // shifted down intact, not clobbered positionally.
+ setItems(["#divider", "a", "b"]);
+
+ const after = Array.from(el.querySelectorAll("li"));
+ expect(after.map((li) => li.textContent)).toEqual(["#divider", "a", "b"]);
+ expect(after[1]).toBe(before[0]!);
+ expect(after[2]).toBe(before[1]!);
+ unmount();
+ cleanup(el);
+ });
+
+ it("protects surviving keyed elements from text nodes taking their position", () => {
+ let setLabel!: (v?: string) => string | void;
+ const Island = ilha.state("label", "").render(({ state }) => {
+ setLabel = state.label as typeof setLabel;
+ return html`
${state.label()}keyed
`;
+ });
+
+ const el = makeEl();
+ const unmount = Island.mount(el);
+ const keyed = el.querySelector("span")!;
+
+ // Empty string interpolation emits nothing; a non-empty one prepends a
+ // text node at the keyed element's position.
+ setLabel("hello");
+
+ const div = el.querySelector("div")!;
+ expect(div.textContent).toBe("hellokeyed");
+ expect(el.querySelector("span")).toBe(keyed);
+ unmount();
+ cleanup(el);
+ });
+
+ it("does not let a duplicate data-key steal an already-placed element", () => {
+ let setKeys!: (v?: string[]) => string[] | void;
+ const Island = ilha.state("keys", ["a", "b"]).render(({ state }) => {
+ setKeys = state.keys as typeof setKeys;
+ return html`
+ ${state.keys().map((k, i) => html`
${k}${i}
`)}
+
`;
+ });
+
+ const el = makeEl();
+ const unmount = Island.mount(el);
+ const firstA = el.querySelectorAll("li")[0]!;
+
+ // Author error (duplicate key) must degrade gracefully: the first "a"
+ // keeps the original node, the second renders as a fresh element.
+ setKeys(["a", "a", "b"]);
+
+ const after = Array.from(el.querySelectorAll("li"));
+ expect(after.map((li) => li.textContent)).toEqual(["a0", "a1", "b2"]);
+ expect(after[0]).toBe(firstA);
+ unmount();
+ cleanup(el);
+ });
+});
+
+describe("morph input property sync", () => {
+ it("updates the live checked property when the checked attribute changes", () => {
+ let setDone!: (v?: boolean) => boolean | void;
+ const Island = ilha.state("done", false).render(({ state }) => {
+ setDone = state.done as typeof setDone;
+ return html``;
+ });
+
+ const el = makeEl();
+ const unmount = Island.mount(el);
+ const input = el.querySelector("input")!;
+ expect(input.checked).toBe(false);
+
+ // User clicks first — the live property is now dirty and no longer
+ // follows attribute changes on its own.
+ input.checked = true;
+
+ setDone(true);
+ expect(el.querySelector("input")).toBe(input);
+ expect(input.checked).toBe(true);
+
+ setDone(false);
+ expect(input.checked).toBe(false);
+ unmount();
+ cleanup(el);
+ });
+
+ it("preserves a user-toggled checkbox across unrelated re-renders", () => {
+ let setCount!: (v?: number) => number | void;
+ const Island = ilha.state("count", 0).render(({ state }) => {
+ setCount = state.count as typeof setCount;
+ return html`
+
${state.count()}
+
+
`;
+ });
+
+ const el = makeEl();
+ const unmount = Island.mount(el);
+ const input = el.querySelector("input")!;
+ input.checked = true;
+
+ // The checked attribute is absent in both trees — the user's live
+ // property state must survive the morph untouched.
+ setCount(1);
+ expect(el.querySelector("input")).toBe(input);
+ expect(input.checked).toBe(true);
+ unmount();
+ cleanup(el);
+ });
+
+ it("updates the live value property when the value attribute changes", () => {
+ let setName!: (v?: string) => string | void;
+ const Island = ilha.state("name", "ada").render(({ state }) => {
+ setName = state.name as typeof setName;
+ return html``;
+ });
+
+ const el = makeEl();
+ const unmount = Island.mount(el);
+ const input = el.querySelector("input")!;
+ // User typed over the rendered value; a template change must win...
+ input.value = "typed";
+ setName("grace");
+ expect(input.value).toBe("grace");
+ unmount();
+ cleanup(el);
+ });
+
+ it("preserves in-progress typing when the value attribute is unchanged", () => {
+ let setCount!: (v?: number) => number | void;
+ const Island = ilha.state("count", 0).render(({ state }) => {
+ setCount = state.count as typeof setCount;
+ return html`
+
${state.count()}
+
+
`;
+ });
+
+ const el = makeEl();
+ const unmount = Island.mount(el);
+ const input = el.querySelector("input")!;
+ input.value = "user typing";
+
+ setCount(1);
+ expect(input.value).toBe("user typing");
+ unmount();
+ cleanup(el);
+ });
});
describe("island.define() custom elements", () => {
diff --git a/packages/ilha/src/index.ts b/packages/ilha/src/index.ts
index 660f954..c5162fe 100644
--- a/packages/ilha/src/index.ts
+++ b/packages/ilha/src/index.ts
@@ -360,23 +360,30 @@ function morphChildren(fromParent: Element, toParent: Element): void {
const toNode = toNodes[i]!;
let fromNode: ChildNode | undefined = fromParent.childNodes[i];
- if (fromKeyed !== null && toNode.nodeType === 1) {
- const key = (toNode as Element).getAttribute("data-key");
- if (key !== null) {
- const match = fromKeyed.get(key);
+ if (fromKeyed !== null) {
+ const toKey = toNode.nodeType === 1 ? (toNode as Element).getAttribute("data-key") : null;
+ if (toKey !== null) {
+ const match = fromKeyed.get(toKey);
if (match) {
+ // Consume the key so a duplicate data-key later in the new tree
+ // cannot steal this node back out of its settled position.
+ fromKeyed.delete(toKey);
if (match !== fromNode) {
fromParent.insertBefore(match, fromNode ?? null);
fromNode = match;
}
- } else if (fromNode instanceof Element) {
- const fromKey = fromNode.getAttribute("data-key");
- if (fromKey !== null && toKeys!.has(fromKey)) {
- // The element at this position belongs to a different SURVIVING
- // key — insert the new child fresh instead of clobbering it.
- fromParent.insertBefore(toNode.cloneNode(true), fromNode);
- continue;
- }
+ }
+ }
+ // The from-element at this position belongs to a DIFFERENT surviving
+ // key (the to-node is unkeyed, a text/comment node, or a new key) —
+ // insert the new child fresh instead of clobbering the survivor. When
+ // fromNode is the keyed match itself, its key equals toKey and this
+ // guard is skipped.
+ if (fromNode instanceof Element) {
+ const fromKey = fromNode.getAttribute("data-key");
+ if (fromKey !== null && fromKey !== toKey && toKeys!.has(fromKey)) {
+ fromParent.insertBefore(toNode.cloneNode(true), fromNode);
+ continue;
}
}
}
@@ -415,6 +422,24 @@ function morphChildren(fromParent: Element, toParent: Element): void {
continue;
}
+ if (fromEl.localName === "input") {
+ // Attributes only set the DEFAULT checked/value; once the user (or a
+ // bind write) touches the live property, attribute updates alone no
+ // longer reflect in the UI, so a positionally-reused input would keep
+ // showing the previous item's state. Mirror the template's attribute
+ // into the property — but only when the attribute actually changed,
+ // so unrelated re-renders never clobber in-progress user input
+ // (same policy as textarea below).
+ const hadChecked = fromEl.hasAttribute("checked");
+ const hadValue = fromEl.getAttribute("value");
+ syncAttributes(fromEl, toEl);
+ const hasChecked = toEl.hasAttribute("checked");
+ if (hasChecked !== hadChecked) (fromEl as HTMLInputElement).checked = hasChecked;
+ const newValue = toEl.getAttribute("value");
+ if (newValue !== hadValue) (fromEl as HTMLInputElement).value = newValue ?? "";
+ continue;
+ }
+
syncAttributes(fromEl, toEl);
if (fromEl.localName === "textarea") {
diff --git a/packages/ilha/src/jsx-runtime.test.tsx b/packages/ilha/src/jsx-runtime.test.tsx
index 0bcc405..d9a9daf 100644
--- a/packages/ilha/src/jsx-runtime.test.tsx
+++ b/packages/ilha/src/jsx-runtime.test.tsx
@@ -108,6 +108,96 @@ describe("ilha JSX runtime", () => {
expect(().value).toBe('');
});
+ it("emits key as data-key on host elements", () => {
+ expect((
x
).value).toBe('
x
');
+ expect((
x
).value).toBe('
x
');
+ });
+
+ it("does not override an explicit data-key with the JSX key", () => {
+ expect(
+ (
+
+ x
+
+ ).value,
+ ).toBe('
x
');
+ });
+
+ it("injects data-key into the root element of function component output", () => {
+ function Row({ label }: { label: string }) {
+ return html``;
+ }
+
+ expect(().value).toBe(
+ '',
+ );
+ });
+
+ it("escapes the injected data-key value", () => {
+ function Row() {
+ return html``;
+ }
+
+ const out = ('} />).value;
+ expect(out).toBe('');
+ expect(document.createRange().createContextualFragment(out).querySelector("img")).toBeNull();
+ });
+
+ it("keeps a keyed component's DOM identity when a sibling is prepended (checked state does not leak)", () => {
+ // Regression: without key propagation, prepending a todo made the new
+ // item positionally reuse the old first item's DOM, inheriting its
+ // user-toggled checked state and controller-owned data-checked attr.
+ type Todo = { id: string; title: string };
+ let setTodos!: (v?: Todo[]) => Todo[] | void;
+
+ function Row({ todo }: { todo: Todo }) {
+ return html``;
+ }
+
+ const Island = ilha
+ .state("todos", [{ id: "t1", title: "first" }] as Todo[])
+ .render(({ state }) => {
+ setTodos = state.todos as typeof setTodos;
+ return (
+
+ {state.todos().map((todo) => (
+
+ ))}
+
+ );
+ });
+
+ const el = makeEl();
+ const unmount = Island.mount(el);
+
+ // User checks the first todo; the checkbox controller reflects it onto
+ // the live checked property and the data-checked presence attr.
+ const firstInput = el.querySelector("input")!;
+ firstInput.checked = true;
+ firstInput.closest('[data-slot="checkbox"]')!.setAttribute("data-checked", "");
+
+ setTodos([
+ { id: "t2", title: "second" },
+ { id: "t1", title: "first" },
+ ]);
+
+ const inputs = Array.from(el.querySelectorAll("input"));
+ expect(inputs.length).toBe(2);
+ // The new item must come in fresh and unchecked...
+ expect(inputs[0]!.checked).toBe(false);
+ expect(inputs[0]!.closest('[data-slot="checkbox"]')!.hasAttribute("data-checked")).toBe(false);
+ // ...while the old item keeps its DOM node and checked state.
+ expect(inputs[1]).toBe(firstInput);
+ expect(inputs[1]!.checked).toBe(true);
+ expect(inputs[1]!.closest('[data-slot="checkbox"]')!.hasAttribute("data-checked")).toBe(true);
+
+ unmount();
+ cleanup(el);
+ });
+
it("renders an array of strings as concatenated escaped HTML", () => {
const items = ["foo", "bar", "baz"];
@@ -677,10 +767,10 @@ describe("ilha JSX runtime", () => {
cleanup(el);
});
- it("strips key prop from rendered HTML", () => {
+ it("reflects key onto rendered HTML as data-key only", () => {
const result =