Skip to content

Commit 9bd6ad1

Browse files
Merge branch 'main', remote-tracking branch 'upstream' into explicit-event-id-minification
3 parents e713b5b + 7607fa3 + 5716a9e commit 9bd6ad1

17 files changed

Lines changed: 516 additions & 53 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
2+
"image": "mcr.microsoft.com/devcontainers/python:3.14-trixie",
33
"postCreateCommand": "/bin/bash -c 'python -m pip install uv && python -m uv sync & git clone https://github.com/reflex-dev/reflex-examples; wait'",
44
"forwardPorts": [3000, 8000],
55
"portsAttributes": {

reflex/app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,8 +615,13 @@ def __call__(self) -> ASGIApp:
615615
Returns:
616616
The backend api.
617617
"""
618+
from reflex.assets import remove_stale_external_asset_symlinks
618619
from reflex.vars.base import GLOBAL_CACHE
619620

621+
# Clean up stale symlinks in assets/external/ before compiling, so that
622+
# rx.asset(shared=True) symlink re-creation doesn't trigger further reloads.
623+
remove_stale_external_asset_symlinks()
624+
620625
self._compile(prerender_routes=should_prerender_routes())
621626

622627
config = get_config()

reflex/assets.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@
77
from reflex.environment import EnvironmentVariables
88

99

10+
def remove_stale_external_asset_symlinks():
11+
"""Remove broken symlinks and empty directories in assets/external/.
12+
13+
When a Python module directory that uses rx.asset(shared=True) is renamed
14+
or deleted, stale symlinks remain in assets/external/ pointing to the old
15+
path. This cleanup prevents issues with file watchers detecting symlink
16+
re-creation during import.
17+
"""
18+
external_dir = (
19+
Path.cwd() / constants.Dirs.APP_ASSETS / constants.Dirs.EXTERNAL_APP_ASSETS
20+
)
21+
if not external_dir.exists():
22+
return
23+
24+
# Remove broken symlinks.
25+
broken = [
26+
p
27+
for p in external_dir.rglob("*")
28+
if p.is_symlink() and not p.resolve().exists()
29+
]
30+
for path in broken:
31+
path.unlink()
32+
33+
# Remove empty directories left behind (deepest first).
34+
for dirpath in sorted(external_dir.rglob("*"), reverse=True):
35+
if dirpath.is_dir() and not dirpath.is_symlink() and not any(dirpath.iterdir()):
36+
dirpath.rmdir()
37+
38+
1039
def asset(
1140
path: str,
1241
shared: bool = False,
@@ -92,6 +121,11 @@ def asset(
92121
if not dst_file.exists() and (
93122
not dst_file.is_symlink() or dst_file.resolve() != src_file_shared.resolve()
94123
):
95-
dst_file.symlink_to(src_file_shared)
124+
try:
125+
dst_file.symlink_to(src_file_shared)
126+
except FileExistsError:
127+
# This happens when Simon builds the app on a bind mount in a docker container.
128+
dst_file.unlink()
129+
dst_file.symlink_to(src_file_shared)
96130

97131
return f"/{external}/{subfolder}/{path}"

reflex/compiler/templates.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ def vite_config_template(
528528
force_full_reload: bool,
529529
experimental_hmr: bool,
530530
sourcemap: bool | Literal["inline", "hidden"],
531+
allowed_hosts: bool | list[str] = False,
531532
):
532533
"""Template for vite.config.js.
533534
@@ -537,10 +538,17 @@ def vite_config_template(
537538
force_full_reload: Whether to force a full reload on changes.
538539
experimental_hmr: Whether to enable experimental HMR features.
539540
sourcemap: The sourcemap configuration.
541+
allowed_hosts: Allow all hosts (True), specific hosts (list of strings), or only localhost (False).
540542
541543
Returns:
542544
Rendered vite.config.js content as string.
543545
"""
546+
if allowed_hosts is True:
547+
allowed_hosts_line = "\n allowedHosts: true,"
548+
elif isinstance(allowed_hosts, list) and allowed_hosts:
549+
allowed_hosts_line = f"\n allowedHosts: {json.dumps(allowed_hosts)},"
550+
else:
551+
allowed_hosts_line = ""
544552
return rf"""import {{ fileURLToPath, URL }} from "url";
545553
import {{ reactRouter }} from "@react-router/dev/vite";
546554
import {{ defineConfig }} from "vite";
@@ -612,7 +620,7 @@ def vite_config_template(
612620
hmr: {"true" if experimental_hmr else "false"},
613621
}},
614622
server: {{
615-
port: process.env.PORT,
623+
port: process.env.PORT,{allowed_hosts_line}
616624
hmr: {"true" if hmr else "false"},
617625
watch: {{
618626
ignored: [

reflex/config.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ class BaseConfig:
213213
dataclasses.field(default=("*",))
214214
)
215215

216+
# Allowed hosts for the Vite dev server. Set to True to allow all hosts,
217+
# or provide a list of hostnames (e.g. ["myservice.local"]) to allow specific ones.
218+
# Prevents 403 errors in Docker, Codespaces, reverse proxies, etc.
219+
vite_allowed_hosts: bool | list[str] = False
220+
216221
# Whether to use React strict mode.
217222
react_strict_mode: bool = True
218223

@@ -254,8 +259,8 @@ class BaseConfig:
254259
# List of plugins to use in the app.
255260
plugins: list[Plugin] = dataclasses.field(default_factory=list)
256261

257-
# List of fully qualified import paths of plugins to disable in the app (e.g. reflex.plugins.sitemap.SitemapPlugin).
258-
disable_plugins: list[str] = dataclasses.field(default_factory=list)
262+
# List of plugin types to disable in the app.
263+
disable_plugins: list[type[Plugin]] = dataclasses.field(default_factory=list)
259264

260265
# The transport method for client-server communication.
261266
transport: Literal["websocket", "polling"] = "websocket"
@@ -353,6 +358,9 @@ def _post_init(self, **kwargs):
353358
for key, env_value in env_kwargs.items():
354359
setattr(self, key, env_value)
355360

361+
# Normalize disable_plugins: convert strings and Plugin subclasses to instances.
362+
self._normalize_disable_plugins()
363+
356364
# Add builtin plugins if not disabled.
357365
if not self._skip_plugins_checks:
358366
self._add_builtin_plugins()
@@ -369,16 +377,52 @@ def _post_init(self, **kwargs):
369377
msg = f"{self._prefixes[0]}REDIS_URL is required when using the redis state manager."
370378
raise ConfigError(msg)
371379

380+
def _normalize_disable_plugins(self):
381+
"""Normalize disable_plugins list entries to Plugin subclasses.
382+
383+
Handles backward compatibility by converting strings (fully qualified
384+
import paths) and Plugin instances to their associated classes.
385+
"""
386+
normalized: list[type[Plugin]] = []
387+
for entry in self.disable_plugins:
388+
if isinstance(entry, type) and issubclass(entry, Plugin):
389+
normalized.append(entry)
390+
elif isinstance(entry, Plugin):
391+
normalized.append(type(entry))
392+
elif isinstance(entry, str):
393+
console.deprecate(
394+
feature_name="Passing strings to disable_plugins",
395+
reason="pass Plugin classes directly instead, e.g. disable_plugins=[SitemapPlugin]",
396+
deprecation_version="0.8.28",
397+
removal_version="0.9.0",
398+
)
399+
try:
400+
from reflex.environment import interpret_plugin_class_env
401+
402+
normalized.append(
403+
interpret_plugin_class_env(entry, "disable_plugins")
404+
)
405+
except Exception:
406+
console.warn(
407+
f"Failed to import plugin from string {entry!r} in disable_plugins. "
408+
"Please pass Plugin subclasses directly.",
409+
)
410+
else:
411+
console.warn(
412+
f"reflex.Config.disable_plugins should contain Plugin subclasses, but got {entry!r}.",
413+
)
414+
self.disable_plugins = normalized
415+
372416
def _add_builtin_plugins(self):
373417
"""Add the builtin plugins to the config."""
374418
for plugin in _PLUGINS_ENABLED_BY_DEFAULT:
375419
plugin_name = plugin.__module__ + "." + plugin.__qualname__
376-
if plugin_name not in self.disable_plugins:
420+
if plugin not in self.disable_plugins:
377421
if not any(isinstance(p, plugin) for p in self.plugins):
378422
console.warn(
379423
f"`{plugin_name}` plugin is enabled by default, but not explicitly added to the config. "
380424
"If you want to use it, please add it to the `plugins` list in your config inside of `rxconfig.py`. "
381-
f"To disable this plugin, set `disable_plugins` to `{[plugin_name, *self.disable_plugins]!r}`.",
425+
f"To disable this plugin, add `{plugin.__name__}` to the `disable_plugins` list.",
382426
)
383427
self.plugins.append(plugin())
384428
else:
@@ -389,16 +433,9 @@ def _add_builtin_plugins(self):
389433
)
390434

391435
for disabled_plugin in self.disable_plugins:
392-
if not isinstance(disabled_plugin, str):
393-
console.warn(
394-
f"reflex.Config.disable_plugins should only contain strings, but got {disabled_plugin!r}. "
395-
)
396-
if not any(
397-
plugin.__module__ + "." + plugin.__qualname__ == disabled_plugin
398-
for plugin in _PLUGINS_ENABLED_BY_DEFAULT
399-
):
436+
if disabled_plugin not in _PLUGINS_ENABLED_BY_DEFAULT:
400437
console.warn(
401-
f"`{disabled_plugin}` is disabled in the config, but it is not a built-in plugin. "
438+
f"`{disabled_plugin!r}` is disabled in the config, but it is not a built-in plugin. "
402439
"Please remove it from the `disable_plugins` list in your config inside of `rxconfig.py`.",
403440
)
404441

reflex/constants/installer.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,20 @@ class Commands(SimpleNamespace):
106106

107107
DEV = "react-router dev --host"
108108
EXPORT = "react-router build"
109-
PROD = "sirv ./build/client --single 404.html --host"
109+
110+
@staticmethod
111+
def get_prod_command(frontend_path: str = "") -> str:
112+
"""Get the prod command with the correct 404.html path for the given frontend_path.
113+
114+
Args:
115+
frontend_path: The frontend path prefix (e.g. "/app").
116+
117+
Returns:
118+
The sirv command with the correct --single fallback path.
119+
"""
120+
stripped = frontend_path.strip("/")
121+
fallback = f"{stripped}/404.html" if stripped else "404.html"
122+
return f"sirv ./build/client --single {fallback} --host"
110123

111124
PATH = "package.json"
112125

reflex/environment.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,17 @@ def interpret_path_env(value: str, field_name: str) -> Path:
149149
return Path(value)
150150

151151

152-
def interpret_plugin_env(value: str, field_name: str) -> Plugin:
153-
"""Interpret a plugin environment variable value.
152+
def interpret_plugin_class_env(value: str, field_name: str) -> type[Plugin]:
153+
"""Interpret an environment variable value as a Plugin subclass.
154+
155+
Resolves a fully qualified import path to the Plugin subclass it refers to.
154156
155157
Args:
156-
value: The environment variable value.
158+
value: The environment variable value (e.g. "reflex.plugins.sitemap.SitemapPlugin").
157159
field_name: The field name.
158160
159161
Returns:
160-
The interpreted value.
162+
The Plugin subclass.
161163
162164
Raises:
163165
EnvironmentVarValueError: If the value is invalid.
@@ -184,10 +186,30 @@ def interpret_plugin_env(value: str, field_name: str) -> Plugin:
184186
msg = f"Invalid plugin class: {plugin_name!r} for {field_name}. Must be a subclass of Plugin."
185187
raise EnvironmentVarValueError(msg)
186188

189+
return plugin_class
190+
191+
192+
def interpret_plugin_env(value: str, field_name: str) -> Plugin:
193+
"""Interpret a plugin environment variable value.
194+
195+
Resolves a fully qualified import path and returns an instance of the Plugin.
196+
197+
Args:
198+
value: The environment variable value (e.g. "reflex.plugins.sitemap.SitemapPlugin").
199+
field_name: The field name.
200+
201+
Returns:
202+
An instance of the Plugin subclass.
203+
204+
Raises:
205+
EnvironmentVarValueError: If the value is invalid.
206+
"""
207+
plugin_class = interpret_plugin_class_env(value, field_name)
208+
187209
try:
188210
return plugin_class()
189211
except Exception as e:
190-
msg = f"Failed to instantiate plugin {plugin_name!r} for {field_name}: {e}"
212+
msg = f"Failed to instantiate plugin {plugin_class.__name__!r} for {field_name}: {e}"
191213
raise EnvironmentVarValueError(msg) from e
192214

193215

@@ -243,8 +265,14 @@ def interpret_env_var_value(
243265
field_type = value_inside_optional(field_type)
244266

245267
if is_union(field_type):
246-
msg = f"Union types are not supported for environment variables: {field_name}."
247-
raise ValueError(msg)
268+
errors = []
269+
for arg in (union_types := get_args(field_type)):
270+
try:
271+
return interpret_env_var_value(value, arg, field_name)
272+
except (ValueError, EnvironmentVarValueError) as e: # noqa: PERF203
273+
errors.append(e)
274+
msg = f"Could not interpret {value!r} for {field_name} as any of {union_types}: {errors}"
275+
raise EnvironmentVarValueError(msg)
248276

249277
value = value.strip()
250278

@@ -268,6 +296,14 @@ def interpret_env_var_value(
268296
return interpret_existing_path_env(value, field_name)
269297
if field_type is Plugin:
270298
return interpret_plugin_env(value, field_name)
299+
if get_origin(field_type) is type:
300+
type_args = get_args(field_type)
301+
if (
302+
type_args
303+
and isinstance(type_args[0], type)
304+
and issubclass(type_args[0], Plugin)
305+
):
306+
return interpret_plugin_class_env(value, field_name)
271307
if get_origin(field_type) is Literal:
272308
literal_values = get_args(field_type)
273309
for literal_value in literal_values:

reflex/istate/proxy.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import functools
99
import inspect
1010
import json
11+
import sys
1112
from collections.abc import Callable, Sequence
1213
from importlib.util import find_spec
1314
from types import MethodType
@@ -132,15 +133,20 @@ async def __aenter__(self) -> Self:
132133
raise ImmutableStateError(msg)
133134

134135
await self._self_actx_lock.acquire()
135-
self._self_actx_lock_holder = current_task
136-
self._self_actx = self._self_app.modify_state(
137-
token=self._self_substate_token, background=True
138-
)
139-
mutable_state = await self._self_actx.__aenter__()
140-
super().__setattr__(
141-
"__wrapped__", mutable_state.get_substate(self._self_substate_path)
142-
)
143-
self._self_mutable = True
136+
try:
137+
self._self_actx_lock_holder = current_task
138+
self._self_actx = self._self_app.modify_state(
139+
token=self._self_substate_token, background=True
140+
)
141+
mutable_state = await self._self_actx.__aenter__()
142+
self._self_mutable = True
143+
super().__setattr__(
144+
"__wrapped__", mutable_state.get_substate(self._self_substate_path)
145+
)
146+
except (Exception, asyncio.CancelledError):
147+
# Restore the proxy to a consistent state since __aexit__ will not be called when __aenter__ raises.
148+
await self.__aexit__(*sys.exc_info())
149+
raise
144150
return self
145151

146152
async def __aexit__(self, *exc_info: Any) -> None:
@@ -154,15 +160,14 @@ async def __aexit__(self, *exc_info: Any) -> None:
154160
if self._self_parent_state_proxy is not None:
155161
await self._self_parent_state_proxy.__aexit__(*exc_info)
156162
return
157-
if self._self_actx is None:
158-
return
159-
self._self_mutable = False
160163
try:
161-
await self._self_actx.__aexit__(*exc_info)
164+
if self._self_mutable and self._self_actx is not None:
165+
await self._self_actx.__aexit__(*exc_info)
162166
finally:
167+
self._self_actx = None
168+
self._self_mutable = False
163169
self._self_actx_lock_holder = None
164170
self._self_actx_lock.release()
165-
self._self_actx = None
166171

167172
def __enter__(self):
168173
"""Enter the regular context manager protocol.
@@ -572,6 +577,8 @@ def __getattr__(self, __name: str) -> Any:
572577
)
573578
and (func := getattr(value, "__func__", None)) is not None
574579
and not inspect.isclass(getattr(value, "__self__", None))
580+
# skip SQLAlchemy instrumented methods
581+
and not getattr(value, "_sa_instrumented", False)
575582
):
576583
# Rebind `self` to the proxy on methods to capture nested mutations.
577584
return functools.partial(func, self)

0 commit comments

Comments
 (0)