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
6 changes: 3 additions & 3 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/geist-mono": "^5.2.8",
"@ilha/router": "^0.8.3",
"@ilha/store": "^0.7.1",
"@ilha/router": "^0.8.4",
"@ilha/store": "^0.7.2",
"areia": "^0.1.36",
"dedent": "^1.7.2",
"ilha": "^0.9.1",
"ilha": "^0.9.2",
"imprensa": "^0.1.20",
"lucide": "^1.23.0",
"shiki": "^4.3.1",
Expand Down
14 changes: 7 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/ilha/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ilha",
"version": "0.9.1",
"version": "0.9.2",
"description": "A tiny, framework-free island architecture library",
"keywords": [
"framework-free",
Expand Down
168 changes: 168 additions & 0 deletions packages/ilha/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8279,6 +8279,174 @@ describe("keyed morph (data-key)", () => {
unmount();
cleanup(el);
});

it("protects surviving keyed elements from unkeyed nodes taking their position", () => {
let setItems!: (v?: string[]) => string[] | void;
const Island = ilha.state("items", ["a", "b"]).render(({ state }) => {
setItems = state.items as typeof setItems;
return html`<ul>
${state
.items()
.map((k) =>
k.startsWith("#")
? html`<li class="divider">${k}</li>`
: html`<li data-key="${k}">${k}</li>`,
)}
</ul>`;
});

const el = makeEl();
const unmount = Island.mount(el);
const before = Array.from(el.querySelectorAll<HTMLLIElement>("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`<div>${state.label()}<span data-key="k">keyed</span></div>`;
});

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`<ul>
${state.keys().map((k, i) => html`<li data-key="${k}">${k}${i}</li>`)}
</ul>`;
});

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`<input type="checkbox" ${state.done() ? raw("checked") : ""} />`;
});

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`<div>
<p>${state.count()}</p>
<input type="checkbox" />
</div>`;
});

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`<input type="text" value="${state.name()}" />`;
});

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`<div>
<p>${state.count()}</p>
<input type="text" value="fixed" />
</div>`;
});

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", () => {
Expand Down
49 changes: 37 additions & 12 deletions packages/ilha/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -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") {
Expand Down
Loading
Loading