diff --git a/state/tree-renderer.ts b/state/tree-renderer.ts index 253ea8b..4564727 100644 --- a/state/tree-renderer.ts +++ b/state/tree-renderer.ts @@ -18,6 +18,28 @@ function deepClone(obj: T): T { return JSON.parse(JSON.stringify(obj)); } +/** + * Checks if a node is a valid range structure. + * + * A "range" in LiveTemplate represents a {{range .Items}}...{{end}} construct. + * It has: + * - `d` (dynamics): Array of rendered items + * - `s` (statics): Array of static HTML fragments between dynamic slots + * + * A "non-range" is any other tree node (e.g., an {{else}} clause with simple content). + * + * @param node - The tree node to check + * @returns true if the node has both `d` and `s` arrays (valid range structure) + */ +function isRangeNode(node: any): boolean { + return ( + node != null && + typeof node === "object" && + Array.isArray(node.d) && + Array.isArray(node.s) + ); +} + /** * Handles tree state management and HTML reconstruction logic for LiveTemplate. */ @@ -110,6 +132,18 @@ export class TreeRenderer { return update; } + // Detect range→non-range transition: when existing has a range structure + // but update does NOT, we must do a full replacement instead of merge. + // Otherwise, the old range items would be preserved and rendered with + // the new (else clause) statics, causing wrong content. + // See isRangeNode() for definition of "range" vs "non-range" structures. + if (isRangeNode(existing) && !isRangeNode(update)) { + this.logger.debug( + `[deepMerge] Range→non-range transition at path ${currentPath}, replacing instead of merging` + ); + return update; + } + const merged: any = { ...existing }; for (const [key, value] of Object.entries(update)) { diff --git a/tests/tree-renderer.test.ts b/tests/tree-renderer.test.ts new file mode 100644 index 0000000..9fc8235 --- /dev/null +++ b/tests/tree-renderer.test.ts @@ -0,0 +1,187 @@ +/** + * TreeRenderer Tests - Range to Non-Range Transitions + * + * In LiveTemplate, tree structures represent rendered template content: + * + * "Range" structure: Represents a {{range .Items}}...{{end}} loop + * - Has `d` (dynamics): Array of rendered items + * - Has `s` (statics): Array of static HTML between dynamic slots + * - Example: { d: [{0: "Item 1"}, {0: "Item 2"}], s: ["
  • ", "
  • "] } + * + * "Non-range" structure: Any other content (e.g., {{else}} clause) + * - Has numbered keys for dynamic content + * - Has `s` for statics, but NO `d` array + * - Example: { s: ["

    No items

    "], 0: "search query" } + * + * The bug these tests cover: When transitioning from range to non-range, + * the old merge behavior preserved the `d` array, causing old items to + * render with new statics (e.g., "No posts found matching [old post title]"). + */ +import { TreeRenderer } from "../state/tree-renderer"; +import { createLogger } from "../utils/logger"; + +describe("TreeRenderer", () => { + let renderer: TreeRenderer; + let mockConsole: { + error: jest.Mock; + warn: jest.Mock; + info: jest.Mock; + debug: jest.Mock; + log: jest.Mock; + }; + + beforeEach(() => { + mockConsole = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + }; + const logger = createLogger({ + level: "debug", + sink: mockConsole as unknown as Console, + }); + renderer = new TreeRenderer(logger); + }); + + describe("applyUpdate - range to non-range transition", () => { + it("should replace range structure with else clause content", () => { + // Initial state: range with items (posts exist) + const initialUpdate = { + s: ["
    ", "
    "], + 0: { + d: [ + { 0: "Post Title 1", _k: "id-1" }, + { 0: "Post Title 2", _k: "id-2" }, + ], + s: ["

    ", "

    "], + }, + }; + renderer.applyUpdate(initialUpdate); + + // Verify initial state has range items + const stateAfterInitial = renderer.getTreeState(); + expect(stateAfterInitial[0]).toHaveProperty("d"); + expect(stateAfterInitial[0].d).toHaveLength(2); + + // Update: else clause (no posts, search returned empty) + // This is what the server sends when range becomes empty + const elseUpdate = { + 0: { + s: ['

    No posts found matching "', '"

    '], + 0: "search query", + }, + }; + renderer.applyUpdate(elseUpdate); + + // Verify: old range 'd' should be REPLACED, not preserved + const stateAfterElse = renderer.getTreeState(); + + // The key assertion: 'd' should NOT exist after range→non-range transition + expect(stateAfterElse[0]).not.toHaveProperty("d"); + + // Should have the else clause content + expect(stateAfterElse[0]).toHaveProperty("0", "search query"); + expect(stateAfterElse[0].s).toEqual([ + '

    No posts found matching "', + '"

    ', + ]); + }); + + it("should preserve range structure when update also has range", () => { + // Initial state: range with items + const initialUpdate = { + s: ["
    ", "
    "], + 0: { + d: [{ 0: "Item 1", _k: "id-1" }], + s: ["
  • ", "
  • "], + }, + }; + renderer.applyUpdate(initialUpdate); + + // Update: still a range but with different items + const rangeUpdate = { + 0: { + d: [ + { 0: "Item 1", _k: "id-1" }, + { 0: "Item 2", _k: "id-2" }, + ], + s: ["
  • ", "
  • "], + }, + }; + renderer.applyUpdate(rangeUpdate); + + // Should merge/update the range, not replace entirely + const state = renderer.getTreeState(); + expect(state[0]).toHaveProperty("d"); + expect(state[0].d).toHaveLength(2); + }); + + it("should handle nested range to non-range transitions", () => { + // Initial: nested structure with range + const initialUpdate = { + s: ["
    ", "
    "], + 0: { + s: ["
    ", "
    "], + 0: { + d: [{ 0: "Nested Item", _k: "nested-1" }], + s: ["", ""], + }, + }, + }; + renderer.applyUpdate(initialUpdate); + + // Update nested path with non-range content + const nestedElseUpdate = { + 0: { + 0: { + s: ["

    ", "

    "], + 0: "No nested items", + }, + }, + }; + renderer.applyUpdate(nestedElseUpdate); + + // Nested range should be replaced + const state = renderer.getTreeState(); + expect(state[0][0]).not.toHaveProperty("d"); + expect(state[0][0][0]).toBe("No nested items"); + }); + }); + + describe("render - range to non-range transition", () => { + it("should render else content after range items are removed", () => { + // Initial: range with items + const initialUpdate = { + s: [""], + 0: { + d: [ + { 0: "Apple", _k: "1" }, + { 0: "Banana", _k: "2" }, + ], + s: ["
  • ", "
  • "], + }, + }; + const initialResult = renderer.applyUpdate(initialUpdate); + + expect(initialResult.html).toContain("
  • Apple
  • "); + expect(initialResult.html).toContain("
  • Banana
  • "); + + // Update to else clause + const elseUpdate = { + 0: { + s: ["

    ", "

    "], + 0: "No items available", + }, + }; + const elseResult = renderer.applyUpdate(elseUpdate); + + // Should NOT contain old items + expect(elseResult.html).not.toContain("Apple"); + expect(elseResult.html).not.toContain("Banana"); + // Should contain else content + expect(elseResult.html).toContain("

    No items available

    "); + }); + }); +});