Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This numeric path does not preserve Python’s arbitrary-precision integer ordering: values above Number.MAX_SAFE_INTEGER have already collapsed to the same JavaScript Number before compareArrays compares them. Preserve integer precision in the frontend representation or explicitly reject/document this unsupported range so the helper does not claim Python list semantics for it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/array.js, line 35:

<comment>This numeric path does not preserve Python’s arbitrary-precision integer ordering: values above `Number.MAX_SAFE_INTEGER` have already collapsed to the same JavaScript `Number` before `compareArrays` compares them. Preserve integer precision in the frontend representation or explicitly reject/document this unsupported range so the helper does not claim Python list semantics for it.</comment>

<file context>
@@ -0,0 +1,51 @@
+    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)) {
</file context>

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);
}
31 changes: 27 additions & 4 deletions packages/reflex-base/src/reflex_base/vars/sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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),
Comment on lines +1831 to +1834

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Release notes omit this fix

This user-facing array ordering change has no bugfix fragment under packages/reflex-base/news/. CLAUDE.md requires a fragment for each affected package. Add a short entry explaining that arrays now compare element by element instead of as comma-joined strings. This repository requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

)


@var_operation
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/units/test_var.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests miss the package layout

The new array comparison tests cover reflex_base.vars.sequence but sit in the top-level test_var.py. CLAUDE.md requires subpackage unit tests to include the corresponding package path. Move these tests under tests/units/reflex_base/vars/ before merging to satisfy that requirement.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests omit required docstrings

Neither test_array_comparison_uses_compare_arrays nor test_array_comparison_imports_compare_arrays has a docstring. CLAUDE.md requires Google-style docstrings on all functions. Add a one-line summary to each test before merging to satisfy that requirement.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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)
Expand Down
74 changes: 74 additions & 0 deletions tests/units/vars/array_helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
33 changes: 33 additions & 0 deletions tests/units/vars/test_array_helpers_template.py
Original file line number Diff line number Diff line change
@@ -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