From d2b11c8adfcb4f64955e3f6e19bece13efeea9c7 Mon Sep 17 00:00:00 2001
From: Adnaan
Date: Thu, 29 Jan 2026 20:52:32 +0100
Subject: [PATCH 1/5] =?UTF-8?q?fix:=20handle=20nested=20range=E2=86=92non-?=
=?UTF-8?q?range=20transitions=20correctly?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The previous implementation only checked for range structures directly
on a node using isRangeNode(). This missed cases where the range is
nested inside another structure, such as:
{{if .ShowList}}
{{range .Items}}...{{end}}
{{else}}
List is hidden
{{end}}
When ShowList changes from true to false:
- Position 0 changes from {"0": {range...}, "s": [""]}
- To {"s": ["List is hidden
"]}
The nested range at position 0.0 was being preserved during merge,
causing orphaned data in the tree state.
Fix:
- Add hasRangeAnywhere() to recursively check for nested ranges
- Add shouldFullReplace() to detect all structure transitions
- Update deepMergeTreeNodes to use shouldFullReplace instead of
just isRangeNode
Tests:
- Add 3 new test cases for nested range→non-range transitions
- Test deeply nested ranges (3 levels deep)
- Test transition back from else to nested range
Co-Authored-By: Claude Opus 4.5
---
state/tree-renderer.ts | 90 ++++++++++++++++++++--
tests/tree-renderer.test.ts | 145 ++++++++++++++++++++++++++++++++++++
2 files changed, 228 insertions(+), 7 deletions(-)
diff --git a/state/tree-renderer.ts b/state/tree-renderer.ts
index 9ccf706..3d82c56 100644
--- a/state/tree-renderer.ts
+++ b/state/tree-renderer.ts
@@ -40,6 +40,79 @@ function isRangeNode(node: any): boolean {
);
}
+/**
+ * Checks if a node or any of its nested children contains a range structure.
+ *
+ * This is needed for detecting structure transitions like:
+ * - {{if .ShowList}}{{range .Items}}...{{end}}
{{else}}Hidden
{{end}}
+ *
+ * When ShowList changes from true to false, the outer node doesn't have `d` directly,
+ * but its child at position "0" does. We need to detect this nested range to know
+ * that a full replacement is required instead of a merge.
+ *
+ * @param node - The tree node to check
+ * @returns true if this node or any nested child has a range structure
+ */
+function hasRangeAnywhere(node: any): boolean {
+ if (node == null || typeof node !== "object" || Array.isArray(node)) {
+ return false;
+ }
+
+ // Check if this node itself is a range
+ if (isRangeNode(node)) {
+ return true;
+ }
+
+ // Check numeric keys (dynamic positions) for nested ranges
+ for (const key of Object.keys(node)) {
+ if (/^\d+$/.test(key)) {
+ const child = node[key];
+ if (child != null && typeof child === "object" && !Array.isArray(child)) {
+ if (hasRangeAnywhere(child)) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Determines if a structure transition requires full replacement instead of merge.
+ *
+ * This handles cases where:
+ * 1. Old structure has a range (directly or nested) but new structure doesn't
+ * 2. New structure has statics (indicating a complete structure definition)
+ * 3. Old structure has dynamics that new structure doesn't have
+ *
+ * @param existing - The existing tree node
+ * @param update - The update tree node
+ * @returns true if the update should fully replace existing instead of merging
+ */
+function shouldFullReplace(existing: any, update: any): boolean {
+ // If update doesn't have statics, it's a partial update, not a replacement
+ if (!update.s || !Array.isArray(update.s)) {
+ return false;
+ }
+
+ // Check for range→non-range transition (including nested ranges)
+ if (hasRangeAnywhere(existing) && !hasRangeAnywhere(update)) {
+ return true;
+ }
+
+ // Check if existing has dynamics that update doesn't have
+ for (const key of Object.keys(existing)) {
+ if (/^\d+$/.test(key) && !(key in update)) {
+ // Existing has a dynamic position that update doesn't
+ // If update has statics, this is a structure change
+ return true;
+ }
+ }
+
+ return false;
+}
+
/**
* Handles tree state management and HTML reconstruction logic for LiveTemplate.
*/
@@ -132,14 +205,17 @@ 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)) {
+ // Detect structure transitions that require full replacement instead of merge.
+ // This handles:
+ // 1. Direct range→non-range: existing has d/s arrays, update doesn't
+ // 2. Nested range→non-range: existing has range in a child, update doesn't
+ // 3. Structure changes: existing has dynamics that update doesn't have
+ //
+ // Without this check, old range data would be preserved and mixed with
+ // new statics, causing wrong content or memory leaks.
+ if (shouldFullReplace(existing, update)) {
this.logger.debug(
- `[deepMerge] Range→non-range transition at path ${currentPath}, replacing instead of merging`
+ `[deepMerge] Structure transition at path ${currentPath}, replacing instead of merging`
);
return update;
}
diff --git a/tests/tree-renderer.test.ts b/tests/tree-renderer.test.ts
index 9fc8235..8df62fd 100644
--- a/tests/tree-renderer.test.ts
+++ b/tests/tree-renderer.test.ts
@@ -184,4 +184,149 @@ describe("TreeRenderer", () => {
expect(elseResult.html).toContain("No items available
");
});
});
+
+ describe("render - NESTED range to non-range transition", () => {
+ /**
+ * This tests the case where a range is nested inside another structure:
+ *
+ * Template:
+ * {{if .ShowList}}
+ *
+ * {{range .Items}}
+ * - {{.Text}}
+ * {{end}}
+ *
+ * {{else}}
+ * List is hidden
+ * {{end}}
+ *
+ * When ShowList goes from true to false:
+ * - Position 0 changes from {0: {range...}, s: [""]}
+ * - To {s: ["List is hidden
"]}
+ *
+ * The nested range at position 0.0 must be removed, not preserved.
+ */
+ it("should replace nested range structure with else clause", () => {
+ // Initial state: ShowList=true with items
+ // Structure: position 0 contains a UL wrapper with nested range at 0.0
+ const initialUpdate = {
+ s: ["", "
"],
+ 0: {
+ // The if-branch: UL wrapper containing the range
+ 0: {
+ // The range (nested at position 0.0)
+ d: [
+ { 0: "item-1", 1: "Apple", _k: "item-1" },
+ { 0: "item-2", 1: "Banana", _k: "item-2" },
+ ],
+ s: ['', ""],
+ },
+ s: [""], // UL wrapper statics
+ },
+ };
+ const initialResult = renderer.applyUpdate(initialUpdate);
+
+ expect(initialResult.html).toContain("");
+ expect(initialResult.html).toContain('- Apple
');
+ expect(initialResult.html).toContain('- Banana
');
+ expect(initialResult.html).toContain("
");
+
+ // Update: ShowList=false, show else clause
+ // The nested range should be completely replaced
+ const elseUpdate = {
+ 0: {
+ // The else-branch: just a paragraph
+ s: ["List is hidden
"],
+ },
+ };
+ const elseResult = renderer.applyUpdate(elseUpdate);
+
+ // Verify the tree state doesn't contain orphaned range data
+ const treeState = renderer.getTreeState();
+ expect(treeState[0]).not.toHaveProperty("d");
+ expect(treeState[0][0]).toBeUndefined(); // Nested range should be gone
+
+ // Should NOT contain old items
+ expect(elseResult.html).not.toContain("Apple");
+ expect(elseResult.html).not.toContain("Banana");
+ expect(elseResult.html).not.toContain("");
+ expect(elseResult.html).not.toContain("- List is hidden
");
+ });
+
+ it("should handle transition back from else to nested range", () => {
+ // Start with else clause (ShowList=false)
+ const elseUpdate = {
+ s: ["", "
"],
+ 0: {
+ s: ["List is hidden
"],
+ },
+ };
+ renderer.applyUpdate(elseUpdate);
+
+ // Transition to ShowList=true with items
+ const rangeUpdate = {
+ 0: {
+ 0: {
+ d: [
+ { 0: "item-1", 1: "New Item", _k: "item-1" },
+ ],
+ s: ['', ""],
+ },
+ s: [""],
+ },
+ };
+ const rangeResult = renderer.applyUpdate(rangeUpdate);
+
+ // Should show the list now
+ expect(rangeResult.html).toContain("");
+ expect(rangeResult.html).toContain('- New Item
');
+ expect(rangeResult.html).not.toContain("List is hidden");
+ });
+
+ it("should handle deeply nested range to non-range transition", () => {
+ // Initial: deeply nested structure with range at position 0.0.0
+ const initialUpdate = {
+ s: ["", ""],
+ 0: {
+ s: [""],
+ 0: {
+ s: ["", "
"],
+ 0: {
+ // Range nested 3 levels deep
+ d: [
+ { 0: "deep-1", 1: "Deep Item 1", _k: "deep-1" },
+ { 0: "deep-2", 1: "Deep Item 2", _k: "deep-2" },
+ ],
+ s: ['', ""],
+ },
+ },
+ },
+ };
+ const initialResult = renderer.applyUpdate(initialUpdate);
+
+ expect(initialResult.html).toContain('Deep Item 1');
+ expect(initialResult.html).toContain('Deep Item 2');
+
+ // Replace the entire nested structure with simple content
+ const simpleUpdate = {
+ 0: {
+ s: ["No content
"],
+ },
+ };
+ const simpleResult = renderer.applyUpdate(simpleUpdate);
+
+ // All nested range data should be gone
+ expect(simpleResult.html).not.toContain("Deep Item");
+ expect(simpleResult.html).not.toContain("No content");
+
+ // Verify tree state is clean
+ const treeState = renderer.getTreeState();
+ expect(treeState[0]).not.toHaveProperty("d");
+ expect(treeState[0][0]).toBeUndefined();
+ });
+ });
});
From 90827e8064a4435b3ee071843d43049e1e2aa8de Mon Sep 17 00:00:00 2001
From: Adnaan
Date: Sun, 1 Feb 2026 07:02:27 +0100
Subject: [PATCH 2/5] fix: use deep merge for update operations to preserve
statics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When applying update operations to range items, the client was using
shallow spread ({...old, ...changes}) which replaced entire sub-objects.
This caused statics to be lost when the server sent partial updates.
For example, when updating field "5" in an item:
- Server sends: {"5": {"0": "new text"}}
- Old item had: {"5": {"s": ["", ""], "0": "old text"}}
- Shallow spread result: {"5": {"0": "new text"}} ← Lost statics!
- Deep merge result: {"5": {"s": ["", ""], "0": "new text"}} ✓
This fix changes both applyDifferentialOpsToRange and
applyDifferentialOpsToRangeMap to use deepMergeTreeNodes instead of
shallow spread.
Also adds oracle-server.js for the Go fuzz testing framework. This
persistent Node.js server applies diffs using the production client
code, serving as the source of truth for diff correctness validation.
Co-Authored-By: Claude Opus 4.5
---
oracle-server.js | 75 ++++++++++++++++++++++++++++++++++++++++++
state/tree-renderer.ts | 24 +++++++++-----
2 files changed, 91 insertions(+), 8 deletions(-)
create mode 100644 oracle-server.js
diff --git a/oracle-server.js b/oracle-server.js
new file mode 100644
index 0000000..934ddf1
--- /dev/null
+++ b/oracle-server.js
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+/**
+ * Persistent Oracle Server for Go fuzz testing framework.
+ *
+ * Unlike cross-validate.ts which handles a single request and exits,
+ * this server stays alive and handles multiple requests via line-delimited JSON.
+ *
+ * Protocol:
+ * - Each line of stdin is a JSON request: {"oldTree": ..., "diff": ...}
+ * - Each request gets a JSON response on stdout: {"html": ..., "tree": ..., "error": ...}
+ * - Server exits when stdin closes
+ *
+ * This reduces per-request overhead from ~300ms (process spawn) to ~20-50ms (IPC only).
+ */
+
+const readline = require("readline");
+const { TreeRenderer } = require("./dist/state/tree-renderer");
+const { createLogger } = require("./dist/utils/logger");
+
+// Create a silent logger to avoid console output
+const logger = createLogger({ level: "error" });
+
+// Create readline interface for line-by-line processing
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ terminal: false,
+});
+
+// Process each line as a separate request
+rl.on("line", (line) => {
+ try {
+ const data = JSON.parse(line);
+
+ // Create a fresh renderer for each request to avoid state leakage
+ const renderer = new TreeRenderer(logger);
+
+ // Apply the old tree first (initial state)
+ if (data.oldTree) {
+ renderer.applyUpdate(data.oldTree);
+ }
+
+ // Apply the diff
+ const result = renderer.applyUpdate(data.diff);
+
+ // Get the current tree state from renderer
+ const finalTree = renderer.getTreeState();
+
+ const output = {
+ html: result.html,
+ tree: finalTree,
+ error: null,
+ };
+
+ // Write response as single line
+ console.log(JSON.stringify(output));
+ } catch (err) {
+ const output = {
+ html: "",
+ tree: null,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ console.log(JSON.stringify(output));
+ }
+});
+
+// Handle stdin close
+rl.on("close", () => {
+ process.exit(0);
+});
+
+// Handle errors gracefully
+process.on("uncaughtException", (err) => {
+ console.error(JSON.stringify({ html: "", tree: null, error: err.message }));
+});
diff --git a/state/tree-renderer.ts b/state/tree-renderer.ts
index 3d82c56..0c904c4 100644
--- a/state/tree-renderer.ts
+++ b/state/tree-renderer.ts
@@ -350,10 +350,15 @@ export class TreeRenderer {
);
const changes = operation[2];
if (updateIndex >= 0 && changes) {
- currentItems[updateIndex] = {
- ...currentItems[updateIndex],
- ...changes,
- };
+ // Deep merge changes instead of shallow spread to preserve statics.
+ // When the server sends partial updates like {"5": {"0": "new text"}},
+ // we need to merge this into the existing item's field 5, not replace it.
+ // Shallow spread would lose the statics ({"s": [...]}) that the client has cached.
+ currentItems[updateIndex] = this.deepMergeTreeNodes(
+ currentItems[updateIndex],
+ changes,
+ `${statePath}.item`
+ );
}
break;
}
@@ -668,10 +673,13 @@ export class TreeRenderer {
);
const changes = operation[2];
if (updateIndex >= 0 && changes) {
- currentItems[updateIndex] = {
- ...currentItems[updateIndex],
- ...changes,
- };
+ // Deep merge changes instead of shallow spread to preserve statics.
+ // See comment in applyDifferentialOpsToRange for detailed explanation.
+ currentItems[updateIndex] = this.deepMergeTreeNodes(
+ currentItems[updateIndex],
+ changes,
+ `${statePath || ""}.item`
+ );
}
break;
}
From 900d977cd9e4733ec796b59a0263fd9646b24705 Mon Sep 17 00:00:00 2001
From: Adnaan
Date: Sun, 1 Feb 2026 07:12:21 +0100
Subject: [PATCH 3/5] fix: use stdout for uncaughtException to maintain JSON
protocol
The oracle server communicates via line-delimited JSON on stdout. Using
console.error() (stderr) for uncaught exceptions would break the protocol
since the Go client only reads from stdout.
Addresses review feedback on PR #20.
Co-Authored-By: Claude Opus 4.5
---
oracle-server.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/oracle-server.js b/oracle-server.js
index 934ddf1..58b38b9 100644
--- a/oracle-server.js
+++ b/oracle-server.js
@@ -69,7 +69,7 @@ rl.on("close", () => {
process.exit(0);
});
-// Handle errors gracefully
+// Handle errors gracefully - use stdout to maintain JSON protocol
process.on("uncaughtException", (err) => {
- console.error(JSON.stringify({ html: "", tree: null, error: err.message }));
+ console.log(JSON.stringify({ html: "", tree: null, error: err.message }));
});
From 8ef8f3cd3eada9e6b9894fea8d00a0a3468f8370 Mon Sep 17 00:00:00 2001
From: Adnaan
Date: Sun, 1 Feb 2026 16:55:28 +0100
Subject: [PATCH 4/5] fix: address PR review comments
- Add process.exit(1) after uncaughtException in oracle-server.js
to prevent continued operation with potentially corrupted state
- Add depth limit (50) to hasRangeAnywhere() as defensive measure
against deeply nested or malformed tree structures
Co-Authored-By: Claude Opus 4.5
---
oracle-server.js | 2 ++
state/tree-renderer.ts | 8 +++++---
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/oracle-server.js b/oracle-server.js
index 58b38b9..a752ee5 100644
--- a/oracle-server.js
+++ b/oracle-server.js
@@ -70,6 +70,8 @@ rl.on("close", () => {
});
// Handle errors gracefully - use stdout to maintain JSON protocol
+// Exit after uncaught exception as process state may be corrupted
process.on("uncaughtException", (err) => {
console.log(JSON.stringify({ html: "", tree: null, error: err.message }));
+ process.exit(1);
});
diff --git a/state/tree-renderer.ts b/state/tree-renderer.ts
index 0c904c4..483ef1a 100644
--- a/state/tree-renderer.ts
+++ b/state/tree-renderer.ts
@@ -53,8 +53,10 @@ function isRangeNode(node: any): boolean {
* @param node - The tree node to check
* @returns true if this node or any nested child has a range structure
*/
-function hasRangeAnywhere(node: any): boolean {
- if (node == null || typeof node !== "object" || Array.isArray(node)) {
+function hasRangeAnywhere(node: any, depth = 0): boolean {
+ // Depth limit to prevent potential stack overflow on deeply nested or malformed trees
+ const MAX_DEPTH = 50;
+ if (depth > MAX_DEPTH || node == null || typeof node !== "object" || Array.isArray(node)) {
return false;
}
@@ -68,7 +70,7 @@ function hasRangeAnywhere(node: any): boolean {
if (/^\d+$/.test(key)) {
const child = node[key];
if (child != null && typeof child === "object" && !Array.isArray(child)) {
- if (hasRangeAnywhere(child)) {
+ if (hasRangeAnywhere(child, depth + 1)) {
return true;
}
}
From 04edb69ac75dd8a85371073e89af38fa944aa168 Mon Sep 17 00:00:00 2001
From: Adnaan
Date: Sun, 1 Feb 2026 17:04:20 +0100
Subject: [PATCH 5/5] fix: address review comments
- Close readline interface before process.exit(1) in oracle-server.js
- Extract mergeRangeItem() helper to reduce duplication in update handlers
Co-Authored-By: Claude Opus 4.5
---
oracle-server.js | 1 +
state/tree-renderer.ts | 24 ++++++++++++++----------
2 files changed, 15 insertions(+), 10 deletions(-)
diff --git a/oracle-server.js b/oracle-server.js
index a752ee5..35ce685 100644
--- a/oracle-server.js
+++ b/oracle-server.js
@@ -73,5 +73,6 @@ rl.on("close", () => {
// Exit after uncaught exception as process state may be corrupted
process.on("uncaughtException", (err) => {
console.log(JSON.stringify({ html: "", tree: null, error: err.message }));
+ rl.close();
process.exit(1);
});
diff --git a/state/tree-renderer.ts b/state/tree-renderer.ts
index 483ef1a..7dc7274 100644
--- a/state/tree-renderer.ts
+++ b/state/tree-renderer.ts
@@ -352,14 +352,10 @@ export class TreeRenderer {
);
const changes = operation[2];
if (updateIndex >= 0 && changes) {
- // Deep merge changes instead of shallow spread to preserve statics.
- // When the server sends partial updates like {"5": {"0": "new text"}},
- // we need to merge this into the existing item's field 5, not replace it.
- // Shallow spread would lose the statics ({"s": [...]}) that the client has cached.
- currentItems[updateIndex] = this.deepMergeTreeNodes(
+ currentItems[updateIndex] = this.mergeRangeItem(
currentItems[updateIndex],
changes,
- `${statePath}.item`
+ statePath
);
}
break;
@@ -675,12 +671,10 @@ export class TreeRenderer {
);
const changes = operation[2];
if (updateIndex >= 0 && changes) {
- // Deep merge changes instead of shallow spread to preserve statics.
- // See comment in applyDifferentialOpsToRange for detailed explanation.
- currentItems[updateIndex] = this.deepMergeTreeNodes(
+ currentItems[updateIndex] = this.mergeRangeItem(
currentItems[updateIndex],
changes,
- `${statePath || ""}.item`
+ statePath || ""
);
}
break;
@@ -901,4 +895,14 @@ export class TreeRenderer {
(item: any) => this.getItemKey(item, statics, statePath) === key
);
}
+
+ /**
+ * Merges changes into a range item using deep merge to preserve statics.
+ * When the server sends partial updates like {"5": {"0": "new text"}},
+ * we need to merge this into the existing item's field 5, not replace it.
+ * Shallow spread would lose the statics ({"s": [...]}) that the client has cached.
+ */
+ private mergeRangeItem(item: any, changes: any, statePath: string): any {
+ return this.deepMergeTreeNodes(item, changes, `${statePath}.item`);
+ }
}