From 6cb42ef645a52dddac4c5822f811d75aeb382a50 Mon Sep 17 00:00:00 2001 From: Anish Gurjar <50738829+anishgurjar@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:24:52 -0400 Subject: [PATCH] fix(vars): compare array vars element-wise with Python semantics Array comparison operations (<, <=, >, >=) compiled to direct JS array comparison, which coerces arrays to comma-joined strings, so [1, 10, 0] < [1, 9, 0] evaluated true while Python compares element-wise and returns false. Compile the four ordering operations through a new compareArrays frontend helper that mirrors Python list semantics: pairwise element comparison, shorter-prefix ordering, recursive nested arrays, booleans as numbers (bool subclasses int), and NaN for NaN operands or incomparable element types so every ordering result is false. Refs #7654 --- .../.templates/web/utils/helpers/array.js | 51 +++++++++++++ .../src/reflex_base/vars/sequence.py | 31 +++++++- tests/units/test_var.py | 28 +++++++ tests/units/vars/array_helpers.test.mjs | 74 +++++++++++++++++++ .../units/vars/test_array_helpers_template.py | 33 +++++++++ 5 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js create mode 100644 tests/units/vars/array_helpers.test.mjs create mode 100644 tests/units/vars/test_array_helpers_template.py diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js new file mode 100644 index 00000000000..ecbd90963ed --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js @@ -0,0 +1,51 @@ +/** Compare two arrays element-wise with Python list semantics. + * + * Elements are compared pairwise in order; the first unequal pair decides + * the result. If every shared element is equal, the shorter array sorts + * before the longer one. Nested arrays are compared recursively. + * + * Booleans compare as numbers (0 or 1), matching Python where bool is a + * subclass of int, so [true] and [1] compare equal. + * + * Returns NaN when an element pair contains NaN (Python makes every + * ordering comparison against float("nan") false) or when elements are + * incomparable (mismatched or unsupported types, where Python raises + * TypeError). Either way every ordering result is false instead of a + * silent string comparison. + */ +export default function compareArrays(lhs, rhs) { + const sharedLength = Math.min(lhs.length, rhs.length); + for (let i = 0; i < sharedLength; i++) { + const left = lhs[i]; + const right = rhs[i]; + if (left === right) { + continue; + } + if (Array.isArray(left) && Array.isArray(right)) { + const nested = compareArrays(left, right); + if (nested !== 0) { + return nested; + } + continue; + } + const leftIsNumeric = typeof left === "number" || typeof left === "boolean"; + const rightIsNumeric = + typeof right === "number" || typeof right === "boolean"; + if (leftIsNumeric && rightIsNumeric) { + const leftNumber = Number(left); + const rightNumber = Number(right); + if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) { + return Number.NaN; + } + if (leftNumber === rightNumber) { + continue; + } + return leftNumber < rightNumber ? -1 : 1; + } + if (typeof left === "string" && typeof right === "string") { + return left < right ? -1 : 1; + } + return Number.NaN; + } + return Math.sign(lhs.length - rhs.length); +} diff --git a/packages/reflex-base/src/reflex_base/vars/sequence.py b/packages/reflex-base/src/reflex_base/vars/sequence.py index 1ded6c57ab1..516d940e82f 100644 --- a/packages/reflex-base/src/reflex_base/vars/sequence.py +++ b/packages/reflex-base/src/reflex_base/vars/sequence.py @@ -1810,6 +1810,13 @@ def array_reverse_operation( ) +_ARRAY_COMPARE_IMPORT: ImportDict = { + "$/utils/helpers/array.js": [ + ImportVar(tag="compareArrays", is_default=True, install=False) + ], +} + + @var_operation def array_lt_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tuple): """Check if an array is less than another array. @@ -1821,7 +1828,11 @@ def array_lt_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tupl Returns: The array less than operation. """ - return var_operation_return(js_expression=f"{lhs!s} < {rhs!s}", var_type=bool) + return var_operation_return( + js_expression=f"(compareArrays({lhs!s}, {rhs!s}) < 0)", + var_type=bool, + var_data=VarData(imports=_ARRAY_COMPARE_IMPORT), + ) @var_operation @@ -1835,7 +1846,11 @@ def array_gt_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tupl Returns: The array greater than operation. """ - return var_operation_return(js_expression=f"{lhs!s} > {rhs!s}", var_type=bool) + return var_operation_return( + js_expression=f"(compareArrays({lhs!s}, {rhs!s}) > 0)", + var_type=bool, + var_data=VarData(imports=_ARRAY_COMPARE_IMPORT), + ) @var_operation @@ -1849,7 +1864,11 @@ def array_le_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tupl Returns: The array less than or equal operation. """ - return var_operation_return(js_expression=f"{lhs!s} <= {rhs!s}", var_type=bool) + return var_operation_return( + js_expression=f"(compareArrays({lhs!s}, {rhs!s}) <= 0)", + var_type=bool, + var_data=VarData(imports=_ARRAY_COMPARE_IMPORT), + ) @var_operation @@ -1863,7 +1882,11 @@ def array_ge_operation(lhs: ArrayVar | list | tuple, rhs: ArrayVar | list | tupl Returns: The array greater than or equal operation. """ - return var_operation_return(js_expression=f"{lhs!s} >= {rhs!s}", var_type=bool) + return var_operation_return( + js_expression=f"(compareArrays({lhs!s}, {rhs!s}) >= 0)", + var_type=bool, + var_data=VarData(imports=_ARRAY_COMPARE_IMPORT), + ) @var_operation diff --git a/tests/units/test_var.py b/tests/units/test_var.py index fe0639d53ac..d8e634f265a 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -413,6 +413,34 @@ def test_datetime_comparison_preserves_microseconds(): ) +@pytest.mark.parametrize( + ("operation", "operator"), + [ + (op.lt, "<"), + (op.le, "<="), + (op.gt, ">"), + (op.ge, ">="), + ], +) +def test_array_comparison_uses_compare_arrays(operation, operator): + lhs = cast(ArrayVar[list[int]], v([1, 10, 0])) + + assert str(operation(lhs, [1, 9, 0])) == ( + f"(compareArrays([1, 10, 0], [1, 9, 0]) {operator} 0)" + ) + + +def test_array_comparison_imports_compare_arrays(): + lhs = cast(ArrayVar[list[int]], v([1, 10, 0])) + comparison = cast(Var, lhs < [1, 9, 0]) + var_data = comparison._get_all_var_data() + + assert var_data is not None + assert dict(var_data.imports)["$/utils/helpers/array.js"] == ( + ImportVar(tag="compareArrays", is_default=True, install=False), + ) + + def test_date_comparison_uses_compare_datetime(): lhs = cast(DateTimeVar, v(date(2024, 1, 1))) rhs = date(2024, 1, 2) diff --git a/tests/units/vars/array_helpers.test.mjs b/tests/units/vars/array_helpers.test.mjs new file mode 100644 index 00000000000..95a3c86ea7d --- /dev/null +++ b/tests/units/vars/array_helpers.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const source = await readFile( + new URL( + "../../../packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js", + import.meta.url, + ), + "utf8", +); + +/** Evaluate the actual frontend helper module and return its default export. */ +async function loadCompareArrays() { + const module = new vm.SourceTextModule(source, { + identifier: "array.js", + context: vm.createContext({}), + }); + await module.link(() => { + throw new Error("array.js must not have imports"); + }); + await module.evaluate(); + return module.namespace.default; +} + +const compareArrays = await loadCompareArrays(); + +test("numeric arrays compare element-wise like Python lists", () => { + assert.equal(compareArrays([10, 20], [5, 6]), 1); + assert.equal(compareArrays([2], [10]), -1); + assert.equal(compareArrays([1, 9], [1, 10]), -1); + assert.equal(compareArrays([1, 10, 0], [1, 9, 0]), 1); +}); + +test("equal arrays compare equal and prefix sorts shorter first", () => { + assert.equal(compareArrays([1, 2], [1, 2]), 0); + assert.equal(compareArrays([1, 2], [1, 2, 3]), -1); + assert.equal(compareArrays([1, 2, 3], [1, 2]), 1); + assert.equal(compareArrays([], []), 0); + assert.equal(compareArrays([], [1]), -1); +}); + +test("strings and booleans keep Python ordering", () => { + assert.equal(compareArrays(["a", "c"], ["a", "b"]), 1); + assert.equal(compareArrays([true], [false]), 1); + assert.equal(compareArrays([1, "a"], [1, "b"]), -1); +}); + +test("nested arrays compare recursively", () => { + assert.equal(compareArrays([[1, 2], [3]], [[1, 2], [2]]), 1); + assert.equal(compareArrays([[1, 2]], [[1, 2]]), 0); + assert.equal(compareArrays([[1]], [[1, 2]]), -1); +}); + +test("incomparable elements give NaN so ordering is false", () => { + assert.ok(Number.isNaN(compareArrays([1, "a"], [1, 2]))); + assert.ok(Number.isNaN(compareArrays([null], [1]))); + assert.ok(Number.isNaN(compareArrays([[1], 2], [[1], [2]]))); +}); + +test("NaN elements make every ordering comparison false like Python", () => { + assert.ok(Number.isNaN(compareArrays([NaN], [1]))); + assert.ok(Number.isNaN(compareArrays([NaN], [NaN]))); + assert.ok(Number.isNaN(compareArrays([1, NaN], [1, 2]))); +}); + +test("booleans compare numerically like Python bool subclassing int", () => { + assert.equal(compareArrays([true], [1]), 0); + assert.equal(compareArrays([true], [2]), -1); + assert.equal(compareArrays([false], [1]), -1); + assert.equal(compareArrays([0.5], [true]), -1); + assert.equal(compareArrays([1], [true]), 0); +}); diff --git a/tests/units/vars/test_array_helpers_template.py b/tests/units/vars/test_array_helpers_template.py new file mode 100644 index 00000000000..8674ea3b0b8 --- /dev/null +++ b/tests/units/vars/test_array_helpers_template.py @@ -0,0 +1,33 @@ +"""Regression tests for the array.js comparison helper.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +ARRAY_HELPERS_TEMPLATE = ( + Path(__file__).parents[3] + / "packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js" +) + + +def test_compare_arrays_matches_python_list_semantics() -> None: + """Execute the helper behavior tests against the real template module.""" + assert ARRAY_HELPERS_TEMPLATE.is_file() + node = shutil.which("node") + if node is None: + pytest.skip("Node.js is required for the frontend runtime tests") + return + result = subprocess.run( + [ + node, + "--experimental-vm-modules", + str(Path(__file__).with_name("array_helpers.test.mjs")), + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr