From 48c504666eef1b954f92c110eb239befdad90aae Mon Sep 17 00:00:00 2001 From: benedikt-bartscher <31854409+benedikt-bartscher@users.noreply.github.com> Date: Thu, 16 May 2024 00:01:14 +0200 Subject: [PATCH 1/5] properly replace ComputedVars (#3254) * properly replace ComputedVars * provide dummy override decorator for older python versions * adjust pyi * fix darglint * cleanup var_data for computed vars inherited from mixin --------- Co-authored-by: Masen Furer --- reflex/state.py | 4 +++- reflex/utils/types.py | 16 ++++++++++++++++ reflex/vars.py | 40 ++++++++++++++++++++++++++++++++++------ reflex/vars.pyi | 1 + 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/reflex/state.py b/reflex/state.py index 287f70073aa..86a222b6661 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -550,7 +550,9 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): for name, value in mixin.__dict__.items(): if isinstance(value, ComputedVar): fget = cls._copy_fn(value.fget) - newcv = ComputedVar(fget=fget, _var_name=value._var_name) + newcv = value._replace(fget=fget) + # cleanup refs to mixin cls in var_data + newcv._var_data = None newcv._var_set_state(cls) setattr(cls, name, newcv) cls.computed_vars[newcv._var_name] = newcv diff --git a/reflex/utils/types.py b/reflex/utils/types.py index f75e20dcc49..6dd120e3dd5 100644 --- a/reflex/utils/types.py +++ b/reflex/utils/types.py @@ -44,6 +44,22 @@ from reflex.base import Base from reflex.utils import console, serializers +if sys.version_info >= (3, 12): + from typing import override +else: + + def override(func: Callable) -> Callable: + """Fallback for @override decorator. + + Args: + func: The function to decorate. + + Returns: + The unmodified function. + """ + return func + + # Potential GenericAlias types for isinstance checks. GenericAliasTypes = [_GenericAlias] diff --git a/reflex/vars.py b/reflex/vars.py index cccef95eb2c..be6aa7eb891 100644 --- a/reflex/vars.py +++ b/reflex/vars.py @@ -39,10 +39,12 @@ # This module used to export ImportVar itself, so we still import it for export here from reflex.utils.imports import ImportDict, ImportVar +from reflex.utils.types import override if TYPE_CHECKING: from reflex.state import BaseState + # Set of unique variable names. USED_VARIABLES = set() @@ -832,19 +834,19 @@ def get_operand_full_name(operand): if invoke_fn: # invoke the function on left operand. operation_name = ( - f"{left_operand_full_name}.{fn}({right_operand_full_name})" - ) # type: ignore + f"{left_operand_full_name}.{fn}({right_operand_full_name})" # type: ignore + ) else: # pass the operands as arguments to the function. operation_name = ( - f"{left_operand_full_name} {op} {right_operand_full_name}" - ) # type: ignore + f"{left_operand_full_name} {op} {right_operand_full_name}" # type: ignore + ) operation_name = f"{fn}({operation_name})" else: # apply operator to operands (left operand right_operand) operation_name = ( - f"{left_operand_full_name} {op} {right_operand_full_name}" - ) # type: ignore + f"{left_operand_full_name} {op} {right_operand_full_name}" # type: ignore + ) operation_name = format.wrap(operation_name, "(") else: # apply operator to left operand ( left_operand) @@ -1882,6 +1884,32 @@ def __init__( kwargs["_var_type"] = kwargs.pop("_var_type", self._determine_var_type()) BaseVar.__init__(self, **kwargs) # type: ignore + @override + def _replace(self, merge_var_data=None, **kwargs: Any) -> ComputedVar: + """Replace the attributes of the ComputedVar. + + Args: + merge_var_data: VarData to merge into the existing VarData. + **kwargs: Var fields to update. + + Returns: + The new ComputedVar instance. + """ + return ComputedVar( + fget=kwargs.get("fget", self.fget), + initial_value=kwargs.get("initial_value", self._initial_value), + cache=kwargs.get("cache", self._cache), + _var_name=kwargs.get("_var_name", self._var_name), + _var_type=kwargs.get("_var_type", self._var_type), + _var_is_local=kwargs.get("_var_is_local", self._var_is_local), + _var_is_string=kwargs.get("_var_is_string", self._var_is_string), + _var_full_name_needs_state_prefix=kwargs.get( + "_var_full_name_needs_state_prefix", + self._var_full_name_needs_state_prefix, + ), + _var_data=VarData.merge(self._var_data, merge_var_data), + ) + @property def _cache_attr(self) -> str: """Get the attribute used to cache the value on the instance. diff --git a/reflex/vars.pyi b/reflex/vars.pyi index 8251563f86d..169e2d919c0 100644 --- a/reflex/vars.pyi +++ b/reflex/vars.pyi @@ -139,6 +139,7 @@ class ComputedVar(Var): def _cache_attr(self) -> str: ... def __get__(self, instance, owner): ... def _deps(self, objclass: Type, obj: Optional[FunctionType] = ...) -> Set[str]: ... + def _replace(self, merge_var_data=None, **kwargs: Any) -> ComputedVar: ... def mark_dirty(self, instance) -> None: ... def _determine_var_type(self) -> Type: ... @overload From 47043ae7872f8a7600addde5f999931274b39c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Brand=C3=A9ho?= Date: Thu, 16 May 2024 02:59:23 +0200 Subject: [PATCH 2/5] throw error for componentstate in foreach (#3243) --- reflex/components/core/foreach.py | 11 ++++++++++ tests/components/core/test_foreach.py | 31 ++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/reflex/components/core/foreach.py b/reflex/components/core/foreach.py index 88f2886a8a6..9a6765491d2 100644 --- a/reflex/components/core/foreach.py +++ b/reflex/components/core/foreach.py @@ -8,6 +8,7 @@ from reflex.components.component import Component from reflex.components.tags import IterTag from reflex.constants import MemoizationMode +from reflex.state import ComponentState from reflex.utils import console from reflex.vars import Var @@ -50,6 +51,7 @@ def create( Raises: ForeachVarError: If the iterable is of type Any. + TypeError: If the render function is a ComponentState. """ if props: console.deprecate( @@ -65,6 +67,15 @@ def create( "(If you are trying to foreach over a state var, add a type annotation to the var). " "See https://reflex.dev/docs/library/layout/foreach/" ) + + if ( + hasattr(render_fn, "__qualname__") + and render_fn.__qualname__ == ComponentState.create.__qualname__ + ): + raise TypeError( + "Using a ComponentState as `render_fn` inside `rx.foreach` is not supported yet." + ) + component = cls( iterable=iterable, render_fn=render_fn, diff --git a/tests/components/core/test_foreach.py b/tests/components/core/test_foreach.py index 9691ed50e6f..6c418459031 100644 --- a/tests/components/core/test_foreach.py +++ b/tests/components/core/test_foreach.py @@ -3,8 +3,9 @@ import pytest from reflex.components import box, el, foreach, text +from reflex.components.component import Component from reflex.components.core.foreach import Foreach, ForeachRenderError, ForeachVarError -from reflex.state import BaseState +from reflex.state import BaseState, ComponentState from reflex.vars import Var @@ -37,6 +38,25 @@ class ForEachState(BaseState): color_index_tuple: Tuple[int, str] = (0, "red") +class TestComponentState(ComponentState): + """A test component state.""" + + foo: bool + + @classmethod + def get_component(cls, *children, **props) -> Component: + """Get the component. + + Args: + children: The children components. + props: The component props. + + Returns: + The component. + """ + return el.div(*children, **props) + + def display_color(color): assert color._var_type == str return box(text(color)) @@ -252,3 +272,12 @@ def test_foreach_component_styles(): ) component._add_style_recursive({box: {"color": "red"}}) assert 'css={{"color": "red"}}' in str(component) + + +def test_foreach_component_state(): + """Test that using a component state to render in the foreach raises an error.""" + with pytest.raises(TypeError): + Foreach.create( + ForEachState.colors_list, + TestComponentState.create, + ) From 89352ac10e7a55a2406fd25170ce3dfe0522270f Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 16 May 2024 13:20:26 -0700 Subject: [PATCH 3/5] rx._x.client_state: react useState Var integration for frontend and backend (#3269) New experimental feature to create client-side react state vars, save them in the global `refs` object and access them in frontend rendering/event triggers as well on the backend via call_script. --- reflex/experimental/__init__.py | 2 + reflex/experimental/client_state.py | 198 ++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 reflex/experimental/client_state.py diff --git a/reflex/experimental/__init__.py b/reflex/experimental/__init__.py index 29bda854536..6972fdfe0de 100644 --- a/reflex/experimental/__init__.py +++ b/reflex/experimental/__init__.py @@ -8,6 +8,7 @@ from ..utils.console import warn from . import hooks as hooks +from .client_state import ClientStateVar as ClientStateVar from .layout import layout as layout from .misc import run_in_thread as run_in_thread @@ -16,6 +17,7 @@ ) _x = SimpleNamespace( + client_state=ClientStateVar.create, hooks=hooks, layout=layout, progress=progress, diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py new file mode 100644 index 00000000000..d0028991c63 --- /dev/null +++ b/reflex/experimental/client_state.py @@ -0,0 +1,198 @@ +"""Handle client side state with `useState`.""" + +import dataclasses +import sys +from typing import Any, Callable, Optional, Type + +from reflex import constants +from reflex.event import EventChain, EventHandler, EventSpec, call_script +from reflex.utils.imports import ImportVar +from reflex.vars import Var, VarData + + +def _client_state_ref(var_name: str) -> str: + """Get the ref path for a ClientStateVar. + + Args: + var_name: The name of the variable. + + Returns: + An accessor for ClientStateVar ref as a string. + """ + return f"refs['_client_state_{var_name}']" + + +@dataclasses.dataclass( + eq=False, + **{"slots": True} if sys.version_info >= (3, 10) else {}, +) +class ClientStateVar(Var): + """A Var that exists on the client via useState.""" + + # The name of the var. + _var_name: str = dataclasses.field() + + # Track the names of the getters and setters + _setter_name: str = dataclasses.field() + _getter_name: str = dataclasses.field() + + # The type of the var. + _var_type: Type = dataclasses.field(default=Any) + + # Whether this is a local javascript variable. + _var_is_local: bool = dataclasses.field(default=False) + + # Whether the var is a string literal. + _var_is_string: bool = dataclasses.field(default=False) + + # _var_full_name should be prefixed with _var_state + _var_full_name_needs_state_prefix: bool = dataclasses.field(default=False) + + # Extra metadata associated with the Var + _var_data: Optional[VarData] = dataclasses.field(default=None) + + def __hash__(self) -> int: + """Define a hash function for a var. + + Returns: + The hash of the var. + """ + return hash( + (self._var_name, str(self._var_type), self._getter_name, self._setter_name) + ) + + @classmethod + def create(cls, var_name, default=None) -> "ClientStateVar": + """Create a local_state Var that can be accessed and updated on the client. + + The `ClientStateVar` should be included in the highest parent component + that contains the components which will access and manipulate the client + state. It has no visual rendering, including it ensures that the + `useState` hook is called in the correct scope. + + To render the var in a component, use the `value` property. + + To update the var in a component, use the `set` property. + + To access the var in an event handler, use the `retrieve` method with + `callback` set to the event handler which should receive the value. + + To update the var in an event handler, use the `push` method with the + value to update. + + Args: + var_name: The name of the variable. + default: The default value of the variable. + + Returns: + ClientStateVar + """ + if default is None: + default_var = Var.create_safe("", _var_is_local=False, _var_is_string=False) + elif not isinstance(default, Var): + default_var = Var.create_safe(default) + else: + default_var = default + setter_name = f"set{var_name.capitalize()}" + return cls( + _var_name="", + _setter_name=setter_name, + _getter_name=var_name, + _var_is_local=False, + _var_is_string=False, + _var_type=default_var._var_type, + _var_data=VarData.merge( + default_var._var_data, + VarData( # type: ignore + hooks={ + f"const [{var_name}, {setter_name}] = useState({default_var._var_name_unwrapped})": None, + f"{_client_state_ref(var_name)} = {var_name}": None, + f"{_client_state_ref(setter_name)} = {setter_name}": None, + }, + imports={ + "react": {ImportVar(tag="useState", install=False)}, + f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], + }, + ), + ), + ) + + @property + def value(self) -> Var: + """Get a placeholder for the Var. + + This property can only be rendered on the frontend. + + To access the value in a backend event handler, see `retrieve`. + + Returns: + an accessor for the client state variable. + """ + return ( + Var.create_safe( + _client_state_ref(self._getter_name), + _var_is_local=False, + _var_is_string=False, + ) + .to(self._var_type) + ._replace( + merge_var_data=VarData( # type: ignore + imports={ + f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], + } + ) + ) + ) + + @property + def set(self) -> Var: + """Set the value of the client state variable. + + This property can only be attached to a frontend event trigger. + + To set a value from a backend event handler, see `push`. + + Returns: + A special EventChain Var which will set the value when triggered. + """ + return ( + Var.create_safe( + _client_state_ref(self._setter_name), + _var_is_local=False, + _var_is_string=False, + ) + .to(EventChain) + ._replace( + merge_var_data=VarData( # type: ignore + imports={ + f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], + } + ) + ) + ) + + def retrieve(self, callback: EventHandler | Callable | None = None) -> EventSpec: + """Pass the value of the client state variable to a backend EventHandler. + + The event handler must `yield` or `return` the EventSpec to trigger the event. + + Args: + callback: The callback to pass the value to. + + Returns: + An EventSpec which will retrieve the value when triggered. + """ + return call_script(_client_state_ref(self._getter_name), callback=callback) + + def push(self, value: Any) -> EventSpec: + """Push a value to the client state variable from the backend. + + The event handler must `yield` or `return` the EventSpec to trigger the event. + + Args: + value: The value to update. + + Returns: + An EventSpec which will push the value when triggered. + """ + return call_script(f"{_client_state_ref(self._setter_name)}({value})") From bc6f0f70cb66e85faa58e5a1b474b3ce8f1c8d04 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 16 May 2024 13:21:40 -0700 Subject: [PATCH 4/5] Support replacing route on redirect (#3072) * Support replacing route on redirect Support next/router `.replace` interface to change page without creating a history entry. * test_event: include test cases for new "replace" kwarg --- reflex/.templates/web/utils/state.js | 9 +++++-- reflex/event.py | 13 ++++++++-- tests/test_event.py | 39 +++++++++++++++++++++------- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 5bc6b8b8b07..8386261e95a 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -126,8 +126,13 @@ export const applyDelta = (state, delta) => { export const applyEvent = async (event, socket) => { // Handle special events if (event.name == "_redirect") { - if (event.payload.external) window.open(event.payload.path, "_blank"); - else Router.push(event.payload.path); + if (event.payload.external) { + window.open(event.payload.path, "_blank"); + } else if (event.payload.replace) { + Router.replace(event.payload.path); + } else { + Router.push(event.payload.path); + } return false; } diff --git a/reflex/event.py b/reflex/event.py index 3f1487c1997..96a59fdc105 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -467,18 +467,27 @@ def fn(): ) -def redirect(path: str | Var[str], external: Optional[bool] = False) -> EventSpec: +def redirect( + path: str | Var[str], + external: Optional[bool] = False, + replace: Optional[bool] = False, +) -> EventSpec: """Redirect to a new path. Args: path: The path to redirect to. external: Whether to open in new tab or not. + replace: If True, the current page will not create a new history entry. Returns: An event to redirect to the path. """ return server_side( - "_redirect", get_fn_signature(redirect), path=path, external=external + "_redirect", + get_fn_signature(redirect), + path=path, + external=external, + replace=replace, ) diff --git a/tests/test_event.py b/tests/test_event.py index 88526315761..284542a4347 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -158,12 +158,29 @@ def test_fn_with_args(_, arg1, arg2): @pytest.mark.parametrize( "input,output", [ - (("/path", None), 'Event("_redirect", {path:`/path`,external:false})'), - (("/path", True), 'Event("_redirect", {path:`/path`,external:true})'), - (("/path", False), 'Event("_redirect", {path:`/path`,external:false})'), ( - (Var.create_safe("path"), None), - 'Event("_redirect", {path:path,external:false})', + ("/path", None, None), + 'Event("_redirect", {path:`/path`,external:false,replace:false})', + ), + ( + ("/path", True, None), + 'Event("_redirect", {path:`/path`,external:true,replace:false})', + ), + ( + ("/path", False, None), + 'Event("_redirect", {path:`/path`,external:false,replace:false})', + ), + ( + (Var.create_safe("path"), None, None), + 'Event("_redirect", {path:path,external:false,replace:false})', + ), + ( + ("/path", None, True), + 'Event("_redirect", {path:`/path`,external:false,replace:true})', + ), + ( + ("/path", True, True), + 'Event("_redirect", {path:`/path`,external:true,replace:true})', ), ], ) @@ -174,11 +191,13 @@ def test_event_redirect(input, output): input: The input for running the test. output: The expected output to validate the test. """ - path, external = input - if external is None: - spec = event.redirect(path) - else: - spec = event.redirect(path, external=external) + path, external, replace = input + kwargs = {} + if external is not None: + kwargs["external"] = external + if replace is not None: + kwargs["replace"] = replace + spec = event.redirect(path, **kwargs) assert isinstance(spec, EventSpec) assert spec.handler.fn.__qualname__ == "_redirect" From 99d59104ad71b6dd28426b14ff5721c685a5bf5d Mon Sep 17 00:00:00 2001 From: Santiago Botero <98826652+boterop@users.noreply.github.com> Date: Thu, 16 May 2024 15:22:44 -0500 Subject: [PATCH 5/5] Add end line in .gitignore (#3309) --- reflex/utils/prerequisites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index cd4739c4459..1852f0434f9 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -413,7 +413,7 @@ def initialize_gitignore( # Write files to the .gitignore file. with open(gitignore_file, "w", newline="\n") as f: console.debug(f"Creating {gitignore_file}") - f.write(f"{(path_ops.join(sorted(files_to_ignore))).lstrip()}") + f.write(f"{(path_ops.join(sorted(files_to_ignore))).lstrip()}\n") def initialize_requirements_txt():