diff --git a/apps/website/package.json b/apps/website/package.json index baf4879..4c74b43 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -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", diff --git a/bun.lock b/bun.lock index 53298e8..81f9aa3 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ }, "packages/ilha": { "name": "ilha", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "alien-signals": "3.2.1", }, @@ -48,30 +48,30 @@ }, "packages/router": { "name": "@ilha/router", - "version": "0.8.3", + "version": "0.8.4", "dependencies": { "rou3": "0.9.0", "unplugin": "3.3.0", }, "devDependencies": { - "ilha": "0.9.1", + "ilha": "0.9.2", "vite": "^8.1.3", }, "peerDependencies": { - "ilha": ">=0.9.1", + "ilha": ">=0.9.2", }, }, "packages/store": { "name": "@ilha/store", - "version": "0.7.1", + "version": "0.7.2", "devDependencies": { "alien-signals": "3.2.1", - "ilha": "0.9.1", + "ilha": "0.9.2", "zod": "4.4.3", }, "peerDependencies": { "alien-signals": "^3.2.1", - "ilha": ">=0.9.1", + "ilha": ">=0.9.2", }, "optionalPeers": [ "ilha", diff --git a/packages/ilha/package.json b/packages/ilha/package.json index 6f2378f..bc482a7 100644 --- a/packages/ilha/package.json +++ b/packages/ilha/package.json @@ -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", diff --git a/packages/ilha/src/index.test.ts b/packages/ilha/src/index.test.ts index a8de47b..13719be 100644 --- a/packages/ilha/src/index.test.ts +++ b/packages/ilha/src/index.test.ts @@ -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``; + }); + + 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``; + }); + + 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 =
  • item
  • ; - expect(result.value).not.toContain("key="); - expect(result.value).toBe("
  • item
  • "); + expect(result.value).not.toContain(" key="); + expect(result.value).toBe('
  • item
  • '); }); it("explicit children prop is overridden by JSX children", () => { @@ -942,7 +1032,7 @@ describe("ilha JSX runtime", () => { return ; }; const r = ; - expect(r.value).toBe(""); + expect(r.value).toBe(''); expect(received).not.toHaveProperty("key"); expect(received).toHaveProperty("id", "x"); }); @@ -1035,11 +1125,11 @@ describe("ilha JSX runtime", () => { expect((x).value).not.toContain("javascript:"); }); - it("key prop does not appear on rendered element from function component", () => { + it("key on a function component surfaces only as data-key on its root element", () => { const Item = (props: { label: string }) =>
  • {props.label}
  • ; const result = ; - expect(result.value).toBe("
  • x
  • "); - expect(result.value).not.toContain("key="); + expect(result.value).toBe('
  • x
  • '); + expect(result.value).not.toContain(" key="); }); it("bind:group SSR emits checked on matching radio input", () => { diff --git a/packages/ilha/src/jsx-runtime.ts b/packages/ilha/src/jsx-runtime.ts index 0861953..fe1a6fd 100644 --- a/packages/ilha/src/jsx-runtime.ts +++ b/packages/ilha/src/jsx-runtime.ts @@ -158,11 +158,39 @@ function pushJsxAttr(chunks: string[], values: unknown[], name: string, value: u chunks.push('"'); } -function renderJsxElement(type: string, props: JsxProps, children: JsxChild[]): RawHtml { +function escapeAttrValue(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """).replace(/", m.index); + const openTag = out.value.slice(m.index, openEnd === -1 ? undefined : openEnd); + if (/\sdata-key\s*=/.test(openTag)) return out; + const insertAt = m.index + m[0].length; + return raw( + `${out.value.slice(0, insertAt)} data-key="${escapeAttrValue(key)}"${out.value.slice(insertAt)}`, + ); +} + +function renderJsxElement( + type: string, + props: JsxProps, + children: JsxChild[], + slotKey?: string, +): RawHtml { const chunks = [`<${type}`]; const values: unknown[] = []; if (props) for (const [name, value] of Object.entries(props)) pushJsxAttr(chunks, values, name, value); + if (slotKey !== undefined && props?.["data-key"] == null) { + pushJsxAttr(chunks, values, "data-key", slotKey); + } chunks[chunks.length - 1] += ">"; if (!VOID_ELEMENTS.has(type)) { for (const child of children) { @@ -185,12 +213,12 @@ export function jsx( const children: JsxChild[] = hasKeyArg || maybeKey === undefined ? restChildren : [maybeKey, ...restChildren]; const normalizedChildren = normalizeJsxChildren(props, children); + const slotKey = extractJsxSlotKey( + props, + typeof keyFromArg === "string" || typeof keyFromArg === "number" ? keyFromArg : undefined, + ); if (typeof type === "function") { - const slotKey = extractJsxSlotKey( - props, - typeof keyFromArg === "string" || typeof keyFromArg === "number" ? keyFromArg : undefined, - ); const componentProps: Record = { ...props, ...(normalizedChildren.length > 0 ? { children: normalizedChildren } : {}), @@ -201,7 +229,7 @@ export function jsx( if (slotKey !== undefined) (out as { key?: string }).key = slotKey; return html`${out}`; } - if (isRawHtml(out)) return out; + if (isRawHtml(out)) return slotKey !== undefined ? injectDataKey(out, slotKey) : out; if (typeof out === "string" && isIsland(type)) { return __ilhaJsxSlot(type, componentProps, slotKey); } @@ -218,7 +246,7 @@ export function jsx( return html`${out}`; } - return renderJsxElement(type, props, normalizedChildren); + return renderJsxElement(type, props, normalizedChildren, slotKey); } export const jsxs = jsx; diff --git a/packages/router/package.json b/packages/router/package.json index 004849f..9382814 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@ilha/router", - "version": "0.8.3", + "version": "0.8.4", "description": "A tiny SPA router for Ilha", "keywords": [ "frontend", @@ -68,10 +68,10 @@ "unplugin": "3.3.0" }, "devDependencies": { - "ilha": "0.9.1", + "ilha": "0.9.2", "vite": "^8.1.3" }, "peerDependencies": { - "ilha": ">=0.9.1" + "ilha": ">=0.9.2" } } diff --git a/packages/store/package.json b/packages/store/package.json index d0e1008..1297301 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -1,6 +1,6 @@ { "name": "@ilha/store", - "version": "0.7.1", + "version": "0.7.2", "description": "Shared reactive store for Ilha islands.", "keywords": [ "forms", @@ -53,12 +53,12 @@ }, "devDependencies": { "alien-signals": "3.2.1", - "ilha": "0.9.1", + "ilha": "0.9.2", "zod": "4.4.3" }, "peerDependencies": { "alien-signals": "^3.2.1", - "ilha": ">=0.9.1" + "ilha": ">=0.9.2" }, "peerDependenciesMeta": { "ilha": { diff --git a/templates/elysia/package.json b/templates/elysia/package.json index 1377e2f..517f481 100644 --- a/templates/elysia/package.json +++ b/templates/elysia/package.json @@ -10,11 +10,11 @@ }, "dependencies": { "@elysiajs/static": "^1.4.10", - "@ilha/router": "^0.8.3", - "@ilha/store": "^0.7.1", + "@ilha/router": "^0.8.4", + "@ilha/store": "^0.7.2", "areia": "^0.1.36", "elysia": "^1.4.29", - "ilha": "^0.9.1", + "ilha": "^0.9.2", "quando": "^0.1.6" }, "devDependencies": { diff --git a/templates/hono/package.json b/templates/hono/package.json index c98682e..ab955c5 100644 --- a/templates/hono/package.json +++ b/templates/hono/package.json @@ -8,11 +8,11 @@ }, "dependencies": { "@hono/node-server": "^2.0.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", "hono": "^4.12.28", - "ilha": "^0.9.1", + "ilha": "^0.9.2", "quando": "^0.1.6" }, "devDependencies": { diff --git a/templates/nitro/package.json b/templates/nitro/package.json index 392680c..7dd61f5 100644 --- a/templates/nitro/package.json +++ b/templates/nitro/package.json @@ -9,10 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "@ilha/router": "^0.8.3", - "@ilha/store": "^0.7.1", + "@ilha/router": "^0.8.4", + "@ilha/store": "^0.7.2", "areia": "^0.1.36", - "ilha": "^0.9.1", + "ilha": "^0.9.2", "nitro": "^3.0.260610-beta", "quando": "^0.1.6" }, diff --git a/templates/vite/package.json b/templates/vite/package.json index f090d6a..9584552 100644 --- a/templates/vite/package.json +++ b/templates/vite/package.json @@ -9,10 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "@ilha/router": "^0.8.3", - "@ilha/store": "^0.7.1", + "@ilha/router": "^0.8.4", + "@ilha/store": "^0.7.2", "areia": "^0.1.36", - "ilha": "^0.9.1", + "ilha": "^0.9.2", "quando": "^0.1.6" }, "devDependencies": {