Skip to content

Commit d49fb5b

Browse files
authored
handle deocrated pages better (#5159)
* handle deocrated pages better * clear DECORATED_PAGES * maybe fix the issues with decorated pages? * print more helpful message * improve module name thing
1 parent 9b36917 commit d49fb5b

6 files changed

Lines changed: 115 additions & 48 deletions

File tree

reflex/app.py

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@
3535
from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin
3636
from reflex.compiler import compiler
3737
from reflex.compiler import utils as compiler_utils
38-
from reflex.compiler.compiler import ExecutorSafeFunctions, compile_theme
38+
from reflex.compiler.compiler import (
39+
ExecutorSafeFunctions,
40+
compile_theme,
41+
readable_name_from_component,
42+
)
3943
from reflex.components.base.app_wrap import AppWrap
4044
from reflex.components.base.error_boundary import ErrorBoundary
4145
from reflex.components.base.fragment import Fragment
@@ -284,6 +288,25 @@ class UnevaluatedPage:
284288
meta: list[dict[str, str]]
285289
context: dict[str, Any] | None
286290

291+
def merged_with(self, other: UnevaluatedPage) -> UnevaluatedPage:
292+
"""Merge the other page into this one.
293+
294+
Args:
295+
other: The other page to merge with.
296+
297+
Returns:
298+
The merged page.
299+
"""
300+
return dataclasses.replace(
301+
self,
302+
title=self.title if self.title is not None else other.title,
303+
description=self.description
304+
if self.description is not None
305+
else other.description,
306+
on_load=self.on_load if self.on_load is not None else other.on_load,
307+
context=self.context if self.context is not None else other.context,
308+
)
309+
287310

288311
@dataclasses.dataclass()
289312
class App(MiddlewareMixin, LifespanMixin):
@@ -719,22 +742,37 @@ def add_page(
719742
# Check if the route given is valid
720743
verify_route_validity(route)
721744

722-
if route in self._unevaluated_pages and environment.RELOAD_CONFIG.is_set():
723-
# when the app is reloaded(typically for app harness tests), we should maintain
724-
# the latest render function of a route.This applies typically to decorated pages
725-
# since they are only added when app._compile is called.
726-
self._unevaluated_pages.pop(route)
745+
unevaluated_page = UnevaluatedPage(
746+
component=component,
747+
route=route,
748+
title=title,
749+
description=description,
750+
image=image,
751+
on_load=on_load,
752+
meta=meta,
753+
context=context,
754+
)
727755

728756
if route in self._unevaluated_pages:
729-
route_name = (
730-
f"`{route}` or `/`"
731-
if route == constants.PageNames.INDEX_ROUTE
732-
else f"`{route}`"
733-
)
734-
raise exceptions.RouteValueError(
735-
f"Duplicate page route {route_name} already exists. Make sure you do not have two"
736-
f" pages with the same route"
737-
)
757+
if self._unevaluated_pages[route].component is component:
758+
unevaluated_page = unevaluated_page.merged_with(
759+
self._unevaluated_pages[route]
760+
)
761+
console.warn(
762+
f"Page {route} is being redefined with the same component."
763+
)
764+
else:
765+
route_name = (
766+
f"`{route}` or `/`"
767+
if route == constants.PageNames.INDEX_ROUTE
768+
else f"`{route}`"
769+
)
770+
existing_component = self._unevaluated_pages[route].component
771+
raise exceptions.RouteValueError(
772+
f"Tried to add page {readable_name_from_component(component)} with route {route_name} but "
773+
f"page {readable_name_from_component(existing_component)} with the same route already exists. "
774+
"Make sure you do not have two pages with the same route."
775+
)
738776

739777
# Setup dynamic args for the route.
740778
# this state assignment is only required for tests using the deprecated state kwarg for App
@@ -746,16 +784,7 @@ def add_page(
746784
on_load if isinstance(on_load, list) else [on_load]
747785
)
748786

749-
self._unevaluated_pages[route] = UnevaluatedPage(
750-
component=component,
751-
route=route,
752-
title=title,
753-
description=description,
754-
image=image,
755-
on_load=on_load,
756-
meta=meta,
757-
context=context,
758-
)
787+
self._unevaluated_pages[route] = unevaluated_page
759788

760789
def _compile_page(self, route: str, save_page: bool = True):
761790
"""Compile a page.
@@ -1021,9 +1050,11 @@ def _apply_decorated_pages(self):
10211050
10221051
This can move back into `compile_` when py39 support is dropped.
10231052
"""
1053+
app_name = get_config().app_name
10241054
# Add the @rx.page decorated pages to collect on_load events.
1025-
for render, kwargs in DECORATED_PAGES[get_config().app_name]:
1055+
for render, kwargs in DECORATED_PAGES[app_name]:
10261056
self.add_page(render, **kwargs)
1057+
DECORATED_PAGES[app_name].clear()
10271058

10281059
def _validate_var_dependencies(self, state: type[BaseState] | None = None) -> None:
10291060
"""Validate the dependencies of the vars in the app.

reflex/compiler/compiler.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from collections.abc import Iterable, Sequence
66
from datetime import datetime
7+
from inspect import getmodule
78
from pathlib import Path
89
from typing import TYPE_CHECKING
910

@@ -676,6 +677,35 @@ def _into_component_once(
676677
return None
677678

678679

680+
def readable_name_from_component(
681+
component: Component | ComponentCallable,
682+
) -> str | None:
683+
"""Get the readable name of a component.
684+
685+
Args:
686+
component: The component to get the name of.
687+
688+
Returns:
689+
The readable name of the component.
690+
"""
691+
if isinstance(component, Component):
692+
return type(component).__name__
693+
if isinstance(component, (Var, int, float, str)):
694+
return str(component)
695+
if isinstance(component, Sequence):
696+
return ", ".join(str(c) for c in component)
697+
if callable(component):
698+
module_name = getattr(component, "__module__", None)
699+
if module_name is not None:
700+
module = getmodule(component)
701+
if module is not None:
702+
module_name = module.__name__
703+
if module_name is not None:
704+
return f"{module_name}.{component.__name__}"
705+
return component.__name__
706+
return None
707+
708+
679709
def into_component(component: Component | ComponentCallable) -> Component:
680710
"""Convert a component to a Component.
681711

reflex/config.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -695,9 +695,6 @@ class EnvironmentVariables:
695695
# The port to run the backend on.
696696
REFLEX_BACKEND_PORT: EnvVar[int | None] = env_var(None)
697697

698-
# Reflex internal env to reload the config.
699-
RELOAD_CONFIG: EnvVar[bool] = env_var(False, internal=True)
700-
701698
# If this env var is set to "yes", App.compile will be a no-op
702699
REFLEX_SKIP_COMPILE: EnvVar[bool] = env_var(False, internal=True)
703700

reflex/testing.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ class AppHarness:
116116
backend: uvicorn.Server | None = None
117117
state_manager: StateManager | None = None
118118
_frontends: list[WebDriver] = dataclasses.field(default_factory=list)
119-
_decorated_pages: list = dataclasses.field(default_factory=list)
120119

121120
@classmethod
122121
def create(
@@ -267,21 +266,13 @@ def _initialize_app(self):
267266
with chdir(self.app_path):
268267
# ensure config and app are reloaded when testing different app
269268
reflex.config.get_config(reload=True)
270-
# Save decorated pages before importing the test app module
271-
before_decorated_pages = reflex.app.DECORATED_PAGES[self.app_name].copy()
272269
# Ensure the AppHarness test does not skip State assignment due to running via pytest
273270
os.environ.pop(reflex.constants.PYTEST_CURRENT_TEST, None)
274271
os.environ[reflex.constants.APP_HARNESS_FLAG] = "true"
275272
self.app_module = reflex.utils.prerequisites.get_compiled_app(
276273
# Do not reload the module for pre-existing apps (only apps generated from source)
277274
reload=self.app_source is not None
278275
)
279-
# Save the pages that were added during testing
280-
self._decorated_pages = [
281-
p
282-
for p in reflex.app.DECORATED_PAGES[self.app_name]
283-
if p not in before_decorated_pages
284-
]
285276
self.app_instance = self.app_module.app
286277
if self.app_instance and isinstance(
287278
self.app_instance._state_manager, StateManagerRedis
@@ -500,10 +491,6 @@ def stop(self) -> None:
500491
if self.frontend_output_thread is not None:
501492
self.frontend_output_thread.join()
502493

503-
# Cleanup decorated pages added during testing
504-
for page in self._decorated_pages:
505-
reflex.app.DECORATED_PAGES[self.app_name].remove(page)
506-
507494
def __exit__(self, *excinfo) -> None:
508495
"""Contextmanager protocol for `stop()`.
509496

reflex/utils/prerequisites.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,6 @@ def get_app(reload: bool = False) -> ModuleType:
371371
from reflex.utils import telemetry
372372

373373
try:
374-
environment.RELOAD_CONFIG.set(reload)
375374
config = get_config()
376375

377376
_check_app_name(config)
@@ -384,11 +383,14 @@ def get_app(reload: bool = False) -> ModuleType:
384383
else config.app_module
385384
)
386385
if reload:
386+
from reflex.page import DECORATED_PAGES
387387
from reflex.state import reload_state_module
388388

389389
# Reset rx.State subclasses to avoid conflict when reloading.
390390
reload_state_module(module=module)
391391

392+
DECORATED_PAGES.clear()
393+
392394
# Reload the app module.
393395
importlib.reload(app)
394396
except Exception as ex:

tests/units/test_app.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import pytest
1616
import sqlmodel
1717
from fastapi import FastAPI, UploadFile
18+
from pytest_mock import MockerFixture
1819
from starlette_admin.auth import AuthProvider
1920
from starlette_admin.contrib.sqla.admin import Admin
2021
from starlette_admin.contrib.sqla.view import ModelView
@@ -49,7 +50,7 @@
4950
_substate_key,
5051
)
5152
from reflex.style import Style
52-
from reflex.utils import exceptions, format
53+
from reflex.utils import console, exceptions, format
5354
from reflex.vars.base import computed_var
5455

5556
from .conftest import chdir
@@ -332,7 +333,28 @@ def index():
332333

333334

334335
@pytest.mark.parametrize(
335-
"first_page,second_page, route",
336+
("first_page", "second_page", "route"),
337+
[
338+
(index, index, None),
339+
(page1, page1, None),
340+
],
341+
)
342+
def test_add_the_same_page(
343+
mocker: MockerFixture, app: App, first_page, second_page, route
344+
):
345+
app.add_page(first_page, route=route)
346+
mock_object = mocker.Mock()
347+
mocker.patch.object(
348+
console,
349+
"warn",
350+
mock_object,
351+
)
352+
app.add_page(second_page, route="/" + route.strip("/") if route else None)
353+
assert mock_object.call_count == 1
354+
355+
356+
@pytest.mark.parametrize(
357+
("first_page", "second_page", "route"),
336358
[
337359
(lambda: rx.fragment(), lambda: rx.fragment(rx.text("second")), "/"),
338360
(rx.fragment(rx.text("first")), rx.fragment(rx.text("second")), "/page1"),
@@ -342,11 +364,9 @@ def index():
342364
"page3",
343365
),
344366
(page1, page2, "page1"),
345-
(index, index, None),
346-
(page1, page1, None),
347367
],
348368
)
349-
def test_add_duplicate_page_route_error(app, first_page, second_page, route):
369+
def test_add_duplicate_page_route_error(app: App, first_page, second_page, route):
350370
app.add_page(first_page, route=route)
351371
with pytest.raises(ValueError):
352372
app.add_page(second_page, route="/" + route.strip("/") if route else None)

0 commit comments

Comments
 (0)