From c3f49d87dadb3a5edce5849dceaa7310be30ef57 Mon Sep 17 00:00:00 2001 From: Pavel Grinchenko Date: Thu, 9 Apr 2026 21:18:25 +0100 Subject: [PATCH 1/2] fix: ensure renderOne re-runs update on every call without data Using a shared null reference caused renderList's identity check to skip the update callback on subsequent renderOne calls. Pass a fresh object each time so the update always runs. --- packages/nanotags/package.json | 2 +- packages/nanotags/src/render.test.ts | 48 ++++++++++++++++++++++++++++ packages/nanotags/src/render.ts | 5 ++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/nanotags/package.json b/packages/nanotags/package.json index 030df1d..961f0f2 100644 --- a/packages/nanotags/package.json +++ b/packages/nanotags/package.json @@ -82,7 +82,7 @@ }, { "path": ".size-check/render.mjs", - "limit": "410 B" + "limit": "417 B" }, { "path": ".size-check/context.mjs", diff --git a/packages/nanotags/src/render.test.ts b/packages/nanotags/src/render.test.ts index 2d72b7c..38e2f91 100644 --- a/packages/nanotags/src/render.test.ts +++ b/packages/nanotags/src/render.test.ts @@ -220,6 +220,54 @@ describe("render", () => { expect(container.children[0]).toBe(firstEl); }); + it("re-runs update callback without data on subsequent calls", () => { + const container = createHostWith(""); + const tpl = makeTpl(""); + let callCount = 0; + + const update = (el: Element) => { + callCount++; + el.textContent = `call-${callCount}`; + }; + + render(container, tpl, { update }); + expect(callCount).toBe(1); + expect(container.children[0]?.textContent).toBe("call-1"); + + render(container, tpl, { update }); + expect(callCount).toBe(2); + expect(container.children[0]?.textContent).toBe("call-2"); + + render(container, tpl, { update }); + expect(callCount).toBe(3); + expect(container.children[0]?.textContent).toBe("call-3"); + }); + + it("skips update when explicit data has not changed", () => { + const container = createHostWith(""); + const tpl = makeTpl(""); + let callCount = 0; + const data = { name: "Alice" }; + + render(container, tpl, { + data, + update: (el, d) => { + callCount++; + el.textContent = d.name; + }, + }); + expect(callCount).toBe(1); + + render(container, tpl, { + data, + update: (el, d) => { + callCount++; + el.textContent = d.name; + }, + }); + expect(callCount).toBe(1); + }); + it("switches between different templates", () => { const container = createHostWith(""); const loadingTpl = makeTpl('
Loading...
'); diff --git a/packages/nanotags/src/render.ts b/packages/nanotags/src/render.ts index 25196b0..a2eee35 100644 --- a/packages/nanotags/src/render.ts +++ b/packages/nanotags/src/render.ts @@ -87,8 +87,11 @@ export function render( template: HTMLTemplateElement, options?: RenderOptions, ): void { + // When no explicit data is provided, use a fresh object each call + // so renderList's identity check always passes and update always runs + const data = [options !== undefined && "data" in options ? options.data : {}] as T[]; renderList(container, template, { - data: [options?.data ?? (null as T)], + data, key: () => { let id = tplIds.get(template); if (id === undefined) { From 432bdf150a0fc14ef717d77cbcd50f069032e297 Mon Sep 17 00:00:00 2001 From: Pavel Grinchenko Date: Thu, 9 Apr 2026 21:37:53 +0100 Subject: [PATCH 2/2] test: add nested render/renderList reconciliation tests --- packages/nanotags/src/render.test.ts | 422 ++++++++++++++++++++++++++- 1 file changed, 419 insertions(+), 3 deletions(-) diff --git a/packages/nanotags/src/render.test.ts b/packages/nanotags/src/render.test.ts index 38e2f91..d91c135 100644 --- a/packages/nanotags/src/render.test.ts +++ b/packages/nanotags/src/render.test.ts @@ -60,7 +60,10 @@ describe("renderList", () => { }, }; - renderList(container, tpl, { ...opts, data: [{ id: 1 }, { id: 2 }, { id: 3 }] }); + renderList(container, tpl, { + ...opts, + data: [{ id: 1 }, { id: 2 }, { id: 3 }], + }); expect(container.children).toHaveLength(3); renderList(container, tpl, { ...opts, data: [{ id: 1 }, { id: 3 }] }); @@ -79,10 +82,16 @@ describe("renderList", () => { }, }; - renderList(container, tpl, { ...opts, data: [{ id: 1 }, { id: 2 }, { id: 3 }] }); + renderList(container, tpl, { + ...opts, + data: [{ id: 1 }, { id: 2 }, { id: 3 }], + }); const [el1, el2, el3] = Array.from(container.children); - renderList(container, tpl, { ...opts, data: [{ id: 3 }, { id: 1 }, { id: 2 }] }); + renderList(container, tpl, { + ...opts, + data: [{ id: 3 }, { id: 1 }, { id: 2 }], + }); expect(container.children[0]).toBe(el3); expect(container.children[1]).toBe(el1); expect(container.children[2]).toBe(el2); @@ -355,3 +364,410 @@ describe("render", () => { expect(container.children[0]?.textContent).toBe("Single"); }); }); + +describe("nested render/renderList", () => { + type Block = { heading: string; snippet: string }; + type Group = { pageId: string; pageTitle: string; blocks: Block[] }; + + const wrapperTpl = makeTpl('
'); + const groupTpl = makeTpl( + '
    ', + ); + const blockTpl = makeTpl("
  • "); + + function renderNested(container: Element, groups: Group[]) { + render(container, wrapperTpl, { + data: groups, + update(el) { + renderList(el, groupTpl, { + data: groups, + key: (g) => g.pageId, + update: (groupEl, g) => { + groupEl.querySelector("[data-title]")!.textContent = g.pageTitle; + renderList(groupEl.querySelector("[data-blocks]")!, blockTpl, { + data: g.blocks, + key: (b) => b.heading, + update: (blockEl, b) => { + blockEl.querySelector("[data-heading]")!.textContent = b.heading; + blockEl.querySelector("[data-snippet]")!.textContent = b.snippet; + }, + }); + }, + }); + }, + }); + } + + it("creates nested structure on first render", () => { + const container = createHostWith(""); + const groups: Group[] = [ + { + pageId: "intro", + pageTitle: "Introduction", + blocks: [ + { heading: "Getting Started", snippet: "Install the package" }, + { heading: "Quick Start", snippet: "Run the command" }, + ], + }, + ]; + + renderNested(container, groups); + + expect(container.children).toHaveLength(1); // wrapper + const wrapper = container.children[0]!; + expect(wrapper.children).toHaveLength(1); // one group + const group = wrapper.children[0]!; + expect(group.querySelector("[data-title]")!.textContent).toBe("Introduction"); + const blocks = group.querySelector("[data-blocks]")!; + expect(blocks.children).toHaveLength(2); + expect(blocks.children[0]!.querySelector("[data-heading]")!.textContent).toBe( + "Getting Started", + ); + expect(blocks.children[1]!.querySelector("[data-snippet]")!.textContent).toBe( + "Run the command", + ); + }); + + it("reuses wrapper element across updates", () => { + const container = createHostWith(""); + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + ]); + const wrapper = container.children[0]!; + + renderNested(container, [ + { + pageId: "a", + pageTitle: "A updated", + blocks: [{ heading: "h1", snippet: "s1 updated" }], + }, + ]); + expect(container.children[0]).toBe(wrapper); + }); + + it("reuses group elements by key across updates", () => { + const container = createHostWith(""); + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + { + pageId: "b", + pageTitle: "B", + blocks: [{ heading: "h2", snippet: "s2" }], + }, + ]); + const wrapper = container.children[0]!; + const groupA = wrapper.children[0]!; + const groupB = wrapper.children[1]!; + + renderNested(container, [ + { + pageId: "a", + pageTitle: "A v2", + blocks: [{ heading: "h1", snippet: "s1 v2" }], + }, + { + pageId: "b", + pageTitle: "B v2", + blocks: [{ heading: "h2", snippet: "s2 v2" }], + }, + ]); + expect(wrapper.children[0]).toBe(groupA); + expect(wrapper.children[1]).toBe(groupB); + expect(groupA.querySelector("[data-title]")!.textContent).toBe("A v2"); + expect(groupB.querySelector("[data-title]")!.textContent).toBe("B v2"); + }); + + it("reuses block elements by key across updates", () => { + const container = createHostWith(""); + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [ + { heading: "h1", snippet: "s1" }, + { heading: "h2", snippet: "s2" }, + ], + }, + ]); + const blocksContainer = container.querySelector("[data-blocks]")!; + const block1 = blocksContainer.children[0]!; + const block2 = blocksContainer.children[1]!; + + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [ + { heading: "h1", snippet: "s1 updated" }, + { heading: "h2", snippet: "s2 updated" }, + ], + }, + ]); + expect(blocksContainer.children[0]).toBe(block1); + expect(blocksContainer.children[1]).toBe(block2); + expect(block1.querySelector("[data-snippet]")!.textContent).toBe("s1 updated"); + expect(block2.querySelector("[data-snippet]")!.textContent).toBe("s2 updated"); + }); + + it("adds and removes groups without affecting siblings", () => { + const container = createHostWith(""); + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + { + pageId: "b", + pageTitle: "B", + blocks: [{ heading: "h2", snippet: "s2" }], + }, + ]); + const wrapper = container.children[0]!; + const groupA = wrapper.children[0]!; + + // Remove "b", add "c" + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + { + pageId: "c", + pageTitle: "C", + blocks: [{ heading: "h3", snippet: "s3" }], + }, + ]); + expect(wrapper.children).toHaveLength(2); + expect(wrapper.children[0]).toBe(groupA); + expect(wrapper.children[1]!.querySelector("[data-title]")!.textContent).toBe("C"); + }); + + it("adds and removes blocks within a group without affecting other groups", () => { + const container = createHostWith(""); + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [ + { heading: "h1", snippet: "s1" }, + { heading: "h2", snippet: "s2" }, + ], + }, + { + pageId: "b", + pageTitle: "B", + blocks: [{ heading: "h3", snippet: "s3" }], + }, + ]); + const wrapper = container.children[0]!; + const groupB = wrapper.children[1]!; + const groupBBlock = groupB.querySelector("[data-blocks]")!.children[0]!; + + // Modify blocks in group "a", keep group "b" the same data shape + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], // removed h2 + }, + { + pageId: "b", + pageTitle: "B", + blocks: [{ heading: "h3", snippet: "s3" }], + }, + ]); + + expect(wrapper.children[1]).toBe(groupB); + // group B's block element is reused since it's a new object with same key + const groupBBlockAfter = groupB.querySelector("[data-blocks]")!.children[0]!; + expect(groupBBlockAfter).toBe(groupBBlock); + + const groupABlocks = wrapper.children[0]!.querySelector("[data-blocks]")!; + expect(groupABlocks.children).toHaveLength(1); + }); + + it("reorders groups and preserves nested block elements", () => { + const container = createHostWith(""); + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + { + pageId: "b", + pageTitle: "B", + blocks: [{ heading: "h2", snippet: "s2" }], + }, + { + pageId: "c", + pageTitle: "C", + blocks: [{ heading: "h3", snippet: "s3" }], + }, + ]); + const wrapper = container.children[0]!; + const groupA = wrapper.children[0]!; + const groupB = wrapper.children[1]!; + const groupC = wrapper.children[2]!; + const blockInA = groupA.querySelector("[data-blocks]")!.children[0]!; + const blockInC = groupC.querySelector("[data-blocks]")!.children[0]!; + + // Reverse order + renderNested(container, [ + { + pageId: "c", + pageTitle: "C", + blocks: [{ heading: "h3", snippet: "s3" }], + }, + { + pageId: "b", + pageTitle: "B", + blocks: [{ heading: "h2", snippet: "s2" }], + }, + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + ]); + expect(wrapper.children[0]).toBe(groupC); + expect(wrapper.children[1]).toBe(groupB); + expect(wrapper.children[2]).toBe(groupA); + // Block elements inside reordered groups are still the same DOM nodes + expect(groupA.querySelector("[data-blocks]")!.children[0]).toBe(blockInA); + expect(groupC.querySelector("[data-blocks]")!.children[0]).toBe(blockInC); + }); + + it("switching outer template cleans up all nested content", () => { + const container = createHostWith(""); + const emptyTpl = makeTpl('
    No results
    '); + + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [ + { heading: "h1", snippet: "s1" }, + { heading: "h2", snippet: "s2" }, + ], + }, + ]); + expect(container.querySelectorAll("[data-heading]")).toHaveLength(2); + + // Switch to empty template + render(container, emptyTpl); + expect(container.children).toHaveLength(1); + expect(container.children[0]!.textContent).toBe("No results"); + expect(container.querySelectorAll("[data-heading]")).toHaveLength(0); + }); + + it("restores nested structure after switching templates back", () => { + const container = createHostWith(""); + const emptyTpl = makeTpl('
    No results
    '); + + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + ]); + const originalWrapper = container.children[0]!; + + render(container, emptyTpl); + expect(container.children[0]!.textContent).toBe("No results"); + + // Switch back - wrapper is recreated (old one was removed) + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + ]); + expect(container.children).toHaveLength(1); + expect(container.children[0]).not.toBe(originalWrapper); + expect(container.querySelector("[data-title]")!.textContent).toBe("A"); + expect(container.querySelector("[data-heading]")!.textContent).toBe("h1"); + }); + + it("handles empty groups list", () => { + const container = createHostWith(""); + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + ]); + + renderNested(container, []); + const wrapper = container.children[0]!; + expect(wrapper.children).toHaveLength(0); + }); + + it("handles groups with empty blocks", () => { + const container = createHostWith(""); + renderNested(container, [ + { + pageId: "a", + pageTitle: "A", + blocks: [{ heading: "h1", snippet: "s1" }], + }, + ]); + const blocksContainer = container.querySelector("[data-blocks]")!; + expect(blocksContainer.children).toHaveLength(1); + + renderNested(container, [{ pageId: "a", pageTitle: "A", blocks: [] }]); + expect(blocksContainer.children).toHaveLength(0); + }); + + it("renderList inside renderList: outer add does not destroy inner state", () => { + const container = createHostWith(""); + const outerTpl = makeTpl('
      '); + const innerTpl = makeTpl("
    • "); + + type Inner = { id: number; text: string }; + type Outer = { id: string; items: Inner[] }; + + function renderTwoLevel(data: Outer[]) { + renderList(container, outerTpl, { + data, + key: (o) => o.id, + update: (el, o) => { + const innerContainer = el.querySelector("[data-inner]")!; + renderList(innerContainer, innerTpl, { + data: o.items, + key: (i) => i.id, + update: (li, i) => { + li.textContent = i.text; + }, + }); + }, + }); + } + + renderTwoLevel([{ id: "x", items: [{ id: 1, text: "one" }] }]); + const outerX = container.children[0]!; + const innerOne = outerX.querySelector("[data-inner]")!.children[0]!; + + // Add a new outer item - existing outer+inner elements should be preserved + renderTwoLevel([ + { id: "x", items: [{ id: 1, text: "one" }] }, + { id: "y", items: [{ id: 2, text: "two" }] }, + ]); + expect(container.children).toHaveLength(2); + expect(container.children[0]).toBe(outerX); + expect(outerX.querySelector("[data-inner]")!.children[0]).toBe(innerOne); + }); +});