From 7ffd27551c230eb4b95778226540d80a6b074afb Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 25 Jan 2026 17:54:05 +0100 Subject: [PATCH 1/3] fix: preserve large integers as strings to prevent precision loss JavaScript's Number.MAX_SAFE_INTEGER is 2^53-1 (9007199254740991). UnixNano timestamps exceed this, causing precision loss when converted to float64. This fix keeps large integers as strings, allowing the server-side GetStringOk to handle them correctly. Co-Authored-By: Claude Opus 4.5 --- livetemplate-client.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/livetemplate-client.ts b/livetemplate-client.ts index c539102..99bc44d 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -515,9 +515,15 @@ export class LiveTemplateClient { * @returns Parsed value with correct type */ private parseValue(value: string): any { - // Try to parse as number + // Try to parse as number, but only if it's safe (won't lose precision) const num = parseFloat(value); if (!isNaN(num) && value.trim() === num.toString()) { + // Check if the number is within JavaScript's safe integer range + // Large integers (like UnixNano timestamps) lose precision as float64 + if (Number.isInteger(num) && Math.abs(num) > Number.MAX_SAFE_INTEGER) { + // Keep as string to preserve precision + return value; + } return num; } From d3a3fae3f876767950bc2aa20ab0cf6cb93ba106 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 25 Jan 2026 18:31:02 +0100 Subject: [PATCH 2/3] fix: address Copilot review comments - Trim whitespace once at the start of parseValue for consistency - Add comprehensive unit tests for large integer handling Tests verify: - Values at/under MAX_SAFE_INTEGER parse to numbers - Values over MAX_SAFE_INTEGER stay as strings - Whitespace handling is consistent Co-Authored-By: Claude Opus 4.5 --- livetemplate-client.ts | 11 +-- tests/parse-value.test.ts | 140 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 tests/parse-value.test.ts diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 99bc44d..bb42a69 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -515,14 +515,17 @@ export class LiveTemplateClient { * @returns Parsed value with correct type */ private parseValue(value: string): any { + // Trim once for consistent handling + const trimmed = value.trim(); + // Try to parse as number, but only if it's safe (won't lose precision) - const num = parseFloat(value); - if (!isNaN(num) && value.trim() === num.toString()) { + const num = parseFloat(trimmed); + if (!isNaN(num) && trimmed === num.toString()) { // Check if the number is within JavaScript's safe integer range // Large integers (like UnixNano timestamps) lose precision as float64 if (Number.isInteger(num) && Math.abs(num) > Number.MAX_SAFE_INTEGER) { - // Keep as string to preserve precision - return value; + // Keep as string (trimmed) to preserve precision while matching server expectations + return trimmed; } return num; } diff --git a/tests/parse-value.test.ts b/tests/parse-value.test.ts new file mode 100644 index 0000000..4a509f1 --- /dev/null +++ b/tests/parse-value.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for the parseValue function in LiveTemplateClient + * + * These tests verify that: + * 1. Values at or under Number.MAX_SAFE_INTEGER parse to numbers + * 2. Values over Number.MAX_SAFE_INTEGER remain as strings (to prevent precision loss) + * 3. Whitespace is handled consistently (trimmed before parsing) + */ + +import { LiveTemplateClient } from "../livetemplate-client"; + +describe("LiveTemplateClient.parseValue", () => { + let client: LiveTemplateClient; + + beforeEach(() => { + // Create a minimal wrapper element for the client using safe DOM methods + const wrapper = document.createElement("div"); + wrapper.id = "test-wrapper"; + wrapper.setAttribute("data-lvt-id", "test"); + document.body.appendChild(wrapper); + client = new LiveTemplateClient(); + }); + + afterEach(() => { + const wrapper = document.getElementById("test-wrapper"); + if (wrapper) { + wrapper.remove(); + } + }); + + // Access parseValue through a test helper since it's private + // We'll use (client as any) to access the private method for testing + const parseValue = (value: string) => (client as any).parseValue(value); + + describe("numbers within safe integer range", () => { + it("parses small positive integers as numbers", () => { + expect(parseValue("42")).toBe(42); + expect(parseValue("0")).toBe(0); + expect(parseValue("1")).toBe(1); + }); + + it("parses small negative integers as numbers", () => { + expect(parseValue("-1")).toBe(-1); + expect(parseValue("-42")).toBe(-42); + }); + + it("parses floating point numbers as numbers", () => { + expect(parseValue("3.14")).toBe(3.14); + expect(parseValue("-2.5")).toBe(-2.5); + }); + + it("parses MAX_SAFE_INTEGER as a number", () => { + const maxSafe = String(Number.MAX_SAFE_INTEGER); // "9007199254740991" + expect(parseValue(maxSafe)).toBe(Number.MAX_SAFE_INTEGER); + }); + + it("parses MIN_SAFE_INTEGER as a number", () => { + const minSafe = String(Number.MIN_SAFE_INTEGER); // "-9007199254740991" + expect(parseValue(minSafe)).toBe(Number.MIN_SAFE_INTEGER); + }); + }); + + describe("large integers (exceeding MAX_SAFE_INTEGER)", () => { + it("keeps integers larger than MAX_SAFE_INTEGER as strings", () => { + // UnixNano timestamp example: larger than MAX_SAFE_INTEGER + const largeInt = "1769358878696557000"; + const result = parseValue(largeInt); + expect(result).toBe(largeInt); + expect(typeof result).toBe("string"); + }); + + it("keeps MAX_SAFE_INTEGER + 1 as a string", () => { + const justOver = String(Number.MAX_SAFE_INTEGER + 1); // "9007199254740992" + const result = parseValue(justOver); + expect(typeof result).toBe("string"); + }); + + it("keeps large negative integers as strings", () => { + const largeNegative = "-9007199254740992"; // MIN_SAFE_INTEGER - 1 + const result = parseValue(largeNegative); + expect(typeof result).toBe("string"); + }); + + it("preserves exact string value for large integers (no precision loss)", () => { + // This is the key test: ensuring the exact value is preserved + const original = "1769358878696557000"; + const result = parseValue(original); + expect(result).toBe("1769358878696557000"); + // If we had converted to number and back, we'd get a different value due to precision loss + expect(result).not.toBe("1769358878696557056"); // What it would be if converted to float64 + }); + }); + + describe("whitespace handling", () => { + it("trims whitespace from numeric strings", () => { + expect(parseValue(" 42 ")).toBe(42); + expect(parseValue("\t100\n")).toBe(100); + }); + + it("trims whitespace from large integer strings", () => { + const withSpaces = " 1769358878696557000 "; + const result = parseValue(withSpaces); + expect(result).toBe("1769358878696557000"); // Trimmed + expect(typeof result).toBe("string"); + }); + + it("trims whitespace from boolean strings", () => { + expect(parseValue(" true ")).toBe(" true "); // Boolean parsing uses exact match + expect(parseValue("true")).toBe(true); + }); + }); + + describe("boolean values", () => { + it("parses 'true' as boolean true", () => { + expect(parseValue("true")).toBe(true); + }); + + it("parses 'false' as boolean false", () => { + expect(parseValue("false")).toBe(false); + }); + + it("does not parse 'True' or 'FALSE' as booleans", () => { + expect(parseValue("True")).toBe("True"); + expect(parseValue("FALSE")).toBe("FALSE"); + }); + }); + + describe("string values", () => { + it("returns non-numeric strings as-is", () => { + expect(parseValue("hello")).toBe("hello"); + expect(parseValue("")).toBe(""); + expect(parseValue("foo123")).toBe("foo123"); + }); + + it("returns strings that look numeric but are not as strings", () => { + expect(parseValue("12abc")).toBe("12abc"); + expect(parseValue("3.14.15")).toBe("3.14.15"); + }); + }); +}); From bba3e7988fb1fe57c7f525ee581b39967c214599 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Mon, 26 Jan 2026 07:59:25 +0100 Subject: [PATCH 3/3] fix: check MAX_SAFE_INTEGER range before string equality comparison The previous logic had a critical bug: the MAX_SAFE_INTEGER check was inside a condition that already fails for large integers due to precision loss during parseFloat(). For example: - parseFloat("1769358878696557000") returns 1769358878696557056 - "1769358878696557000" === "1769358878696557056" is false - So the outer if block never executed and MAX_SAFE_INTEGER was unreachable The fix moves the range check BEFORE the string equality check, ensuring large integers are correctly identified and preserved as strings. Co-Authored-By: Claude Opus 4.5 --- livetemplate-client.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/livetemplate-client.ts b/livetemplate-client.ts index bb42a69..c6cf4bd 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -518,16 +518,19 @@ export class LiveTemplateClient { // Trim once for consistent handling const trimmed = value.trim(); - // Try to parse as number, but only if it's safe (won't lose precision) + // Try to parse as number const num = parseFloat(trimmed); - if (!isNaN(num) && trimmed === num.toString()) { - // Check if the number is within JavaScript's safe integer range - // Large integers (like UnixNano timestamps) lose precision as float64 + if (!isNaN(num)) { + // Check range FIRST - large integers (like UnixNano timestamps) must stay as strings + // to preserve precision. JavaScript's Number can only safely represent integers + // up to 2^53-1 (MAX_SAFE_INTEGER = 9,007,199,254,740,991). if (Number.isInteger(num) && Math.abs(num) > Number.MAX_SAFE_INTEGER) { - // Keep as string (trimmed) to preserve precision while matching server expectations return trimmed; } - return num; + // Only convert to number if string representation matches (no precision loss) + if (trimmed === num.toString()) { + return num; + } } // Try to parse as boolean