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
2 changes: 2 additions & 0 deletions docs/vars/var-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,8 @@ def var_string_example():
)
```

An optional string var (`str | None`) holding `None` acts as an empty string in the string methods (`lower`, `upper`, `title`, `capitalize`, `strip`, `split`, `contains`, `startswith`, `endswith`, `replace`, `length`, indexing, slicing and `*`): each gives what it gives for `""`. For example, `lower` gives an empty string, `contains("a")` is false, `length` is 0 and `split(" ")` gives `[""]`. Concatenation with `+` and the comparison operators are not covered.

## Get Item (Indexing)

Indexing is only supported for strings, lists, tuples, dicts, and dataframes. To index into a state var strict type annotations are required.
Expand Down
1 change: 1 addition & 0 deletions packages/reflex-base/news/+optional-str-none.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Methods of an optional string var (`str | None`) holding `None` no longer crash the page or the production prerender: the string acts as an empty string, so each method gives what it gives for `""`. For example, `lower` gives an empty string, `contains("a")` is false and `split(" ")` gives `[""]`. Before, the methods compiled to a native string method (`split` with a separator, `strip`, `lower`, `upper`, `contains`, ...) threw `Cannot read properties of null`, while `length`, indexing and slicing already read `None` as empty. Concatenation with `+` and the comparison operators are unchanged.
59 changes: 44 additions & 15 deletions packages/reflex-base/src/reflex_base/vars/sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,18 @@ def create(
class StringVar(Var[STRING_TYPE], python_types=str):
"""Base class for immutable string vars."""

def _or_empty(self) -> StringVar[Any]:

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: Concatenation is still unguarded, so None + "x" renders as "nullx" in the browser. The new docs and news entry say an optional string holding None acts as "" in every string operation, so guard __add__ and __radd__ with _or_empty() as the array __add__ does. The ordering comparisons (<, >, <=, >=) have the same gap, so either guard them or narrow the docs claim.

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/vars/sequence.py, line 786:

<comment>Concatenation is still unguarded, so `None + "x"` renders as `"nullx"` in the browser. The new docs and news entry say an optional string holding `None` acts as `""` in every string operation, so guard `__add__` and `__radd__` with `_or_empty()` as the array `__add__` does. The ordering comparisons (`<`, `>`, `<=`, `>=`) have the same gap, so either guard them or narrow the docs claim.</comment>

<file context>
@@ -783,6 +783,16 @@ def create(
 class StringVar(Var[STRING_TYPE], python_types=str):
     """Base class for immutable string vars."""
 
+    def _or_empty(self) -> StringVar[Any]:
+        """View an optional string as an empty string while it holds None.
+
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Narrowed the claim instead of guarding + (cbd9751): the docs and the news fragment now say the string methods read None as "", and name + and the comparison operators as not covered.

I left + alone on purpose. An f-string (f"{S.m} x") builds the same concatenation without going through __add__, so guarding __add__ / __radd__ would make S.m + " x" and f"{S.m} x" render differently for the same value. Neither throws, so it is outside what #7873 is about (operations that crash the page); changing what a None renders as in concatenation is a separate decision for both forms together.


Generated by Claude Code

"""View an optional string as an empty string while it holds None.

Returns:
The string, guarded against None if its type allows None.
"""
var_type = self._var_type
# Most strings are plain ``str``: skip the union check for them.
if var_type is str or not types.is_optional(var_type):
return self
return string_or_empty_operation(self)

def __add__(self, other: StringVar | str) -> ConcatVarOperation:
"""Concatenate two strings.

Expand Down Expand Up @@ -825,7 +837,7 @@ def __mul__(self, other: NumberVar | int) -> StringVar:
):
raise_unsupported_operand_types("*", (type(self), type(other)))

return string_repeat_operation(self, other)
return string_repeat_operation(self._or_empty(), other)

def __rmul__(self, other: NumberVar | int) -> StringVar:
"""Multiply the sequence by a number or an integer.
Expand All @@ -841,7 +853,7 @@ def __rmul__(self, other: NumberVar | int) -> StringVar:
):
raise_unsupported_operand_types("*", (type(other), type(self)))

return string_repeat_operation(self, other)
return string_repeat_operation(self._or_empty(), other)

@overload
def __getitem__(self, i: slice) -> StringVar: ...
Expand Down Expand Up @@ -882,7 +894,7 @@ def lower(self) -> StringVar:
Returns:
The string lower operation.
"""
return string_lower_operation(self)
return string_lower_operation(self._or_empty())

def lstrip(self, chars: StringVar | str | None = None) -> StringVar:
"""Left strip the string.
Expand All @@ -896,31 +908,31 @@ def lstrip(self, chars: StringVar | str | None = None) -> StringVar:
if chars is not None and not isinstance(chars, (StringVar, str)):
raise_unsupported_operand_types("lstrip", (type(self), type(chars)))

return string_lstrip_operation(self, chars)
return string_lstrip_operation(self._or_empty(), chars)

def upper(self) -> StringVar:
"""Convert the string to uppercase.

Returns:
The string upper operation.
"""
return string_upper_operation(self)
return string_upper_operation(self._or_empty())

def title(self) -> StringVar:
"""Convert the string to title case.

Returns:
The string title operation.
"""
return string_title_operation(self)
return string_title_operation(self._or_empty())

def capitalize(self) -> StringVar:
"""Capitalize the string.

Returns:
The string capitalize operation.
"""
return string_capitalize_operation(self)
return string_capitalize_operation(self._or_empty())

def strip(self, chars: StringVar | str | None = None) -> StringVar:
"""Strip the string.
Expand All @@ -934,7 +946,7 @@ def strip(self, chars: StringVar | str | None = None) -> StringVar:
if chars is not None and not isinstance(chars, (StringVar, str)):
raise_unsupported_operand_types("strip", (type(self), type(chars)))

return string_strip_operation(self, chars)
return string_strip_operation(self._or_empty(), chars)

def reversed(self) -> StringVar:
"""Reverse the string.
Expand All @@ -956,7 +968,7 @@ def rstrip(self, chars: StringVar | str | None = None) -> StringVar:
if chars is not None and not isinstance(chars, (StringVar, str)):
raise_unsupported_operand_types("rstrip", (type(self), type(chars)))

return string_rstrip_operation(self, chars)
return string_rstrip_operation(self._or_empty(), chars)

def contains(
self, other: StringVar | str, field: StringVar | str | None = None
Expand All @@ -976,7 +988,7 @@ def contains(
if not isinstance(field, (StringVar, str)):
raise_unsupported_operand_types("contains", (type(self), type(field)))
return string_contains_field_operation(self, other, field)
return string_contains_operation(self, other)
return string_contains_operation(self._or_empty(), other)

def split(self, separator: StringVar | str = "") -> ArrayVar[list[str]]:
"""Split the string.
Expand All @@ -1002,7 +1014,7 @@ def startswith(self, prefix: StringVar | str) -> BooleanVar:
"""
if not isinstance(prefix, (StringVar, str)):
raise_unsupported_operand_types("startswith", (type(self), type(prefix)))
return string_starts_with_operation(self, prefix)
return string_starts_with_operation(self._or_empty(), prefix)

def endswith(self, suffix: StringVar | str) -> BooleanVar:
"""Check if the string ends with a suffix.
Expand All @@ -1015,7 +1027,7 @@ def endswith(self, suffix: StringVar | str) -> BooleanVar:
"""
if not isinstance(suffix, (StringVar, str)):
raise_unsupported_operand_types("endswith", (type(self), type(suffix)))
return string_ends_with_operation(self, suffix)
return string_ends_with_operation(self._or_empty(), suffix)

def __lt__(self, other: StringVar | str) -> BooleanVar:
"""Check if the string is less than another string.
Expand Down Expand Up @@ -1098,7 +1110,7 @@ def replace(self, search_value: Any, new_value: Any) -> StringVar: # pyright: i
if not isinstance(new_value, (StringVar, str)):
raise_unsupported_operand_types("replace", (type(self), type(new_value)))

return string_replace_operation(self, search_value, new_value)
return string_replace_operation(self._or_empty(), search_value, new_value)


@var_operation
Expand Down Expand Up @@ -1157,6 +1169,22 @@ def string_ge_operation(lhs: StringVar[Any] | str, rhs: StringVar[Any] | str):
return var_operation_return(js_expression=f"{lhs!s} >= {rhs!s}", var_type=bool)


@var_operation
def string_or_empty_operation(string: Var) -> CustomVarOperationReturn[str]:
"""View an optional string as an empty string while it holds None.

Args:
string: The optional string.

Returns:
The guarded string operation, typed without None.
"""
return var_operation_return(
js_expression=f'({string!s} ?? "")',
var_type=types.value_inside_optional(string._var_type),
)


@var_operation
def string_lower_operation(string: StringVar[Any]):
"""Convert a string to lowercase.
Expand Down Expand Up @@ -1771,13 +1799,14 @@ def string_split_operation(string: StringVar[Any], sep: StringVar | str = ""):
The split string. An empty separator splits by code points.
"""
if not isinstance(sep, LiteralStringVar):
js_expression = f"pyStrSplit({string!s}, {sep!s})"
js_expression = f"pyStrSplit({string._or_empty()!s}, {sep!s})"
var_data = VarData(imports=_PY_STR_SPLIT_IMPORT)
elif sep._var_value == "":
# pyStrChars reads null as an empty string itself.
js_expression = f"pyStrChars({string!s})"
var_data = VarData(imports=_PY_STR_CHARS_IMPORT)
else:
js_expression = f"{string!s}.split({sep!s})"
js_expression = f"{string._or_empty()!s}.split({sep!s})"
var_data = None
return var_operation_return(
js_expression=js_expression, var_type=list[str], var_data=var_data
Expand Down
46 changes: 46 additions & 0 deletions tests/integration/tests_playwright/test_var_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class VarOperationState(rx.State):
optional_dict: rx.Field[dict[str, str] | None] = rx.field(None)
optional_list_value: rx.Field[list[str] | None] = rx.field(["red", "yellow"])
optional_dict_value: rx.Field[dict[str, str] | None] = rx.field({"name": "red"})
optional_str: rx.Field[str | None] = rx.field(None)
optional_str_value: rx.Field[str | None] = rx.field("Some Text")
str_var1: rx.Field[str] = rx.field("first")
str_var2: rx.Field[str] = rx.field("second")
str_var3: rx.Field[str] = rx.field("ThIrD")
Expand Down Expand Up @@ -823,6 +825,40 @@ def index():
rx.foreach(VarOperationState.optional_list_value, rx.text.span),
id="optional_list_value",
),
# Operations on an optional string holding None read it as empty.
rx.text(VarOperationState.optional_str.lower(), id="optional_str_lower"),
rx.text(VarOperationState.optional_str.strip(), id="optional_str_strip"),
rx.text(
VarOperationState.optional_str.split(" ").to_string(),
id="optional_str_split",
),
rx.text(
VarOperationState.optional_str.split(
VarOperationState.strip_chars
).to_string(),
id="optional_str_split_var",
),
rx.text(
VarOperationState.optional_str.contains("a"),
id="optional_str_contains",
),
rx.text(
VarOperationState.optional_str.startswith("a"),
id="optional_str_startswith",
),
rx.text(
VarOperationState.optional_str.replace("a", "b"),
id="optional_str_replace",
),
rx.text(VarOperationState.optional_str * 2, id="optional_str_repeat"),
rx.text(
VarOperationState.optional_str_value.upper(),
id="optional_str_value_upper",
),
rx.text(
VarOperationState.optional_str_value.split(" ").to_string(),
id="optional_str_value_split",
),
rx.box(
rx.foreach(VarOperationState.optional_dict_value, rx.text.span),
id="optional_dict_value",
Expand Down Expand Up @@ -1160,6 +1196,16 @@ def test_var_operations(driver, var_operations: AppHarness):
("optional_list_join", ""),
("optional_list_value_length", "2"),
("optional_list_value_contains", "true"),
("optional_str_lower", ""),
("optional_str_strip", ""),
("optional_str_split", '[""]'),
("optional_str_split_var", '[""]'),
("optional_str_contains", "false"),
("optional_str_startswith", "false"),
("optional_str_replace", ""),
("optional_str_repeat", ""),
("optional_str_value_upper", "SOME TEXT"),
("optional_str_value_split", '["Some","Text"]'),
]

for tag, expected in tests:
Expand Down
70 changes: 69 additions & 1 deletion tests/units/reflex_base/vars/test_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import pytest
from reflex_base.vars.base import Var
from reflex_base.vars.sequence import ArrayVar
from reflex_base.vars.sequence import ArrayVar, StringVar


def _array(var_type: Any, js_expr: str = "state.maybe") -> ArrayVar:
Expand Down Expand Up @@ -62,3 +62,71 @@ def test_optional_array_operation_results():
# Indexing is already null-safe, and the var itself still reads as None.
assert str(maybe[0]) == "state.maybe?.at?.(0)"
assert str(maybe.is_none()) == "!(isNotNullOrUndefined(state.maybe))"


def _string(var_type: Any, js_expr: str = "state.maybe") -> StringVar:
var = Var(_js_expr=js_expr, _var_type=var_type).guess_type()
assert isinstance(var, StringVar)
return var


_SEP = Var(_js_expr="state.sep").to(str)

# Each operation with the JS it compiles to, given the JS of the string it reads.
_STRING_OPERATIONS: list[tuple[Callable[[StringVar], Var], str]] = [
(lambda string: string.lower(), "{}.toLowerCase()"),
(lambda string: string.upper(), "{}.toUpperCase()"),
(
lambda string: string.title(),
(
"{}.split(' ').map(word => word.charAt(0).toUpperCase()"
" + word.slice(1).toLowerCase()).join(' ')"
),
),
(
lambda string: string.capitalize(),
"(((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())({}))",
),
(lambda string: string.strip(), "pyStrip({}, null)"),
(lambda string: string.lstrip("-"), 'pyLstrip({}, "-")'),
(lambda string: string.rstrip(_SEP), "pyRstrip({}, state.sep)"),
(lambda string: string.contains("x"), '{}.includes("x")'),
(lambda string: string.split(" "), '{}.split(" ")'),
(lambda string: string.split(_SEP), "pyStrSplit({}, state.sep)"),
(lambda string: string.startswith("x"), '{}.startsWith("x")'),
(lambda string: string.endswith("x"), '{}.endsWith("x")'),
(lambda string: string.replace("x", "y"), '{}.replaceAll("x", "y")'),
(lambda string: string * 2, "{}.repeat(Math.max(0, 2))"),
(lambda string: 2 * string, "{}.repeat(Math.max(0, 2))"),
]


@pytest.mark.parametrize(("operation", "expected"), _STRING_OPERATIONS)
def test_optional_string_operations_treat_none_as_empty(
operation: Callable[[StringVar], Var], expected: str
):
"""Every operation on an optional string reads a null value as an empty string."""
assert str(operation(_string(str | None))) == expected.format('(state.maybe ?? "")')


@pytest.mark.parametrize(("operation", "expected"), _STRING_OPERATIONS)
def test_string_operations_do_not_guard_a_required_string(
operation: Callable[[StringVar], Var], expected: str
):
"""A string that cannot be None compiles without the null guard."""
assert str(operation(_string(str))) == expected.format("state.maybe")


def test_optional_string_operation_results():
"""The null guard drops None from the result type; null-safe helpers need none."""
maybe = _string(str | None)
assert maybe.lower()._var_type is str
assert maybe.split(" ")._var_type == list[str]
# The code point helpers already read null as an empty string.
assert str(maybe.length()) == "pyStrLength(state.maybe)"
assert str(maybe[0]) == "pyStrAt(state.maybe, 0)"
assert str(maybe[1:]) == "pyStrSlice(state.maybe, 1, null)"
assert str(maybe.split()) == "pyStrChars(state.maybe)"
assert str(maybe.reversed()) == 'pyStrChars(state.maybe).slice().reverse().join("")'
# The var itself still reads as None.
assert str(maybe.is_none()) == "!(isNotNullOrUndefined(state.maybe))"
Loading