This commit is contained in:
Khaleel Al-Adhami 2024-10-28 11:52:37 -07:00
commit dd1ddd653f
52 changed files with 911 additions and 479 deletions

View File

@ -51,6 +51,7 @@ jobs:
SCREENSHOT_DIR: /tmp/screenshots
REDIS_URL: ${{ matrix.state_manager == 'redis' && 'redis://localhost:6379' || '' }}
run: |
poetry run playwright install --with-deps
poetry run pytest tests/integration
- uses: actions/upload-artifact@v4
name: Upload failed test screenshots

View File

@ -122,7 +122,7 @@ jobs:
fail-fast: false
matrix:
# Show OS combos first in GUI
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest]
python-version: ['3.10.11', '3.11.4']
env:

View File

@ -3,7 +3,7 @@ fail_fast: true
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.7.0
rev: v0.7.1
hooks:
- id: ruff-format
args: [reflex, tests]

75
poetry.lock generated
View File

@ -521,6 +521,21 @@ files = [
{file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"},
]
[[package]]
name = "dill"
version = "0.3.9"
description = "serialize all of Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"},
{file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"},
]
[package.extras]
graph = ["objgraph (>=1.7.2)"]
profile = ["gprof2dot (>=2022.7.29)"]
[[package]]
name = "distlib"
version = "0.3.9"
@ -1977,20 +1992,6 @@ files = [
[package.dependencies]
six = ">=1.5"
[[package]]
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "python-engineio"
version = "4.10.1"
@ -2163,13 +2164,13 @@ md = ["cmarkgfm (>=0.8.0)"]
[[package]]
name = "redis"
version = "5.1.1"
version = "5.2.0"
description = "Python client for Redis database and key-value store"
optional = false
python-versions = ">=3.8"
files = [
{file = "redis-5.1.1-py3-none-any.whl", hash = "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24"},
{file = "redis-5.1.1.tar.gz", hash = "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72"},
{file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"},
{file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"},
]
[package.dependencies]
@ -2286,29 +2287,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruff"
version = "0.7.0"
version = "0.7.1"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.7.0-py3-none-linux_armv6l.whl", hash = "sha256:0cdf20c2b6ff98e37df47b2b0bd3a34aaa155f59a11182c1303cce79be715628"},
{file = "ruff-0.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:496494d350c7fdeb36ca4ef1c9f21d80d182423718782222c29b3e72b3512737"},
{file = "ruff-0.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:214b88498684e20b6b2b8852c01d50f0651f3cc6118dfa113b4def9f14faaf06"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630fce3fefe9844e91ea5bbf7ceadab4f9981f42b704fae011bb8efcaf5d84be"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:211d877674e9373d4bb0f1c80f97a0201c61bcd1e9d045b6e9726adc42c156aa"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:194d6c46c98c73949a106425ed40a576f52291c12bc21399eb8f13a0f7073495"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82c2579b82b9973a110fab281860403b397c08c403de92de19568f32f7178598"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9af971fe85dcd5eaed8f585ddbc6bdbe8c217fb8fcf510ea6bca5bdfff56040e"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b641c7f16939b7d24b7bfc0be4102c56562a18281f84f635604e8a6989948914"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d71672336e46b34e0c90a790afeac8a31954fd42872c1f6adaea1dff76fd44f9"},
{file = "ruff-0.7.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ab7d98c7eed355166f367597e513a6c82408df4181a937628dbec79abb2a1fe4"},
{file = "ruff-0.7.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1eb54986f770f49edb14f71d33312d79e00e629a57387382200b1ef12d6a4ef9"},
{file = "ruff-0.7.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dc452ba6f2bb9cf8726a84aa877061a2462afe9ae0ea1d411c53d226661c601d"},
{file = "ruff-0.7.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4b406c2dce5be9bad59f2de26139a86017a517e6bcd2688da515481c05a2cb11"},
{file = "ruff-0.7.0-py3-none-win32.whl", hash = "sha256:f6c968509f767776f524a8430426539587d5ec5c662f6addb6aa25bc2e8195ec"},
{file = "ruff-0.7.0-py3-none-win_amd64.whl", hash = "sha256:ff4aabfbaaba880e85d394603b9e75d32b0693152e16fa659a3064a85df7fce2"},
{file = "ruff-0.7.0-py3-none-win_arm64.whl", hash = "sha256:10842f69c245e78d6adec7e1db0a7d9ddc2fff0621d730e61657b64fa36f207e"},
{file = "ruff-0.7.0.tar.gz", hash = "sha256:47a86360cf62d9cd53ebfb0b5eb0e882193fc191c6d717e8bef4462bc3b9ea2b"},
{file = "ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89"},
{file = "ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35"},
{file = "ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad"},
{file = "ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112"},
{file = "ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378"},
{file = "ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8"},
{file = "ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd"},
{file = "ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9"},
{file = "ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307"},
{file = "ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37"},
{file = "ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4"},
]
[[package]]
@ -3047,4 +3048,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
content-hash = "c5da15520cef58124f6699007c81158036840469d4f9972592d72bd456c45e7e"
content-hash = "547fdabf7a030c2a7c8d63eb5b2a3c5e821afa86390f08b895db038d30013904"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "reflex"
version = "0.6.4dev1"
version = "0.6.5dev1"
description = "Web apps in pure Python."
license = "Apache-2.0"
authors = [
@ -33,7 +33,6 @@ jinja2 = ">=3.1.2,<4.0"
psutil = ">=5.9.4,<7.0"
pydantic = ">=1.10.2,<3.0"
python-multipart = ">=0.0.5,<0.1"
python-dotenv = ">=1.0.1"
python-socketio = ">=5.7.0,<6.0"
redis = ">=4.3.5,<6.0"
rich = ">=13.0.0,<14.0"
@ -66,10 +65,11 @@ pytest = ">=7.1.2,<9.0"
pytest-mock = ">=3.10.0,<4.0"
pyright = ">=1.1.229,<1.1.335"
darglint = ">=1.8.1,<2.0"
dill = ">=0.3.8"
toml = ">=0.10.2,<1.0"
pytest-asyncio = ">=0.24.0"
pytest-cov = ">=4.0.0,<6.0"
ruff = "^0.7.0"
ruff = "0.7.1"
pandas = ">=2.1.1,<3.0"
pillow = ">=10.0.0,<12.0"
plotly = ">=5.13.0,<6.0"

View File

@ -1,11 +1,11 @@
{% extends "web/pages/base_page.js.jinja2" %}
{% block early_imports %}
import '/styles/styles.css'
import '$/styles/styles.css'
{% endblock %}
{% block declaration %}
import { EventLoopProvider, StateProvider, defaultColorMode } from "/utils/context.js";
import { EventLoopProvider, StateProvider, defaultColorMode } from "$/utils/context.js";
import { ThemeProvider } from 'next-themes'
{% for library_alias, library_path in window_libraries %}
import * as {{library_alias}} from "{{library_path}}";

View File

@ -1,5 +1,5 @@
import { createContext, useContext, useMemo, useReducer, useState } from "react"
import { applyDelta, Event, hydrateClientStorage, useEventLoop, refs } from "/utils/state.js"
import { applyDelta, Event, hydrateClientStorage, useEventLoop, refs } from "$/utils/state.js"
{% if initial_state %}
export const initialState = {{ initial_state|json_dumps }}
@ -59,6 +59,8 @@ export const initialEvents = () => [
{% else %}
export const state_name = undefined
export const exception_state_name = undefined
export const onLoadInternalEvent = () => []
export const initialEvents = () => []

View File

@ -4,8 +4,8 @@ import {
ColorModeContext,
defaultColorMode,
isDevMode,
lastCompiledTimeStamp
} from "/utils/context.js";
lastCompiledTimeStamp,
} from "$/utils/context.js";
export default function RadixThemesColorModeProvider({ children }) {
const { theme, resolvedTheme, setTheme } = useTheme();
@ -37,7 +37,7 @@ export default function RadixThemesColorModeProvider({ children }) {
const allowedModes = ["light", "dark", "system"];
if (!allowedModes.includes(mode)) {
console.error(
`Invalid color mode "${mode}". Defaulting to "${defaultColorMode}".`,
`Invalid color mode "${mode}". Defaulting to "${defaultColorMode}".`
);
mode = defaultColorMode;
}

View File

@ -2,6 +2,7 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"$/*": ["*"],
"@/*": ["public/*"]
}
}

View File

@ -2,7 +2,7 @@
import axios from "axios";
import io from "socket.io-client";
import JSON5 from "json5";
import env from "/env.json";
import env from "$/env.json";
import Cookies from "universal-cookie";
import { useEffect, useRef, useState } from "react";
import Router, { useRouter } from "next/router";
@ -12,9 +12,9 @@ import {
onLoadInternalEvent,
state_name,
exception_state_name,
} from "utils/context.js";
import debounce from "/utils/helpers/debounce";
import throttle from "/utils/helpers/throttle";
} from "$/utils/context.js";
import debounce from "$/utils/helpers/debounce";
import throttle from "$/utils/helpers/throttle";
import * as Babel from "@babel/standalone";
// Endpoint URLs.

View File

@ -331,6 +331,7 @@ _MAPPING: dict = {
"var",
"ComponentState",
"State",
"dynamic",
],
"style": ["Style", "toggle_color_mode"],
"utils.imports": ["ImportVar"],

View File

@ -184,6 +184,7 @@ from .model import session as session
from .page import page as page
from .state import ComponentState as ComponentState
from .state import State as State
from .state import dynamic as dynamic
from .state import var as var
from .style import Style as Style
from .style import toggle_color_mode as toggle_color_mode

View File

@ -6,6 +6,7 @@ import asyncio
import concurrent.futures
import contextlib
import copy
import dataclasses
import functools
import inspect
import io
@ -18,6 +19,7 @@ import traceback
from datetime import datetime
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Callable,
@ -47,7 +49,10 @@ from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin
from reflex.base import Base
from reflex.compiler import compiler
from reflex.compiler import utils as compiler_utils
from reflex.compiler.compiler import ExecutorSafeFunctions
from reflex.compiler.compiler import (
ExecutorSafeFunctions,
compile_theme,
)
from reflex.components.base.app_wrap import AppWrap
from reflex.components.base.error_boundary import ErrorBoundary
from reflex.components.base.fragment import Fragment
@ -88,6 +93,9 @@ from reflex.utils import codespaces, console, exceptions, format, prerequisites,
from reflex.utils.exec import is_prod_mode, is_testing_env, should_skip_compile
from reflex.utils.imports import ImportVar
if TYPE_CHECKING:
from reflex.vars import Var
# Define custom types.
ComponentCallable = Callable[[], Component]
Reducer = Callable[[Event], Coroutine[Any, Any, StateUpdate]]
@ -170,6 +178,21 @@ class OverlayFragment(Fragment):
pass
@dataclasses.dataclass(
frozen=True,
)
class UnevaluatedPage:
"""An uncompiled page."""
component: Union[Component, ComponentCallable]
route: str
title: Union[Var, str, None]
description: Union[Var, str, None]
image: str
on_load: Union[EventHandler, EventSpec, List[Union[EventHandler, EventSpec]], None]
meta: List[Dict[str, str]]
class App(MiddlewareMixin, LifespanMixin, Base):
"""The main Reflex app that encapsulates the backend and frontend.
@ -220,6 +243,9 @@ class App(MiddlewareMixin, LifespanMixin, Base):
# Attributes to add to the html root tag of every page.
html_custom_attrs: Optional[Dict[str, str]] = None
# A map from a route to an unevaluated page. PRIVATE.
unevaluated_pages: Dict[str, UnevaluatedPage] = {}
# A map from a page route to the component to render. Users should use `add_page`. PRIVATE.
pages: Dict[str, Component] = {}
@ -381,8 +407,8 @@ class App(MiddlewareMixin, LifespanMixin, Base):
def _add_optional_endpoints(self):
"""Add optional api endpoints (_upload)."""
# To upload files.
if Upload.is_used:
# To upload files.
self.api.post(str(constants.Endpoint.UPLOAD))(upload(self))
# To access uploaded files.
@ -442,8 +468,8 @@ class App(MiddlewareMixin, LifespanMixin, Base):
self,
component: Component | ComponentCallable,
route: str | None = None,
title: str | None = None,
description: str | None = None,
title: str | Var | None = None,
description: str | Var | None = None,
image: str = constants.DefaultPage.IMAGE,
on_load: (
EventHandler | EventSpec | list[EventHandler | EventSpec] | None
@ -479,13 +505,13 @@ class App(MiddlewareMixin, LifespanMixin, Base):
# Check if the route given is valid
verify_route_validity(route)
if route in self.pages and os.getenv(constants.RELOAD_CONFIG):
if route in self.unevaluated_pages and os.getenv(constants.RELOAD_CONFIG):
# when the app is reloaded(typically for app harness tests), we should maintain
# the latest render function of a route.This applies typically to decorated pages
# since they are only added when app._compile is called.
self.pages.pop(route)
self.unevaluated_pages.pop(route)
if route in self.pages:
if route in self.unevaluated_pages:
route_name = (
f"`{route}` or `/`"
if route == constants.PageNames.INDEX_ROUTE
@ -501,58 +527,38 @@ class App(MiddlewareMixin, LifespanMixin, Base):
state = self.state if self.state else State
state.setup_dynamic_args(get_route_args(route))
# Generate the component if it is a callable.
component = self._generate_component(component)
# unpack components that return tuples in an rx.fragment.
if isinstance(component, tuple):
component = Fragment.create(*component)
# Ensure state is enabled if this page uses state.
if self.state is None:
if on_load or component._has_stateful_event_triggers():
self._enable_state()
else:
for var in component._get_vars(include_children=True):
var_data = var._get_all_var_data()
if not var_data:
continue
if not var_data.state:
continue
self._enable_state()
break
component = OverlayFragment.create(component)
meta_args = {
"title": (
title
if title is not None
else format.make_default_page_title(get_config().app_name, route)
),
"image": image,
"meta": meta,
}
if description is not None:
meta_args["description"] = description
# Add meta information to the component.
compiler_utils.add_meta(
component,
**meta_args,
if on_load:
self.load_events[route] = (
on_load if isinstance(on_load, list) else [on_load]
)
self.unevaluated_pages[route] = UnevaluatedPage(
component=component,
route=route,
title=title,
description=description,
image=image,
on_load=on_load,
meta=meta,
)
def _compile_page(self, route: str):
"""Compile a page.
Args:
route: The route of the page to compile.
"""
component, enable_state = compiler.compile_unevaluated_page(
route, self.unevaluated_pages[route], self.state
)
if enable_state:
self._enable_state()
# Add the page.
self._check_routes_conflict(route)
self.pages[route] = component
# Add the load events.
if on_load:
if not isinstance(on_load, list):
on_load = [on_load]
self.load_events[route] = on_load
def get_load_events(self, route: str) -> list[EventHandler | EventSpec]:
"""Get the load events for a route.
@ -679,7 +685,7 @@ class App(MiddlewareMixin, LifespanMixin, Base):
for i, tags in imports.items()
if i not in constants.PackageJson.DEPENDENCIES
and i not in constants.PackageJson.DEV_DEPENDENCIES
and not any(i.startswith(prefix) for prefix in ["/", ".", "next/"])
and not any(i.startswith(prefix) for prefix in ["/", "$/", ".", "next/"])
and i != ""
and any(tag.install for tag in tags)
}
@ -827,13 +833,18 @@ class App(MiddlewareMixin, LifespanMixin, Base):
"""
from reflex.utils.exceptions import ReflexRuntimeError
self.pages = {}
def get_compilation_time() -> str:
return str(datetime.now().time()).split(".")[0]
# Render a default 404 page if the user didn't supply one
if constants.Page404.SLUG not in self.pages:
if constants.Page404.SLUG not in self.unevaluated_pages:
self.add_custom_404_page()
for route in self.unevaluated_pages:
self._compile_page(route)
# Add the optional endpoints (_upload)
self._add_optional_endpoints()
@ -857,7 +868,7 @@ class App(MiddlewareMixin, LifespanMixin, Base):
progress.start()
task = progress.add_task(
f"[{get_compilation_time()}] Compiling:",
total=len(self.pages)
total=len(self.unevaluated_pages)
+ fixed_pages_within_executor
+ adhoc_steps_without_executor,
)
@ -886,38 +897,8 @@ class App(MiddlewareMixin, LifespanMixin, Base):
all_imports = {}
custom_components = set()
for _route, component in self.pages.items():
# Merge the component style with the app style.
component._add_style_recursive(self.style, self.theme)
# Add component._get_all_imports() to all_imports.
all_imports.update(component._get_all_imports())
# Add the app wrappers from this component.
app_wrappers.update(component._get_all_app_wrap_components())
# Add the custom components from the page to the set.
custom_components |= component._get_all_custom_components()
progress.advance(task)
# Perform auto-memoization of stateful components.
(
stateful_components_path,
stateful_components_code,
page_components,
) = compiler.compile_stateful_components(self.pages.values())
progress.advance(task)
# Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State.
if code_uses_state_contexts(stateful_components_code) and self.state is None:
raise ReflexRuntimeError(
"To access rx.State in frontend components, at least one "
"subclass of rx.State must be defined in the app."
)
compile_results.append((stateful_components_path, stateful_components_code))
# Compile the root document before fork.
compile_results.append(
compiler.compile_document_root(
@ -927,31 +908,12 @@ class App(MiddlewareMixin, LifespanMixin, Base):
)
)
# Compile the contexts before fork.
compile_results.append(
compiler.compile_contexts(self.state, self.theme),
)
# Fix #2992 by removing the top-level appearance prop
if self.theme is not None:
self.theme.appearance = None
app_root = self._app_root(app_wrappers=app_wrappers)
progress.advance(task)
# Prepopulate the global ExecutorSafeFunctions class with input data required by the compile functions.
# This is required for multiprocessing to work, in presence of non-picklable inputs.
for route, component in zip(self.pages, page_components):
ExecutorSafeFunctions.COMPILE_PAGE_ARGS_BY_ROUTE[route] = (
route,
component,
self.state,
)
ExecutorSafeFunctions.COMPILE_APP_APP_ROOT = app_root
ExecutorSafeFunctions.CUSTOM_COMPONENTS = custom_components
ExecutorSafeFunctions.STYLE = self.style
# Use a forking process pool, if possible. Much faster, especially for large sites.
# Fallback to ThreadPoolExecutor as something that will always work.
executor = None
@ -969,36 +931,55 @@ class App(MiddlewareMixin, LifespanMixin, Base):
max_workers=environment.REFLEX_COMPILE_THREADS
)
for route, component in self.pages.items():
component._add_style_recursive(self.style, self.theme)
ExecutorSafeFunctions.COMPONENTS[route] = component
for route, page in self.unevaluated_pages.items():
if route in self.pages:
continue
ExecutorSafeFunctions.UNCOMPILED_PAGES[route] = page
ExecutorSafeFunctions.STATE = self.state
pages_results = []
with executor:
result_futures = []
custom_components_future = None
def _mark_complete(_=None):
progress.advance(task)
pages_futures = []
def _submit_work(fn, *args, **kwargs):
f = executor.submit(fn, *args, **kwargs)
f.add_done_callback(_mark_complete)
# f = executor.apipe(fn, *args, **kwargs)
result_futures.append(f)
# Compile all page components.
for route in self.pages:
_submit_work(ExecutorSafeFunctions.compile_page, route)
for route in self.unevaluated_pages:
if route in self.pages:
continue
# Compile the app wrapper.
_submit_work(ExecutorSafeFunctions.compile_app)
# Compile the custom components.
custom_components_future = executor.submit(
ExecutorSafeFunctions.compile_custom_components,
f = executor.submit(
ExecutorSafeFunctions.compile_unevaluated_page,
route,
self.style,
self.theme,
)
pages_futures.append(f)
# Compile the pre-compiled pages.
for route in self.pages:
_submit_work(
ExecutorSafeFunctions.compile_page,
route,
)
custom_components_future.add_done_callback(_mark_complete)
# Compile the root stylesheet with base styles.
_submit_work(compiler.compile_root_stylesheet, self.stylesheets)
# Compile the theme.
_submit_work(ExecutorSafeFunctions.compile_theme)
_submit_work(compile_theme, self.style)
# Compile the Tailwind config.
if config.tailwind is not None:
@ -1012,21 +993,70 @@ class App(MiddlewareMixin, LifespanMixin, Base):
# Wait for all compilation tasks to complete.
for future in concurrent.futures.as_completed(result_futures):
compile_results.append(future.result())
progress.advance(task)
# Special case for custom_components, since we need the compiled imports
# to install proper frontend packages.
for future in concurrent.futures.as_completed(pages_futures):
pages_results.append(future.result())
progress.advance(task)
for route, component, compiled_page in pages_results:
self._check_routes_conflict(route)
self.pages[route] = component
compile_results.append(compiled_page)
for _, component in self.pages.items():
# Add component._get_all_imports() to all_imports.
all_imports.update(component._get_all_imports())
# Add the app wrappers from this component.
app_wrappers.update(component._get_all_app_wrap_components())
# Add the custom components from the page to the set.
custom_components |= component._get_all_custom_components()
# Perform auto-memoization of stateful components.
(
*custom_components_result,
custom_components_imports,
) = custom_components_future.result()
compile_results.append(custom_components_result)
all_imports.update(custom_components_imports)
stateful_components_path,
stateful_components_code,
page_components,
) = compiler.compile_stateful_components(self.pages.values())
progress.advance(task)
# Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State.
if code_uses_state_contexts(stateful_components_code) and self.state is None:
raise ReflexRuntimeError(
"To access rx.State in frontend components, at least one "
"subclass of rx.State must be defined in the app."
)
compile_results.append((stateful_components_path, stateful_components_code))
app_root = self._app_root(app_wrappers=app_wrappers)
# Get imports from AppWrap components.
all_imports.update(app_root._get_all_imports())
progress.advance(task)
# Compile the contexts.
compile_results.append(
compiler.compile_contexts(self.state, self.theme),
)
progress.advance(task)
# Compile the app root.
compile_results.append(
compiler.compile_app(app_root),
)
progress.advance(task)
# Compile custom components.
*custom_components_result, custom_components_imports = (
compiler.compile_components(custom_components)
)
compile_results.append(custom_components_result)
all_imports.update(custom_components_imports)
progress.advance(task)
progress.stop()

View File

@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, Optional, Type, Union
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Tuple, Type, Union
from reflex import constants
from reflex.compiler import templates, utils
from reflex.components.base.fragment import Fragment
from reflex.components.component import (
BaseComponent,
Component,
@ -67,8 +68,8 @@ def _compile_app(app_root: Component) -> str:
window_libraries = [
(_normalize_library_name(name), name) for name in bundled_libraries
] + [
("utils_context", f"/{constants.Dirs.UTILS}/context"),
("utils_state", f"/{constants.Dirs.UTILS}/state"),
("utils_context", f"$/{constants.Dirs.UTILS}/context"),
("utils_state", f"$/{constants.Dirs.UTILS}/state"),
]
return templates.APP_ROOT.render(
@ -127,7 +128,7 @@ def _compile_contexts(state: Optional[Type[BaseState]], theme: Component | None)
def _compile_page(
component: Component,
state: Type[BaseState],
state: Type[BaseState] | None,
) -> str:
"""Compile the component given the app state.
@ -142,7 +143,7 @@ def _compile_page(
imports = utils.compile_imports(imports)
# Compile the code to render the component.
kwargs = {"state_name": state.get_name()} if state else {}
kwargs = {"state_name": state.get_name()} if state is not None else {}
return templates.PAGE.render(
imports=imports,
@ -228,7 +229,7 @@ def _compile_components(
"""
imports = {
"react": [ImportVar(tag="memo")],
f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="E"), ImportVar(tag="isTrue")],
f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="E"), ImportVar(tag="isTrue")],
}
component_renders = []
@ -315,7 +316,7 @@ def _compile_stateful_components(
# Don't import from the file that we're about to create.
all_imports = utils.merge_imports(*all_import_dicts)
all_imports.pop(
f"/{constants.Dirs.UTILS}/{constants.PageNames.STATEFUL_COMPONENTS}", None
f"$/{constants.Dirs.UTILS}/{constants.PageNames.STATEFUL_COMPONENTS}", None
)
return templates.STATEFUL_COMPONENTS.render(
@ -424,7 +425,7 @@ def compile_contexts(
def compile_page(
path: str, component: Component, state: Type[BaseState]
path: str, component: Component, state: Type[BaseState] | None
) -> tuple[str, str]:
"""Compile a single page.
@ -534,6 +535,73 @@ def purge_web_pages_dir():
utils.empty_dir(get_web_dir() / constants.Dirs.PAGES, keep_files=["_app.js"])
if TYPE_CHECKING:
from reflex.app import UnevaluatedPage
def compile_unevaluated_page(
route: str, page: UnevaluatedPage, state: Type[BaseState] | None = None
) -> Tuple[Component, bool]:
"""Compiles an uncompiled page into a component and adds meta information.
Args:
route: The route of the page.
page: The uncompiled page object.
state: The state of the app.
Returns:
The compiled component and whether state should be enabled.
"""
# Generate the component if it is a callable.
component = page.component
component = component if isinstance(component, Component) else component()
# unpack components that return tuples in an rx.fragment.
if isinstance(component, tuple):
component = Fragment.create(*component)
enable_state = False
# Ensure state is enabled if this page uses state.
if state is None:
if page.on_load or component._has_stateful_event_triggers():
enable_state = True
else:
for var in component._get_vars(include_children=True):
var_data = var._get_all_var_data()
if not var_data:
continue
if not var_data.state:
continue
enable_state = True
break
from reflex.app import OverlayFragment
from reflex.utils.format import make_default_page_title
component = OverlayFragment.create(component)
meta_args = {
"title": (
page.title
if page.title is not None
else make_default_page_title(get_config().app_name, route)
),
"image": page.image,
"meta": page.meta,
}
if page.description is not None:
meta_args["description"] = page.description
# Add meta information to the component.
utils.add_meta(
component,
**meta_args,
)
return component, enable_state
class ExecutorSafeFunctions:
"""Helper class to allow parallelisation of parts of the compilation process.
@ -559,13 +627,12 @@ class ExecutorSafeFunctions:
"""
COMPILE_PAGE_ARGS_BY_ROUTE = {}
COMPILE_APP_APP_ROOT: Component | None = None
CUSTOM_COMPONENTS: set[CustomComponent] | None = None
STYLE: ComponentStyle | None = None
COMPONENTS: Dict[str, Component] = {}
UNCOMPILED_PAGES: Dict[str, UnevaluatedPage] = {}
STATE: Optional[Type[BaseState]] = None
@classmethod
def compile_page(cls, route: str):
def compile_page(cls, route: str) -> tuple[str, str]:
"""Compile a page.
Args:
@ -574,46 +641,45 @@ class ExecutorSafeFunctions:
Returns:
The path and code of the compiled page.
"""
return compile_page(*cls.COMPILE_PAGE_ARGS_BY_ROUTE[route])
return compile_page(route, cls.COMPONENTS[route], cls.STATE)
@classmethod
def compile_app(cls):
"""Compile the app.
def compile_unevaluated_page(
cls,
route: str,
style: ComponentStyle,
theme: Component | None,
) -> tuple[str, Component, tuple[str, str]]:
"""Compile an unevaluated page.
Args:
route: The route of the page to compile.
style: The style of the page.
theme: The theme of the page.
Returns:
The path and code of the compiled app.
Raises:
ValueError: If the app root is not set.
The route, compiled component, and compiled page.
"""
if cls.COMPILE_APP_APP_ROOT is None:
raise ValueError("COMPILE_APP_APP_ROOT should be set")
return compile_app(cls.COMPILE_APP_APP_ROOT)
component, enable_state = compile_unevaluated_page(
route, cls.UNCOMPILED_PAGES[route]
)
component = component if isinstance(component, Component) else component()
component._add_style_recursive(style, theme)
return route, component, compile_page(route, component, cls.STATE)
@classmethod
def compile_custom_components(cls):
"""Compile the custom components.
Returns:
The path and code of the compiled custom components.
Raises:
ValueError: If the custom components are not set.
"""
if cls.CUSTOM_COMPONENTS is None:
raise ValueError("CUSTOM_COMPONENTS should be set")
return compile_components(cls.CUSTOM_COMPONENTS)
@classmethod
def compile_theme(cls):
def compile_theme(cls, style: ComponentStyle | None) -> tuple[str, str]:
"""Compile the theme.
Args:
style: The style to compile.
Returns:
The path and code of the compiled theme.
Raises:
ValueError: If the style is not set.
"""
if cls.STYLE is None:
if style is None:
raise ValueError("STYLE should be set")
return compile_theme(cls.STYLE)
return compile_theme(style)

View File

@ -83,6 +83,12 @@ def validate_imports(import_dict: ParsedImportDict):
f"{_import.tag}/{_import.alias}" if _import.alias else _import.tag
)
if import_name in used_tags:
already_imported = used_tags[import_name]
if (already_imported[0] == "$" and already_imported[1:] == lib) or (
lib[0] == "$" and lib[1:] == already_imported
):
used_tags[import_name] = lib if lib[0] == "$" else already_imported
continue
raise ValueError(
f"Can not compile, the tag {import_name} is used multiple time from {lib} and {used_tags[import_name]}"
)

View File

@ -38,6 +38,7 @@ from reflex.constants import (
)
from reflex.constants.compiler import SpecialAttributes
from reflex.event import (
EventCallback,
EventChain,
EventChainVar,
EventHandler,
@ -1129,6 +1130,8 @@ class Component(BaseComponent, ABC):
for trigger in self.event_triggers.values():
if isinstance(trigger, EventChain):
for event in trigger.events:
if isinstance(event, EventCallback):
continue
if isinstance(event, EventSpec):
if event.handler.state_full_name:
return True
@ -1308,7 +1311,9 @@ class Component(BaseComponent, ABC):
if self._get_ref_hook():
# Handle hooks needed for attaching react refs to DOM nodes.
_imports.setdefault("react", set()).add(ImportVar(tag="useRef"))
_imports.setdefault(f"/{Dirs.STATE_PATH}", set()).add(ImportVar(tag="refs"))
_imports.setdefault(f"$/{Dirs.STATE_PATH}", set()).add(
ImportVar(tag="refs")
)
if self._get_mount_lifecycle_hook():
# Handle hooks for `on_mount` / `on_unmount`.
@ -1665,7 +1670,7 @@ class CustomComponent(Component):
"""A custom user-defined component."""
# Use the components library.
library = f"/{Dirs.COMPONENTS_PATH}"
library = f"$/{Dirs.COMPONENTS_PATH}"
# The function that creates the component.
component_fn: Callable[..., Component] = Component.create
@ -2234,7 +2239,7 @@ class StatefulComponent(BaseComponent):
"""
if self.rendered_as_shared:
return {
f"/{Dirs.UTILS}/{PageNames.STATEFUL_COMPONENTS}": [
f"$/{Dirs.UTILS}/{PageNames.STATEFUL_COMPONENTS}": [
ImportVar(tag=self.tag)
]
}

View File

@ -66,8 +66,8 @@ class WebsocketTargetURL(Var):
_js_expr="getBackendURL(env.EVENT).href",
_var_data=VarData(
imports={
"/env.json": [ImportVar(tag="env", is_default=True)],
f"/{Dirs.STATE_PATH}": [ImportVar(tag="getBackendURL")],
"$/env.json": [ImportVar(tag="env", is_default=True)],
f"$/{Dirs.STATE_PATH}": [ImportVar(tag="getBackendURL")],
},
),
_var_type=WebsocketTargetURL,

View File

@ -21,7 +21,7 @@ route_not_found: Var = Var(_js_expr=constants.ROUTE_NOT_FOUND)
class ClientSideRouting(Component):
"""The client-side routing component."""
library = "/utils/client_side_routing"
library = "$/utils/client_side_routing"
tag = "useClientSideRouting"
def add_hooks(self) -> list[str]:

View File

@ -67,7 +67,7 @@ class Clipboard(Fragment):
The import dict for the component.
"""
return {
"/utils/helpers/paste.js": ImportVar(
"$/utils/helpers/paste.js": ImportVar(
tag="usePasteHandler", is_default=True
),
}

View File

@ -15,7 +15,7 @@ from reflex.vars.base import LiteralVar, Var
from reflex.vars.number import ternary_operation
_IS_TRUE_IMPORT: ImportDict = {
f"/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
f"$/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
}

View File

@ -118,7 +118,7 @@ class DebounceInput(Component):
_var_type=Type[Component],
_var_data=VarData(
imports=child._get_imports(),
hooks=child._get_hooks_internal(),
hooks=child._get_all_hooks(),
),
),
)
@ -128,6 +128,10 @@ class DebounceInput(Component):
component.event_triggers.update(child.event_triggers)
component.children = child.children
component._rename_props = child._rename_props
outer_get_all_custom_code = component._get_all_custom_code
component._get_all_custom_code = lambda: outer_get_all_custom_code().union(
child._get_all_custom_code()
)
return component
def _render(self):

View File

@ -29,7 +29,7 @@ DEFAULT_UPLOAD_ID: str = "default"
upload_files_context_var_data: VarData = VarData(
imports={
"react": "useContext",
f"/{Dirs.CONTEXTS_PATH}": "UploadFilesContext",
f"$/{Dirs.CONTEXTS_PATH}": "UploadFilesContext",
},
hooks={
"const [filesById, setFilesById] = useContext(UploadFilesContext);": None,
@ -134,8 +134,8 @@ uploaded_files_url_prefix = Var(
_js_expr="getBackendURL(env.UPLOAD)",
_var_data=VarData(
imports={
f"/{Dirs.STATE_PATH}": "getBackendURL",
"/env.json": ImportVar(tag="env", is_default=True),
f"$/{Dirs.STATE_PATH}": "getBackendURL",
"$/env.json": ImportVar(tag="env", is_default=True),
}
),
).to(str)
@ -170,7 +170,7 @@ def _on_drop_spec(files: Var) -> Tuple[Var[Any]]:
class UploadFilesProvider(Component):
"""AppWrap component that provides a dict of selected files by ID via useContext."""
library = f"/{Dirs.CONTEXTS_PATH}"
library = f"$/{Dirs.CONTEXTS_PATH}"
tag = "UploadFilesProvider"

View File

@ -34,8 +34,8 @@ uploaded_files_url_prefix = Var(
_js_expr="getBackendURL(env.UPLOAD)",
_var_data=VarData(
imports={
f"/{Dirs.STATE_PATH}": "getBackendURL",
"/env.json": ImportVar(tag="env", is_default=True),
f"$/{Dirs.STATE_PATH}": "getBackendURL",
"$/env.json": ImportVar(tag="env", is_default=True),
}
),
).to(str)

View File

@ -344,7 +344,7 @@ class DataEditor(NoSSRComponent):
return {
"": f"{format.format_library_name(self.library)}/dist/index.css",
self.library: "GridCellKind",
"/utils/helpers/dataeditor.js": ImportVar(
"$/utils/helpers/dataeditor.js": ImportVar(
tag="formatDataEditorCells", is_default=False, install=False
),
}

View File

@ -90,7 +90,7 @@ def load_dynamic_serializer():
for lib, names in component._get_all_imports().items():
formatted_lib_name = format_library_name(lib)
if (
not lib.startswith((".", "/"))
not lib.startswith((".", "/", "$/"))
and not lib.startswith("http")
and formatted_lib_name not in libs_in_window
):
@ -106,7 +106,7 @@ def load_dynamic_serializer():
# Rewrite imports from `/` to destructure from window
for ix, line in enumerate(module_code_lines[:]):
if line.startswith("import "):
if 'from "/' in line:
if 'from "$/' in line or 'from "/' in line:
module_code_lines[ix] = (
line.replace("import ", "const ", 1).replace(
" from ", " = window['__reflex'][", 1
@ -157,7 +157,7 @@ def load_dynamic_serializer():
merge_var_data=VarData.merge(
VarData(
imports={
f"/{constants.Dirs.STATE_PATH}": [
f"$/{constants.Dirs.STATE_PATH}": [
imports.ImportVar(tag="evalReactComponent"),
],
"react": [

View File

@ -187,7 +187,7 @@ class Form(BaseHTML):
"""
return {
"react": "useCallback",
f"/{Dirs.STATE_PATH}": ["getRefValue", "getRefValues"],
f"$/{Dirs.STATE_PATH}": ["getRefValue", "getRefValues"],
}
def add_hooks(self) -> list[str]:
@ -615,6 +615,42 @@ class Textarea(BaseHTML):
# Fired when a key is released
on_key_up: EventHandler[key_event]
@classmethod
def create(cls, *children, **props):
"""Create a textarea component.
Args:
*children: The children of the textarea.
**props: The properties of the textarea.
Returns:
The textarea component.
Raises:
ValueError: when `enter_key_submit` is combined with `on_key_down`.
"""
enter_key_submit = props.get("enter_key_submit")
auto_height = props.get("auto_height")
custom_attrs = props.setdefault("custom_attrs", {})
if enter_key_submit is not None:
enter_key_submit = Var.create(enter_key_submit)
if "on_key_down" in props:
raise ValueError(
"Cannot combine `enter_key_submit` with `on_key_down`.",
)
custom_attrs["on_key_down"] = Var(
_js_expr=f"(e) => enterKeySubmitOnKeyDown(e, {str(enter_key_submit)})",
_var_data=VarData.merge(enter_key_submit._get_all_var_data()),
)
if auto_height is not None:
auto_height = Var.create(auto_height)
custom_attrs["on_input"] = Var(
_js_expr=f"(e) => autoHeightOnInput(e, {str(auto_height)})",
_var_data=VarData.merge(auto_height._get_all_var_data()),
)
return super().create(*children, **props)
def _exclude_props(self) -> list[str]:
return super()._exclude_props() + [
"auto_height",
@ -634,28 +670,6 @@ class Textarea(BaseHTML):
custom_code.add(ENTER_KEY_SUBMIT_JS)
return custom_code
def _render(self) -> Tag:
tag = super()._render()
if self.enter_key_submit is not None:
if "on_key_down" in self.event_triggers:
raise ValueError(
"Cannot combine `enter_key_submit` with `on_key_down`.",
)
tag.add_props(
on_key_down=Var(
_js_expr=f"(e) => enterKeySubmitOnKeyDown(e, {str(self.enter_key_submit)})",
_var_data=VarData.merge(self.enter_key_submit._get_all_var_data()),
)
)
if self.auto_height is not None:
tag.add_props(
on_input=Var(
_js_expr=f"(e) => autoHeightOnInput(e, {str(self.auto_height)})",
_var_data=VarData.merge(self.auto_height._get_all_var_data()),
)
)
return tag
button = Button.create
fieldset = Fieldset.create

View File

@ -1376,10 +1376,10 @@ class Textarea(BaseHTML):
on_unmount: Optional[EventType[[]]] = None,
**props,
) -> "Textarea":
"""Create the component.
"""Create a textarea component.
Args:
*children: The children of the component.
*children: The children of the textarea.
auto_complete: Whether the form control should have autocomplete enabled
auto_focus: Automatically focuses the textarea when the page loads
auto_height: Automatically fit the content height to the text (use min-height with this prop)
@ -1419,10 +1419,13 @@ class Textarea(BaseHTML):
class_name: The class name for the component.
autofocus: Whether the component should take the focus once the page is loaded
custom_attrs: custom attribute
**props: The props of the component.
**props: The properties of the textarea.
Returns:
The component.
The textarea component.
Raises:
ValueError: when `enter_key_submit` is combined with `on_key_down`.
"""
...

View File

@ -3,10 +3,10 @@
import dataclasses
from typing import List, Optional
from reflex.components.component import Component, NoSSRComponent
from reflex.components.component import NoSSRComponent
from reflex.event import EventHandler, identity_event
from reflex.utils.imports import ImportDict
from reflex.vars.base import Var
from reflex.vars.base import LiteralVar, Var
@dataclasses.dataclass(frozen=True)
@ -92,6 +92,9 @@ class Moment(NoSSRComponent):
# Display the date in the given timezone.
tz: Var[str]
# The locale to use when rendering.
locale: Var[str]
# Fires when the date changes.
on_change: EventHandler[identity_event(str)]
@ -101,22 +104,15 @@ class Moment(NoSSRComponent):
Returns:
The import dict for the component.
"""
imports = {}
if isinstance(self.locale, LiteralVar):
imports[""] = f"moment/locale/{self.locale._var_value}"
elif self.locale is not None:
# If the user is using a variable for the locale, we can't know the
# value at compile time so import all locales available.
imports[""] = "moment/min/locales"
if self.tz is not None:
return {"moment-timezone": ""}
return {}
imports["moment-timezone"] = ""
@classmethod
def create(cls, *children, **props) -> Component:
"""Create a Moment component.
Args:
*children: The children of the component.
**props: The properties of the component.
Returns:
The Moment Component.
"""
comp = super().create(*children, **props)
if "tz" in props:
comp.lib_dependencies.append("moment-timezone")
return comp
return imports

View File

@ -51,6 +51,7 @@ class Moment(NoSSRComponent):
unix: Optional[Union[Var[bool], bool]] = None,
local: Optional[Union[Var[bool], bool]] = None,
tz: Optional[Union[Var[str], str]] = None,
locale: Optional[Union[Var[str], str]] = None,
style: Optional[Style] = None,
key: Optional[Any] = None,
id: Optional[Any] = None,
@ -75,7 +76,7 @@ class Moment(NoSSRComponent):
on_unmount: Optional[EventType[[]]] = None,
**props,
) -> "Moment":
"""Create a Moment component.
"""Create the component.
Args:
*children: The children of the component.
@ -99,15 +100,16 @@ class Moment(NoSSRComponent):
unix: Tells Moment to parse the given date value as a unix timestamp.
local: Outputs the result in local time.
tz: Display the date in the given timezone.
locale: The locale to use when rendering.
style: The style of the component.
key: A unique key for the component.
id: The id for the component.
class_name: The class name for the component.
autofocus: Whether the component should take the focus once the page is loaded
custom_attrs: custom attribute
**props: The properties of the component.
**props: The props of the component.
Returns:
The Moment Component.
The component.
"""
...

View File

@ -221,7 +221,7 @@ class Theme(RadixThemesComponent):
The import dict.
"""
_imports: ImportDict = {
"/utils/theme.js": [ImportVar(tag="theme", is_default=True)],
"$/utils/theme.js": [ImportVar(tag="theme", is_default=True)],
}
if get_config().tailwind is None:
# When tailwind is disabled, import the radix-ui styles directly because they will
@ -265,7 +265,7 @@ class ThemePanel(RadixThemesComponent):
class RadixThemesColorModeProvider(Component):
"""Next-themes integration for radix themes components."""
library = "/components/reflex/radix_themes_color_mode_provider.js"
library = "$/components/reflex/radix_themes_color_mode_provider.js"
tag = "RadixThemesColorModeProvider"
is_default = True

View File

@ -251,7 +251,7 @@ class Toaster(Component):
_js_expr=f"{toast_ref} = toast",
_var_data=VarData(
imports={
"/utils/state": [ImportVar(tag="refs")],
"$/utils/state": [ImportVar(tag="refs")],
self.library: [ImportVar(tag="toast", install=False)],
}
),

View File

@ -8,12 +8,12 @@ import os
import sys
import urllib.parse
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Union
from typing import Any, Dict, List, Optional, Set
from typing_extensions import get_type_hints
from reflex.utils.exceptions import ConfigError, EnvironmentVarValueError
from reflex.utils.types import value_inside_optional
from reflex.utils.types import GenericType, is_union, value_inside_optional
try:
import pydantic.v1 as pydantic
@ -157,11 +157,13 @@ def get_default_value_for_field(field: dataclasses.Field) -> Any:
)
def interpret_boolean_env(value: str) -> bool:
# TODO: Change all interpret_.* signatures to value: str, field: dataclasses.Field once we migrate rx.Config to dataclasses
def interpret_boolean_env(value: str, field_name: str) -> bool:
"""Interpret a boolean environment variable value.
Args:
value: The environment variable value.
field_name: The field name.
Returns:
The interpreted value.
@ -176,14 +178,15 @@ def interpret_boolean_env(value: str) -> bool:
return True
elif value.lower() in false_values:
return False
raise EnvironmentVarValueError(f"Invalid boolean value: {value}")
raise EnvironmentVarValueError(f"Invalid boolean value: {value} for {field_name}")
def interpret_int_env(value: str) -> int:
def interpret_int_env(value: str, field_name: str) -> int:
"""Interpret an integer environment variable value.
Args:
value: The environment variable value.
field_name: The field name.
Returns:
The interpreted value.
@ -194,14 +197,17 @@ def interpret_int_env(value: str) -> int:
try:
return int(value)
except ValueError as ve:
raise EnvironmentVarValueError(f"Invalid integer value: {value}") from ve
raise EnvironmentVarValueError(
f"Invalid integer value: {value} for {field_name}"
) from ve
def interpret_path_env(value: str) -> Path:
def interpret_path_env(value: str, field_name: str) -> Path:
"""Interpret a path environment variable value.
Args:
value: The environment variable value.
field_name: The field name.
Returns:
The interpreted value.
@ -211,16 +217,19 @@ def interpret_path_env(value: str) -> Path:
"""
path = Path(value)
if not path.exists():
raise EnvironmentVarValueError(f"Path does not exist: {path}")
raise EnvironmentVarValueError(f"Path does not exist: {path} for {field_name}")
return path
def interpret_env_var_value(value: str, field: dataclasses.Field) -> Any:
def interpret_env_var_value(
value: str, field_type: GenericType, field_name: str
) -> Any:
"""Interpret an environment variable value based on the field type.
Args:
value: The environment variable value.
field: The field.
field_type: The field type.
field_name: The field name.
Returns:
The interpreted value.
@ -228,20 +237,25 @@ def interpret_env_var_value(value: str, field: dataclasses.Field) -> Any:
Raises:
ValueError: If the value is invalid.
"""
field_type = value_inside_optional(field.type)
field_type = value_inside_optional(field_type)
if is_union(field_type):
raise ValueError(
f"Union types are not supported for environment variables: {field_name}."
)
if field_type is bool:
return interpret_boolean_env(value)
return interpret_boolean_env(value, field_name)
elif field_type is str:
return value
elif field_type is int:
return interpret_int_env(value)
return interpret_int_env(value, field_name)
elif field_type is Path:
return interpret_path_env(value)
return interpret_path_env(value, field_name)
else:
raise ValueError(
f"Invalid type for environment variable {field.name}: {field_type}. This is probably an issue in Reflex."
f"Invalid type for environment variable {field_name}: {field_type}. This is probably an issue in Reflex."
)
@ -316,7 +330,7 @@ class EnvironmentVariables:
field.type = type_hints.get(field.name) or field.type
value = (
interpret_env_var_value(raw_value, field)
interpret_env_var_value(raw_value, field.type, field.name)
if raw_value is not None
else get_default_value_for_field(field)
)
@ -387,7 +401,7 @@ class Config(Base):
telemetry_enabled: bool = True
# The bun path
bun_path: Union[str, Path] = constants.Bun.DEFAULT_PATH
bun_path: Path = constants.Bun.DEFAULT_PATH
# List of origins that are allowed to connect to the backend API.
cors_allowed_origins: List[str] = ["*"]
@ -484,17 +498,17 @@ class Config(Base):
Returns:
The updated config values.
Raises:
EnvVarValueError: If an environment variable is set to an invalid type.
"""
from reflex.utils.exceptions import EnvVarValueError
if self.env_file:
from dotenv import load_dotenv
try:
from dotenv import load_dotenv # type: ignore
# load env file if exists
load_dotenv(self.env_file, override=True)
except ImportError:
console.error(
"""The `python-dotenv` package is required to load environment variables from a file. Run `pip install "python-dotenv>=1.0.1"`."""
)
updated_values = {}
# Iterate over the fields.
@ -510,21 +524,11 @@ class Config(Base):
dedupe=True,
)
# Convert the env var to the expected type.
try:
if issubclass(field.type_, bool):
# special handling for bool values
env_var = env_var.lower() in ["true", "1", "yes"]
else:
env_var = field.type_(env_var)
except ValueError as ve:
console.error(
f"Could not convert {key.upper()}={env_var} to type {field.type_}"
)
raise EnvVarValueError from ve
# Interpret the value.
value = interpret_env_var_value(env_var, field.type_, field.name)
# Set the value.
updated_values[key] = env_var
updated_values[key] = value
return updated_values

View File

@ -35,7 +35,6 @@ ColorType = Literal[
"amber",
"gold",
"bronze",
"gray",
"accent",
"black",
"white",

View File

@ -114,8 +114,8 @@ class Imports(SimpleNamespace):
EVENTS = {
"react": [ImportVar(tag="useContext")],
f"/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
f"/{Dirs.STATE_PATH}": [ImportVar(tag=CompileVars.TO_EVENT)],
f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
f"$/{Dirs.STATE_PATH}": [ImportVar(tag=CompileVars.TO_EVENT)],
}

View File

@ -120,7 +120,7 @@ class Node(SimpleNamespace):
# The Node version.
VERSION = "22.10.0"
# The minimum required node version.
MIN_VERSION = "18.17.0"
MIN_VERSION = "18.18.0"
@classproperty
@classmethod
@ -173,17 +173,17 @@ class PackageJson(SimpleNamespace):
PATH = "package.json"
DEPENDENCIES = {
"@babel/standalone": "7.25.8",
"@babel/standalone": "7.26.0",
"@emotion/react": "11.13.3",
"axios": "1.7.7",
"json5": "2.2.3",
"next": "14.2.15",
"next": "15.0.1",
"next-sitemap": "4.2.3",
"next-themes": "0.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-focus-lock": "2.13.2",
"socket.io-client": "4.8.0",
"socket.io-client": "4.8.1",
"universal-cookie": "7.2.1",
}
DEV_DEPENDENCIES = {

View File

@ -394,7 +394,9 @@ class CallableEventSpec(EventSpec):
class EventChain(EventActionsMixin):
"""Container for a chain of events that will be executed in order."""
events: List[Union[EventSpec, EventVar]] = dataclasses.field(default_factory=list)
events: Sequence[Union[EventSpec, EventVar, EventCallback]] = dataclasses.field(
default_factory=list
)
args_spec: Optional[Union[Callable, Sequence[Callable]]] = dataclasses.field(
default=None
@ -1540,13 +1542,8 @@ class LiteralEventChainVar(ArgsFunctionOperation, LiteralVar, EventChainVar):
)
G = ParamSpec("G")
IndividualEventType = Union[EventSpec, EventHandler, Callable[G, Any], Var[Any]]
EventType = Union[IndividualEventType[G], List[IndividualEventType[G]]]
P = ParamSpec("P")
Q = ParamSpec("Q")
T = TypeVar("T")
V = TypeVar("V")
V2 = TypeVar("V2")
@ -1568,55 +1565,73 @@ if sys.version_info >= (3, 10):
"""
self.func = func
@property
def prevent_default(self):
"""Prevent default behavior.
Returns:
The event callback with prevent default behavior.
"""
return self
@property
def stop_propagation(self):
"""Stop event propagation.
Returns:
The event callback with stop propagation behavior.
"""
return self
@overload
def __get__(
self: EventCallback[[V], T], instance: None, owner
) -> Callable[[Union[Var[V], V]], EventSpec]: ...
def __call__(
self: EventCallback[Concatenate[V, Q], T], value: V | Var[V]
) -> EventCallback[Q, T]: ...
@overload
def __call__(
self: EventCallback[Concatenate[V, V2, Q], T],
value: V | Var[V],
value2: V2 | Var[V2],
) -> EventCallback[Q, T]: ...
@overload
def __call__(
self: EventCallback[Concatenate[V, V2, V3, Q], T],
value: V | Var[V],
value2: V2 | Var[V2],
value3: V3 | Var[V3],
) -> EventCallback[Q, T]: ...
@overload
def __call__(
self: EventCallback[Concatenate[V, V2, V3, V4, Q], T],
value: V | Var[V],
value2: V2 | Var[V2],
value3: V3 | Var[V3],
value4: V4 | Var[V4],
) -> EventCallback[Q, T]: ...
def __call__(self, *values) -> EventCallback: # type: ignore
"""Call the function with the values.
Args:
*values: The values to call the function with.
Returns:
The function with the values.
"""
return self.func(*values) # type: ignore
@overload
def __get__(
self: EventCallback[[V, V2], T], instance: None, owner
) -> Callable[[Union[Var[V], V], Union[Var[V2], V2]], EventSpec]: ...
@overload
def __get__(
self: EventCallback[[V, V2, V3], T], instance: None, owner
) -> Callable[
[Union[Var[V], V], Union[Var[V2], V2], Union[Var[V3], V3]],
EventSpec,
]: ...
@overload
def __get__(
self: EventCallback[[V, V2, V3, V4], T], instance: None, owner
) -> Callable[
[
Union[Var[V], V],
Union[Var[V2], V2],
Union[Var[V3], V3],
Union[Var[V4], V4],
],
EventSpec,
]: ...
@overload
def __get__(
self: EventCallback[[V, V2, V3, V4, V5], T], instance: None, owner
) -> Callable[
[
Union[Var[V], V],
Union[Var[V2], V2],
Union[Var[V3], V3],
Union[Var[V4], V4],
Union[Var[V5], V5],
],
EventSpec,
]: ...
self: EventCallback[P, T], instance: None, owner
) -> EventCallback[P, T]: ...
@overload
def __get__(self, instance, owner) -> Callable[P, T]: ...
def __get__(self, instance, owner) -> Callable:
def __get__(self, instance, owner) -> Callable: # type: ignore
"""Get the function with the instance bound to it.
Args:
@ -1643,6 +1658,9 @@ if sys.version_info >= (3, 10):
return func # type: ignore
else:
class EventCallback(Generic[P, T]):
"""A descriptor that wraps a function to be used as an event."""
def event_handler(func: Callable[P, T]) -> Callable[P, T]:
"""Wrap a function to be used as an event.
@ -1655,6 +1673,17 @@ else:
return func
G = ParamSpec("G")
IndividualEventType = Union[
EventSpec, EventHandler, Callable[G, Any], EventCallback[G, Any], Var[Any]
]
ItemOrList = Union[V, List[V]]
EventType = ItemOrList[IndividualEventType[G]]
class EventNamespace(types.SimpleNamespace):
"""A namespace for event related classes."""

View File

@ -21,7 +21,7 @@ NoValue = object()
_refs_import = {
f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")],
f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")],
}
@ -178,9 +178,12 @@ class ClientStateVar(Var):
if self._global_ref
else self._setter_name
)
_var_data = VarData(imports=_refs_import if self._global_ref else {})
if value is not NoValue:
# This is a hack to make it work like an EventSpec taking an arg
value_str = str(LiteralVar.create(value))
value_var = LiteralVar.create(value)
_var_data = VarData.merge(_var_data, value_var._get_all_var_data())
value_str = str(value_var)
if value_str.startswith("_"):
# remove patterns of ["*"] from the value_str using regex
@ -190,7 +193,7 @@ class ClientStateVar(Var):
setter = f"(() => {setter}({value_str}))"
return Var(
_js_expr=setter,
_var_data=VarData(imports=_refs_import if self._global_ref else {}),
_var_data=_var_data,
).to(FunctionVar, EventChain)
@property

View File

@ -38,7 +38,7 @@ def get_engine(url: str | None = None) -> sqlalchemy.engine.Engine:
url = url or conf.db_url
if url is None:
raise ValueError("No database url configured")
if environment.ALEMBIC_CONFIG.exists():
if not environment.ALEMBIC_CONFIG.exists():
console.warn(
"Database is not initialized, run [bold]reflex db init[/bold] first."
)

View File

@ -30,6 +30,7 @@ from typing import (
Set,
Tuple,
Type,
TypeVar,
Union,
cast,
get_args,
@ -79,6 +80,7 @@ from reflex.utils import console, format, path_ops, prerequisites, types
from reflex.utils.exceptions import (
ComputedVarShadowsBaseVars,
ComputedVarShadowsStateVar,
DynamicComponentInvalidSignature,
DynamicRouteArgShadowsStateVar,
EventHandlerShadowsBuiltInStateMethod,
ImmutableStateError,
@ -218,6 +220,7 @@ class EventHandlerSetVar(EventHandler):
Raises:
AttributeError: If the given Var name does not exist on the state.
EventHandlerValueError: If the given Var name is not a str
NotImplementedError: If the setter for the given Var is async
"""
from reflex.utils.exceptions import EventHandlerValueError
@ -226,11 +229,20 @@ class EventHandlerSetVar(EventHandler):
raise EventHandlerValueError(
f"Var name must be passed as a string, got {args[0]!r}"
)
handler = getattr(self.state_cls, constants.SETTER_PREFIX + args[0], None)
# Check that the requested Var setter exists on the State at compile time.
if getattr(self.state_cls, constants.SETTER_PREFIX + args[0], None) is None:
if handler is None:
raise AttributeError(
f"Variable `{args[0]}` cannot be set on `{self.state_cls.get_full_name()}`"
)
if asyncio.iscoroutinefunction(handler.fn):
raise NotImplementedError(
f"Setter for {args[0]} is async, which is not supported."
)
return super().__call__(*args)
@ -2051,11 +2063,23 @@ class BaseState(Base, ABC, extra=pydantic.Extra.allow):
"""
try:
return pickle.dumps((self._to_schema(), self))
except pickle.PicklingError:
console.warn(
except (pickle.PicklingError, AttributeError) as og_pickle_error:
error = (
f"Failed to serialize state {self.get_full_name()} due to unpicklable object. "
"This state will not be persisted."
"This state will not be persisted. "
)
try:
import dill
return dill.dumps((self._to_schema(), self))
except ImportError:
error += (
f"Pickle error: {og_pickle_error}. "
"Consider `pip install 'dill>=0.3.8'` for more exotic serialization support."
)
except (pickle.PicklingError, TypeError, ValueError) as ex:
error += f"Dill was also unable to pickle the state: {ex}"
console.warn(error)
return b""
@classmethod
@ -2095,6 +2119,51 @@ class State(BaseState):
is_hydrated: bool = False
T = TypeVar("T", bound=BaseState)
def dynamic(func: Callable[[T], Component]):
"""Create a dynamically generated components from a state class.
Args:
func: The function to generate the component.
Returns:
The dynamically generated component.
Raises:
DynamicComponentInvalidSignature: If the function does not have exactly one parameter.
DynamicComponentInvalidSignature: If the function does not have a type hint for the state class.
"""
number_of_parameters = len(inspect.signature(func).parameters)
func_signature = get_type_hints(func)
if "return" in func_signature:
func_signature.pop("return")
values = list(func_signature.values())
if number_of_parameters != 1:
raise DynamicComponentInvalidSignature(
"The function must have exactly one parameter, which is the state class."
)
if len(values) != 1:
raise DynamicComponentInvalidSignature(
"You must provide a type hint for the state class in the function."
)
state_class: Type[T] = values[0]
def wrapper() -> Component:
from reflex.components.base.fragment import fragment
return fragment(state_class._evaluate(lambda state: func(state)))
return wrapper
class FrontendEventExceptionState(State):
"""Substate for handling frontend exceptions."""
@ -2848,9 +2917,13 @@ class StateManagerDisk(StateManager):
for substate in state.get_substates():
substate_token = _substate_key(client_token, substate)
fresh_instance = await root_state.get_state(substate)
instance = await self.load_state(substate_token)
if instance is None:
instance = await root_state.get_state(substate)
if instance is not None:
# Ensure all substates exist, even if they weren't serialized previously.
instance.substates = fresh_instance.substates
else:
instance = fresh_instance
state.substates[substate.get_name()] = instance
instance.parent_state = state

View File

@ -23,7 +23,7 @@ LiteralColorMode = Literal["system", "light", "dark"]
# Reference the global ColorModeContext
color_mode_imports = {
f"/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="ColorModeContext")],
f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="ColorModeContext")],
"react": [ImportVar(tag="useContext")],
}

View File

@ -249,7 +249,8 @@ class AppHarness:
return textwrap.dedent(source)
def _initialize_app(self):
os.environ["TELEMETRY_ENABLED"] = "" # disable telemetry reporting for tests
# disable telemetry reporting for tests
os.environ["TELEMETRY_ENABLED"] = "false"
self.app_path.mkdir(parents=True, exist_ok=True)
if self.app_source is not None:
app_globals = self._get_globals_from_signature(self.app_source)

View File

@ -143,3 +143,7 @@ class StateSchemaMismatchError(ReflexError, TypeError):
class EnvironmentVarValueError(ReflexError, ValueError):
"""Raised when an environment variable is set to an invalid value."""
class DynamicComponentInvalidSignature(ReflexError, TypeError):
"""Raised when a dynamic component has an invalid signature."""

View File

@ -23,6 +23,12 @@ def merge_imports(
for lib, fields in (
import_dict if isinstance(import_dict, tuple) else import_dict.items()
):
# If the lib is an absolute path, we need to prefix it with a $
lib = (
"$" + lib
if lib.startswith(("/utils/", "/components/", "/styles/", "/public/"))
else lib
)
if isinstance(fields, (list, tuple, set)):
all_imports[lib].extend(
(

View File

@ -151,30 +151,40 @@ class VarData:
"""
return dict((k, list(v)) for k, v in self.imports)
@classmethod
def merge(cls, *others: VarData | None) -> VarData | None:
def merge(*all: VarData | None) -> VarData | None:
"""Merge multiple var data objects.
Args:
*others: The var data objects to merge.
*all: The var data objects to merge.
Returns:
The merged var data object.
# noqa: DAR102 *all
"""
state = ""
field_name = ""
_imports = {}
hooks = {}
for var_data in others:
if var_data is None:
continue
state = state or var_data.state
field_name = field_name or var_data.field_name
_imports = imports.merge_imports(_imports, var_data.imports)
hooks.update(
var_data.hooks
if isinstance(var_data.hooks, dict)
else {k: None for k in var_data.hooks}
all_var_datas = list(filter(None, all))
if not all_var_datas:
return None
if len(all_var_datas) == 1:
return all_var_datas[0]
# Get the first non-empty field name or default to empty string.
field_name = next(
(var_data.field_name for var_data in all_var_datas if var_data.field_name),
"",
)
# Get the first non-empty state or default to empty string.
state = next(
(var_data.state for var_data in all_var_datas if var_data.state), ""
)
hooks = {hook: None for var_data in all_var_datas for hook in var_data.hooks}
_imports = imports.merge_imports(
*(var_data.imports for var_data in all_var_datas)
)
if state or _imports or hooks or field_name:
@ -184,6 +194,7 @@ class VarData:
imports=_imports,
hooks=hooks,
)
return None
def __bool__(self) -> bool:
@ -217,7 +228,7 @@ class VarData:
): None
},
imports={
f"/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")],
f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")],
"react": [ImportVar(tag="useContext")],
},
)
@ -956,7 +967,7 @@ class Var(Generic[VAR_TYPE]):
_js_expr="refs",
_var_data=VarData(
imports={
f"/{constants.Dirs.STATE_PATH}": [imports.ImportVar(tag="refs")]
f"$/{constants.Dirs.STATE_PATH}": [imports.ImportVar(tag="refs")]
}
),
).to(ObjectVar, Dict[str, str])
@ -2530,7 +2541,7 @@ def get_uuid_string_var() -> Var:
unique_uuid_var = get_unique_variable_name()
unique_uuid_var_data = VarData(
imports={
f"/{constants.Dirs.STATE_PATH}": {ImportVar(tag="generateUUID")}, # type: ignore
f"$/{constants.Dirs.STATE_PATH}": {ImportVar(tag="generateUUID")}, # type: ignore
"react": "useMemo",
},
hooks={f"const {unique_uuid_var} = useMemo(generateUUID, [])": None},

View File

@ -1090,7 +1090,7 @@ boolean_types = Union[BooleanVar, bool]
_IS_TRUE_IMPORT: ImportDict = {
f"/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
f"$/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
}

View File

@ -0,0 +1,59 @@
"""Integration tests for a stateless app."""
from typing import Generator
import httpx
import pytest
from playwright.sync_api import Page, expect
import reflex as rx
from reflex.testing import AppHarness
def StatelessApp():
"""A stateless app that renders a heading."""
import reflex as rx
def index():
return rx.heading("This is a stateless app")
app = rx.App()
app.add_page(index)
@pytest.fixture(scope="module")
def stateless_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
"""Create a stateless app AppHarness.
Args:
tmp_path_factory: pytest fixture for creating temporary directories.
Yields:
AppHarness: A harness for testing the stateless app.
"""
with AppHarness.create(
root=tmp_path_factory.mktemp("stateless_app"),
app_source=StatelessApp, # type: ignore
) as harness:
yield harness
def test_statelessness(stateless_app: AppHarness, page: Page):
"""Test that the stateless app renders a heading but backend/_event is not mounted.
Args:
stateless_app: A harness for testing the stateless app.
page: A Playwright page.
"""
assert stateless_app.frontend_url is not None
assert stateless_app.backend is not None
assert stateless_app.backend.started
res = httpx.get(rx.config.get_config().api_url + "/_event")
assert res.status_code == 404
res2 = httpx.get(rx.config.get_config().api_url + "/ping")
assert res2.status_code == 200
page.goto(stateless_app.frontend_url)
expect(page.get_by_role("heading")).to_have_text("This is a stateless app")

View File

@ -3,10 +3,18 @@
from typing import Generator
import pytest
from selenium.webdriver.common.by import By
from playwright.sync_api import Page
from reflex.testing import AppHarness
expected_col_headers = ["Name", "Age", "Location"]
expected_row_headers = ["John", "Jane", "Joe"]
expected_cells_data = [
["30", "New York"],
["31", "San Fransisco"],
["32", "Los Angeles"],
]
def Table():
"""App using table component."""
@ -17,11 +25,6 @@ def Table():
@app.add_page
def index():
return rx.center(
rx.input(
id="token",
value=rx.State.router.session.client_token,
is_read_only=True,
),
rx.table.root(
rx.table.header(
rx.table.row(
@ -53,7 +56,7 @@ def Table():
@pytest.fixture()
def table(tmp_path_factory) -> Generator[AppHarness, None, None]:
def table_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
"""Start Table app at tmp_path via AppHarness.
Args:
@ -71,47 +74,27 @@ def table(tmp_path_factory) -> Generator[AppHarness, None, None]:
yield harness
@pytest.fixture
def driver(table: AppHarness):
"""GEt an instance of the browser open to the table app.
Args:
table: harness for Table app
Yields:
WebDriver instance.
"""
driver = table.frontend()
try:
token_input = driver.find_element(By.ID, "token")
assert token_input
# wait for the backend connection to send the token
token = table.poll_for_value(token_input)
assert token is not None
yield driver
finally:
driver.quit()
def test_table(driver, table: AppHarness):
def test_table(page: Page, table_app: AppHarness):
"""Test that a table component is rendered properly.
Args:
driver: Selenium WebDriver open to the app
table: Harness for Table app
table_app: Harness for Table app
page: Playwright page instance
"""
assert table.app_instance is not None, "app is not running"
assert table_app.frontend_url is not None, "frontend url is not available"
thead = driver.find_element(By.TAG_NAME, "thead")
# poll till page is fully loaded.
table.poll_for_content(element=thead)
# check headers
assert thead.find_element(By.TAG_NAME, "tr").text == "Name Age Location"
# check first row value
assert (
driver.find_element(By.TAG_NAME, "tbody")
.find_elements(By.TAG_NAME, "tr")[0]
.text
== "John 30 New York"
)
page.goto(table_app.frontend_url)
table = page.get_by_role("table")
# Check column headers
headers = table.get_by_role("columnheader").all_inner_texts()
assert headers == expected_col_headers
# Check rows headers
rows = table.get_by_role("rowheader").all_inner_texts()
assert rows == expected_row_headers
# Check cells
rows = table.get_by_role("cell").all_inner_texts()
for i, expected_row in enumerate(expected_cells_data):
assert [rows[idx := i * 2], rows[idx + 1]] == expected_row

View File

@ -12,7 +12,7 @@ def test_websocket_target_url():
var_data = url._get_all_var_data()
assert var_data is not None
assert sorted(tuple((key for key, _ in var_data.imports))) == sorted(
("/utils/state", "/env.json")
("$/utils/state", "$/env.json")
)
@ -22,10 +22,10 @@ def test_connection_banner():
assert sorted(tuple(_imports)) == sorted(
(
"react",
"/utils/context",
"/utils/state",
"$/utils/context",
"$/utils/state",
"@radix-ui/themes@^3.0.0",
"/env.json",
"$/env.json",
)
)
@ -40,10 +40,10 @@ def test_connection_modal():
assert sorted(tuple(_imports)) == sorted(
(
"react",
"/utils/context",
"/utils/state",
"$/utils/context",
"$/utils/state",
"@radix-ui/themes@^3.0.0",
"/env.json",
"$/env.json",
)
)

View File

@ -237,9 +237,12 @@ def test_add_page_default_route(app: App, index_page, about_page):
about_page: The about page.
"""
assert app.pages == {}
assert app.unevaluated_pages == {}
app.add_page(index_page)
app._compile_page("index")
assert app.pages.keys() == {"index"}
app.add_page(about_page)
app._compile_page("about")
assert app.pages.keys() == {"index", "about"}
@ -252,8 +255,9 @@ def test_add_page_set_route(app: App, index_page, windows_platform: bool):
windows_platform: Whether the system is windows.
"""
route = "test" if windows_platform else "/test"
assert app.pages == {}
assert app.unevaluated_pages == {}
app.add_page(index_page, route=route)
app._compile_page("test")
assert app.pages.keys() == {"test"}
@ -267,8 +271,9 @@ def test_add_page_set_route_dynamic(index_page, windows_platform: bool):
app = App(state=EmptyState)
assert app.state is not None
route = "/test/[dynamic]"
assert app.pages == {}
assert app.unevaluated_pages == {}
app.add_page(index_page, route=route)
app._compile_page("test/[dynamic]")
assert app.pages.keys() == {"test/[dynamic]"}
assert "dynamic" in app.state.computed_vars
assert app.state.computed_vars["dynamic"]._deps(objclass=EmptyState) == {
@ -286,9 +291,9 @@ def test_add_page_set_route_nested(app: App, index_page, windows_platform: bool)
windows_platform: Whether the system is windows.
"""
route = "test\\nested" if windows_platform else "/test/nested"
assert app.pages == {}
assert app.unevaluated_pages == {}
app.add_page(index_page, route=route)
assert app.pages.keys() == {route.strip(os.path.sep)}
assert app.unevaluated_pages.keys() == {route.strip(os.path.sep)}
def test_add_page_invalid_api_route(app: App, index_page):
@ -1238,6 +1243,7 @@ def test_overlay_component(
app.add_page(rx.box("Index"), route="/test")
# overlay components are wrapped during compile only
app._compile_page("test")
app._setup_overlay_component()
page = app.pages["test"]
@ -1365,6 +1371,7 @@ def test_app_state_determination():
# Add a page with `on_load` enables state.
a1.add_page(rx.box("About"), route="/about", on_load=rx.console_log(""))
a1._compile_page("about")
assert a1.state is not None
a2 = App()
@ -1372,6 +1379,7 @@ def test_app_state_determination():
# Referencing a state Var enables state.
a2.add_page(rx.box(rx.text(GenState.value)), route="/")
a2._compile_page("index")
assert a2.state is not None
a3 = App()
@ -1379,6 +1387,7 @@ def test_app_state_determination():
# Referencing router enables state.
a3.add_page(rx.box(rx.text(State.router.page.full_path)), route="/")
a3._compile_page("index")
assert a3.state is not None
a4 = App()
@ -1390,6 +1399,7 @@ def test_app_state_determination():
a4.add_page(
rx.box(rx.button("Click", on_click=DynamicState.on_counter)), route="/page2"
)
a4._compile_page("page2")
assert a4.state is not None
@ -1469,6 +1479,9 @@ def test_add_page_component_returning_tuple():
app.add_page(index) # type: ignore
app.add_page(page2) # type: ignore
app._compile_page("index")
app._compile_page("page2")
assert isinstance((fragment_wrapper := app.pages["index"].children[0]), Fragment)
assert isinstance((first_text := fragment_wrapper.children[0]), Text)
assert str(first_text.children[0].contents) == '"first"' # type: ignore

View File

@ -1,5 +1,7 @@
import multiprocessing
import os
from pathlib import Path
from typing import Any, Dict
import pytest
@ -42,7 +44,12 @@ def test_set_app_name(base_config_values):
("TELEMETRY_ENABLED", True),
],
)
def test_update_from_env(base_config_values, monkeypatch, env_var, value):
def test_update_from_env(
base_config_values: Dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
env_var: str,
value: Any,
):
"""Test that environment variables override config values.
Args:
@ -57,6 +64,29 @@ def test_update_from_env(base_config_values, monkeypatch, env_var, value):
assert getattr(config, env_var.lower()) == value
def test_update_from_env_path(
base_config_values: Dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
):
"""Test that environment variables override config values.
Args:
base_config_values: Config values.
monkeypatch: The pytest monkeypatch object.
tmp_path: The pytest tmp_path fixture object.
"""
monkeypatch.setenv("BUN_PATH", "/test")
assert os.environ.get("BUN_PATH") == "/test"
with pytest.raises(ValueError):
rx.Config(**base_config_values)
monkeypatch.setenv("BUN_PATH", str(tmp_path))
assert os.environ.get("BUN_PATH") == str(tmp_path)
config = rx.Config(**base_config_values)
assert config.bun_path == tmp_path
@pytest.mark.parametrize(
"kwargs, expected",
[

View File

@ -106,6 +106,7 @@ class TestState(BaseState):
fig: Figure = Figure()
dt: datetime.datetime = datetime.datetime.fromisoformat("1989-11-09T18:53:00+01:00")
_backend: int = 0
asynctest: int = 0
@ComputedVar
def sum(self) -> float:
@ -129,6 +130,14 @@ class TestState(BaseState):
"""Do something."""
pass
async def set_asynctest(self, value: int):
"""Set the asynctest value. Intentionally overwrite the default setter with an async one.
Args:
value: The new value.
"""
self.asynctest = value
class ChildState(TestState):
"""A child state fixture."""
@ -313,6 +322,7 @@ def test_class_vars(test_state):
"upper",
"fig",
"dt",
"asynctest",
}
@ -733,6 +743,7 @@ def test_reset(test_state, child_state):
"mapping",
"dt",
"_backend",
"asynctest",
}
# The dirty vars should be reset.
@ -3179,6 +3190,13 @@ async def test_setvar(mock_app: rx.App, token: str):
TestState.setvar(42, 42)
@pytest.mark.asyncio
async def test_setvar_async_setter():
"""Test that overridden async setters raise Exception when used with setvar."""
with pytest.raises(NotImplementedError):
TestState.setvar("asynctest", 42)
@pytest.mark.skipif("REDIS_URL" not in os.environ, reason="Test requires redis")
@pytest.mark.parametrize(
"expiration_kwargs, expected_values",
@ -3313,3 +3331,68 @@ def test_assignment_to_undeclared_vars():
state.handle_supported_regular_vars()
state.handle_non_var()
@pytest.mark.asyncio
async def test_deserialize_gc_state_disk(token):
"""Test that a state can be deserialized from disk with a grandchild state.
Args:
token: A token.
"""
class Root(BaseState):
pass
class State(Root):
num: int = 42
class Child(State):
foo: str = "bar"
dsm = StateManagerDisk(state=Root)
async with dsm.modify_state(token) as root:
s = await root.get_state(State)
s.num += 1
c = await root.get_state(Child)
assert s._get_was_touched()
assert not c._get_was_touched()
dsm2 = StateManagerDisk(state=Root)
root = await dsm2.get_state(token)
s = await root.get_state(State)
assert s.num == 43
c = await root.get_state(Child)
assert c.foo == "bar"
class Obj(Base):
"""A object containing a callable for testing fallback pickle."""
_f: Callable
def test_fallback_pickle():
"""Test that state serialization will fall back to dill."""
class DillState(BaseState):
_o: Optional[Obj] = None
_f: Optional[Callable] = None
_g: Any = None
state = DillState(_reflex_internal_init=True) # type: ignore
state._o = Obj(_f=lambda: 42)
state._f = lambda: 420
pk = state._serialize()
unpickled_state = BaseState._deserialize(pk)
assert unpickled_state._f() == 420
assert unpickled_state._o._f() == 42
# Some object, like generator, are still unpicklable with dill.
state._g = (i for i in range(10))
pk = state._serialize()
assert len(pk) == 0
with pytest.raises(EOFError):
BaseState._deserialize(pk)

View File

@ -601,6 +601,7 @@ formatted_router = {
"sum": 3.14,
"upper": "",
"router": formatted_router,
"asynctest": 0,
},
ChildState.get_full_name(): {
"count": 23,