diff --git a/docs/api-reference/plugins.md b/docs/api-reference/plugins.md index 13386a6a4bf..3374544c415 100644 --- a/docs/api-reference/plugins.md +++ b/docs/api-reference/plugins.md @@ -203,6 +203,10 @@ class Plugin: """Get paths to stylesheets required by the plugin.""" return [] + def register_route(self, **context) -> None: + """Contribute pages before the app's first compilation.""" + pass + def pre_compile(self, **context) -> None: """Called before compilation to perform custom tasks.""" pass @@ -245,3 +249,58 @@ config = rx.Config( ], ) ``` + +### Contributing Pages + +A plugin can contribute pages by overriding `register_route`. The hook runs +once for each `App`, after app-defined pages have been collected and before any +page is evaluated. A fresh app created by development reload or a new process +runs the hooks again. + +Use the provided `add_page` capability rather than calling `app.add_page` +directly. The framework collects every plugin's calls first and commits them +only after all route hooks succeed, so a failing hook cannot leave partial pages +or dynamic route arguments on the app: + +```python +class DashboardPlugin(Plugin): + def __init__(self, route="/dashboard"): + self.route = route + + def register_route(self, *, add_page, has_app_page, **context): + # Let an app-defined page override this optional default. + if not has_app_page(self.route): + add_page(dashboard_page, route=self.route, title="Dashboard") +``` + +`add_page` accepts the same page options as `App.add_page` (`title`, +`on_load`, `meta`, ...) but takes them as keyword arguments only; its signature +is described by the `AddPageProtocol` in the hook context. It uses the app's +normal page-preparation path while collecting the descriptor, then the +framework commits the prepared descriptors together. +Adding a route already defined by the app or by another plugin raises a +`RouteValueError` identifying the owner. +`has_app_page(route)` checks only app-defined pages; it intentionally ignores +pages staged by earlier plugins so plugin conflicts do not silently depend on +configuration order. + +The hook also receives `app_type`, which supports concrete-app compatibility +checks without exposing the mutable `App` instance. + +An `App` subclass that adds page-registration keyword arguments must normalize +them in its protected `_prepare_page` override and return the base prepared-page +descriptor. That method must not mutate app route state: plugin hooks call it +during collection, while the framework owns the final batch commit. The public +`add_page` and protected `_commit_page` overrides remain the direct-registration +API but are intentionally not dispatched during a staged plugin batch, so an +override cannot make the batch commit partially. + +### Looking Up Configured Plugins + +Use `rx.plugins.get_plugin(PluginType)` when runtime behavior needs the plugin +instance declared in `rxconfig.py`. It returns the single configured instance +matching that type (including subclasses), returns `None` when none matches, +and raises `ConfigError` when the lookup is ambiguous. Runtime-readable values +should be derived from the plugin's constructor arguments so every process can +recreate the same behavior from `rxconfig.plugins` without a separate binding +step. diff --git a/news/6728.feature.md b/news/6728.feature.md new file mode 100644 index 00000000000..7c737d2f828 --- /dev/null +++ b/news/6728.feature.md @@ -0,0 +1 @@ +Run plugins' staged `register_route` hooks once per app before page evaluation so plugins can contribute pages atomically, and invalidate the cached route resolver when a page is added after it was first built. diff --git a/packages/reflex-base/news/6728.feature.md b/packages/reflex-base/news/6728.feature.md new file mode 100644 index 00000000000..819ec4236c6 --- /dev/null +++ b/packages/reflex-base/news/6728.feature.md @@ -0,0 +1 @@ +Add a staged `Plugin.register_route` hook with `add_page` and `has_app_page` capabilities that runs once per app before its first compilation, and `rx.plugins.get_plugin()` to look up the configured plugin instance by type (raising `ConfigError` on ambiguous matches). diff --git a/packages/reflex-base/src/reflex_base/plugins/__init__.py b/packages/reflex-base/src/reflex_base/plugins/__init__.py index c0623746a30..e0790d3ab8c 100644 --- a/packages/reflex-base/src/reflex_base/plugins/__init__.py +++ b/packages/reflex-base/src/reflex_base/plugins/__init__.py @@ -8,6 +8,8 @@ PostBuildContext, PostCompileContext, PreCompileContext, + RegisterRouteContext, + get_plugin, ) from .compiler import ( BaseContext, @@ -35,11 +37,13 @@ "PostBuildContext", "PostCompileContext", "PreCompileContext", + "RegisterRouteContext", "SitemapPlugin", "TailwindV3Plugin", "TailwindV4Plugin", "_ScreenshotPlugin", "embed", + "get_plugin", "sitemap", "tailwind_v3", "tailwind_v4", diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index 6673309840b..3f52afe48ee 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -1,12 +1,14 @@ """Base class for all plugins.""" -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, ParamSpec, Protocol, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, ParamSpec, Protocol, TypedDict, TypeVar from typing_extensions import Unpack +from reflex_base.utils.exceptions import ConfigError + class HookOrder(str, Enum): """Dispatch bucket for a compiler ``enter_component`` / ``leave_component`` hook.""" @@ -18,8 +20,10 @@ class HookOrder(str, Enum): if TYPE_CHECKING: from reflex.app import App, UnevaluatedPage - from reflex_base.components.component import BaseComponent + from reflex_base.components.component import BaseComponent, Component + from reflex_base.event import EventType from reflex_base.plugins.compiler import ComponentAndChildren, PageContext + from reflex_base.vars.base import Var class CommonContext(TypedDict): @@ -57,6 +61,51 @@ class PreCompileContext(CommonContext): unevaluated_pages: Sequence["UnevaluatedPage"] +class AddPageProtocol(Protocol): + """Protocol for staging a page contribution during route registration. + + Mirrors the keyword surface of ``App.add_page``; page options are + keyword-only. Concrete ``App`` subclasses may accept extra keywords, which + are forwarded to their ``_prepare_page`` extension. + """ + + def __call__( + self, + component: "Component | Callable[[], Any] | None" = None, + route: str | None = None, + *, + title: "str | Var | None" = None, + description: "str | Var | None" = None, + image: str = ..., + on_load: "EventType[()] | None" = None, + meta: Sequence["Mapping[str, Any] | Component"] = ..., + context: dict[str, Any] | None = None, + **extra_page_args: Any, + ) -> None: + """Stage a page contribution owned by the calling plugin. + + Args: + component: The component or component callable to display. + route: The route to display the component at. + title: The title of the page. + description: The description of the page. + image: The image to display on the page. + on_load: The event handler(s) called each time the page loads. + meta: The metadata of the page. + context: Values passed to the page for custom page-specific logic. + extra_page_args: Keyword arguments added by concrete ``App`` + subclasses that extend page registration. + """ + + +class RegisterRouteContext(CommonContext): + """Context for the ``register_route`` hook.""" + + app_type: type["App"] + add_page: AddPageProtocol + has_app_page: Callable[[str], bool] + + class PostCompileContext(CommonContext): """Context for post-compile hooks.""" @@ -129,6 +178,22 @@ def get_stylesheet_paths(self, **context: Unpack[CommonContext]) -> Sequence[str """ return [] + def register_route(self, **context: Unpack[RegisterRouteContext]) -> None: + """Contribute pages before the app's first compilation. + + The hook runs once per app, after app-defined pages are collected and + before any page is evaluated. Calls to ``context["add_page"]`` are + prepared without mutating app route state, then committed after every + plugin hook succeeds. ``context["app_type"]`` supports concrete-app + compatibility checks without exposing the mutable app. Use + ``context["has_app_page"]`` to let an app-defined page override an + optional plugin page; it intentionally ignores other plugins so + plugin-versus-plugin route conflicts cannot be hidden by ordering. + + Args: + context: The route registration context. + """ + def pre_compile(self, **context: Unpack[PreCompileContext]) -> None: """Called before the compilation of the plugin. @@ -263,3 +328,35 @@ def __repr__(self): A string representation of the plugin. """ return f"{self.__class__.__name__}()" + + +_PluginT = TypeVar("_PluginT", bound=Plugin) + + +def get_plugin(plugin_cls: type[_PluginT]) -> _PluginT | None: + """Return the configured plugin instance of the given type, if any. + + Args: + plugin_cls: The plugin type (or base type) to look up. + + Returns: + The configured plugin that is an instance of ``plugin_cls``, or + ``None`` when no such plugin is configured. + + Raises: + ConfigError: When more than one configured plugin matches — behavior + must not silently depend on the configuration order. + """ + # Inline import: reflex_base.config imports this module for the Plugin type. + from reflex_base.config import get_config + + matches = (p for p in get_config().plugins if isinstance(p, plugin_cls)) + match = next(matches, None) + if next(matches, None) is not None: + msg = ( + f"Multiple {plugin_cls.__name__} instances are configured, but " + "get_plugin() requires an unambiguous match. Request a more specific " + "type or remove duplicate entries from the rxconfig plugins list." + ) + raise ConfigError(msg) + return match diff --git a/reflex/app.py b/reflex/app.py index 9364c547436..509b6dcdbfc 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -106,6 +106,8 @@ from warnings import deprecated if TYPE_CHECKING: + from reflex_base.plugins import Plugin + from reflex_base.plugins.base import AddPageProtocol from reflex_base.vars import Var # Define custom types. @@ -308,6 +310,26 @@ def merged_with(self, other: UnevaluatedPage) -> UnevaluatedPage: ) +@dataclasses.dataclass(frozen=True) +class _PreparedPage: + """A validated page descriptor awaiting registration.""" + + page: UnevaluatedPage + route_args: dict[str, str] + + +def _route_arg_label(arg_type: str) -> str: + """Return the human-readable label for a dynamic route argument type. + + Args: + arg_type: The ``RouteArgType`` value. + + Returns: + ``"list"`` for catch-all arguments, otherwise ``"single"``. + """ + return "list" if arg_type == constants.RouteArgType.LIST else "single" + + @dataclasses.dataclass() class App(MiddlewareMixin, LifespanMixin): """The main Reflex app that encapsulates the backend and frontend. @@ -389,6 +411,9 @@ class App(MiddlewareMixin, LifespanMixin): default_factory=dict ) + # Whether the plugins' ``register_route`` hooks have run for this app. + _plugin_routes_registered: bool = False + # A map from a page route to the component to render. Users should use `add_page`. _pages: dict[str, Component] = dataclasses.field(default_factory=dict) @@ -868,6 +893,25 @@ def _generate_component(component: Component | ComponentCallable) -> Component: return into_component(component) + @staticmethod + def _page_route_key( + component: Component | ComponentCallable | None, route: str | None + ) -> str | None: + """Derive the normalized route used to register a page. + + Args: + component: The page component or component callable. + route: The explicit route, if any. + + Returns: + The normalized route, or ``None`` when no route can be derived. + """ + if route is not None: + return format.format_route(route) + if isinstance(component, Callable): + return format.format_route(format.to_kebab_case(component.__name__)) + return None + def add_page( self, component: Component | ComponentCallable | None = None, @@ -897,23 +941,66 @@ def add_page( Raises: PageValueError: When the component is not set for a non-404 page. RouteValueError: When the specified route name already exists. + ValueError: When the route syntax is invalid. """ - # If the route is not set, get it from the callable. + self._register_prepared_page( + self._prepare_page( + component, + route, + title, + description, + image, + on_load, + meta, + context, + ) + ) + + def _prepare_page( + self, + component: Component | ComponentCallable | None = None, + route: str | None = None, + title: str | Var | None = None, + description: str | Var | None = None, + image: str = constants.DefaultPage.IMAGE, + on_load: EventType[()] | None = None, + meta: Sequence[Mapping[str, Any] | Component] = constants.DefaultPage.META_LIST, + context: dict[str, Any] | None = None, + ) -> _PreparedPage: + """Validate and normalize a page without mutating app route state. + + App subclasses that extend ``add_page`` arguments should perform their + pure argument normalization here so staged plugin contributions and + direct page registration share the same preparation path. + + Args: + component: The component to display at the page. + route: The route to display the component at. + title: The title of the page. + description: The description of the page. + image: The image to display on the page. + on_load: The event handler(s) called each time the page loads. + meta: The metadata of the page. + context: Values passed to page for custom page-specific logic. + + Returns: + The validated page descriptor and its dynamic route arguments. + + Raises: + PageValueError: When the component is not set for a non-404 page. + RouteValueError: When a route cannot be derived. + ValueError: When the route syntax is invalid. + """ + route = self._page_route_key(component, route) if route is None: - if not isinstance(component, Callable): - msg = "Route must be set if component is not a callable." - raise exceptions.RouteValueError(msg) - # Format the route. - route = format.format_route(format.to_kebab_case(component.__name__)) - else: - route = format.format_route(route) + msg = "Route must be set if component is not a callable." + raise exceptions.RouteValueError(msg) if route == constants.Page404.SLUG: if component is None: from reflex_components_core.el.elements import span component = span("404: Page not found") - component = self._generate_component(component) title = title or constants.Page404.TITLE description = description or constants.Page404.DESCRIPTION image = image or constants.Page404.IMAGE @@ -944,41 +1031,235 @@ def add_page( _source_module=source_module, ) - if route in self._unevaluated_pages: - if self._unevaluated_pages[route].component is component: - unevaluated_page = unevaluated_page.merged_with( - self._unevaluated_pages[route] + return _PreparedPage( + page=unevaluated_page, + route_args=get_route_args(route), + ) + + def _register_prepared_page(self, prepared: _PreparedPage) -> None: + """Resolve conflicts and commit one directly added page. + + Args: + prepared: The validated page descriptor to register. + + Raises: + RouteValueError: When another component already owns the route. + """ + page = prepared.page + if page.route in self._unevaluated_pages: + existing_page = self._unevaluated_pages[page.route] + if existing_page.component is page.component: + prepared = dataclasses.replace( + prepared, + page=page.merged_with(existing_page), ) console.warn( - f"Page {route} is being redefined with the same component." + f"Page {page.route} is being redefined with the same component." ) else: route_name = ( - f"`{route}` or `/`" - if route == constants.PageNames.INDEX_ROUTE - else f"`{route}`" + f"`{page.route}` or `/`" + if page.route == constants.PageNames.INDEX_ROUTE + else f"`{page.route}`" ) - existing_component = self._unevaluated_pages[route].component msg = ( - f"Tried to add page {readable_name_from_component(component)} with route {route_name} but " - f"page {readable_name_from_component(existing_component)} with the same route already exists. " + f"Tried to add page {readable_name_from_component(page.component)} with route {route_name} but " + f"page {readable_name_from_component(existing_page.component)} with the same route already exists. " "Make sure you do not have two pages with the same route." ) raise exceptions.RouteValueError(msg) + self._commit_page(prepared) - # Setup dynamic args for the route. - # this state assignment is only required for tests using the deprecated state kwarg for App - state = self._state or State - route_args = get_route_args(route) - state.setup_dynamic_args(route_args) + def _commit_page( + self, prepared: _PreparedPage, *, setup_dynamic_args: bool = True + ) -> None: + """Commit a prepared page to the app. - self._load_events[route] = ( - (on_load if isinstance(on_load, list) else [on_load]) - if on_load is not None + This is the fixed, non-fallible final write. Staged plugin batches + invoke this framework implementation directly so an override cannot + make the batch partially commit — subclasses must customize page + registration in ``_prepare_page``, not here. + + Args: + prepared: The validated page descriptor to register. + setup_dynamic_args: Whether to install its dynamic route variables. + A staged plugin batch installs all variables once before + committing its descriptors. + """ + page = prepared.page + if setup_dynamic_args: + # This state assignment is only required for tests using the + # deprecated state kwarg for App. + state = self._state or State + state.setup_dynamic_args(prepared.route_args) + + self._load_events[page.route] = ( + (page.on_load if isinstance(page.on_load, list) else [page.on_load]) + if page.on_load is not None else [] ) - self._unevaluated_pages[route] = unevaluated_page + self._unevaluated_pages[page.route] = page + self.__dict__.pop("router", None) + + def _register_plugin_pages(self, plugins: Sequence[Plugin]) -> None: + """Collect and commit plugin page contributions, once per app instance. + + Hooks receive a staged ``add_page`` capability. No live app route + state is mutated until every hook succeeds, so a hook exception cannot + leave partial routes or dynamic route variables behind. + + Args: + plugins: The active plugins, in configuration order. + """ + if self._plugin_routes_registered: + return + + # Snapshot preserving definition order for deterministic error messages. + app_routes = dict.fromkeys(self._unevaluated_pages) + contributions: list[_PreparedPage] = [] + route_owners: dict[str, str] = {} + + def has_app_page(route: str) -> bool: + """Return whether the app defines a normalized route. + + Args: + route: The route to check. + + Returns: + Whether the route belongs to the app rather than a plugin. + """ + return format.format_route(route) in app_routes + + def make_add_page(plugin_name: str) -> AddPageProtocol: + """Create a staged page registrar bound to one plugin. + + Args: + plugin_name: Name identifying the contributing plugin. + + Returns: + A registrar recording contributions under ``plugin_name``. + """ + + def add_page( + component: Component | ComponentCallable | None = None, + route: str | None = None, + **page_args: Any, + ) -> None: + """Stage a page contribution owned by the active plugin. + + Args: + component: The component or component callable to register. + route: The explicit page route, if any. + page_args: Additional keyword page-preparation arguments. + + Raises: + RouteValueError: If a route cannot be derived or is + already owned. + """ + prepared = self._prepare_page(component, route, **page_args) + route_key = prepared.page.route + + if route_key in app_routes: + msg = ( + f"Plugin {plugin_name} tried to register route " + f"`{route_key}`, but that route is already defined by " + "the app; use has_app_page(route) to keep the " + "app-defined page." + ) + raise exceptions.RouteValueError(msg) + + if (owner := route_owners.get(route_key)) is not None: + msg = ( + f"Plugin {plugin_name} tried to register route " + f"`{route_key}`, but that route is already defined by " + f"plugin {owner}; a route can only be registered by " + "one plugin." + ) + raise exceptions.RouteValueError(msg) + + existing_routes = app_routes.keys() | route_owners.keys() + if conflict := self._find_route_conflict(existing_routes, route_key): + existing_route, existing_segment, new_segment = conflict + existing_owner = ( + "the app" + if existing_route in app_routes + else f"plugin {route_owners[existing_route]}" + ) + msg = ( + f"Plugin {plugin_name} tried to register route " + f"`{route_key}`, but it conflicts with route " + f"`{existing_route}` defined by {existing_owner}; " + "dynamic segment names must match " + f"(`{existing_segment}` != `{new_segment}`)." + ) + raise exceptions.RouteValueError(msg) + + route_owners[route_key] = plugin_name + contributions.append(prepared) + + return add_page + + class_names = [type(plugin).__name__ for plugin in plugins] + duplicated = {name for name in class_names if class_names.count(name) > 1} + for index, (plugin, class_name) in enumerate( + zip(plugins, class_names, strict=True) + ): + plugin_name = ( + f"{class_name} (plugins[{index}])" + if class_name in duplicated + else class_name + ) + plugin.register_route( + app_type=type(self), + add_page=make_add_page(plugin_name), + has_app_page=has_app_page, + ) + + # This state assignment is only required for tests using the + # deprecated state kwarg for App. + state = self._state or State + staged_args: dict[str, str] = {} + # First recorded source of each dynamic argument wins: stale variables + # from an earlier app in this process, then app routes, then staged + # contributions in commit order. + arg_sources: dict[str, tuple[str, str]] = { + name: (arg_type, "an earlier app in this process (restart to clear it)") + for name, arg_type in state._dynamic_route_arg_types().items() + } + for route in app_routes: + for name, arg_type in get_route_args(route).items(): + arg_sources.setdefault( + name, (arg_type, f"route `{route}` defined by the app") + ) + for prepared in contributions: + route = prepared.page.route + owner = route_owners[route] + for name, arg_type in prepared.route_args.items(): + existing_type, source = arg_sources.setdefault( + name, (arg_type, f"route `{route}` defined by plugin {owner}") + ) + if existing_type != arg_type: + msg = ( + f"Plugin {owner} tried to register route `{route}` with " + f"dynamic argument `{name}` of type " + f"`{_route_arg_label(arg_type)}`, but {source} already " + f"uses it as type `{_route_arg_label(existing_type)}`. " + "Dynamic argument types must be consistent across routes." + ) + raise exceptions.RouteValueError(msg) + staged_args.setdefault(name, arg_type) + if staged_args: + state.setup_dynamic_args(staged_args) + + # Commit through the framework implementation: concrete App extensions + # normalize extra arguments in ``_prepare_page``; the final writes are + # intentionally fixed and non-fallible so the batch cannot partially + # commit. + for prepared in contributions: + App._commit_page(self, prepared, setup_dynamic_args=False) + + self._plugin_routes_registered = True def _compile_page(self, route: str, save_page: bool = True): """Compile a page. @@ -1057,15 +1338,38 @@ def _check_routes_conflict(self, new_route: str): """ from reflex_base.utils.exceptions import RouteValueError + conflict = self._find_route_conflict(self._pages, new_route) + if conflict is not None: + route, existing_segment, new_segment = conflict + msg = ( + "You cannot use different slug names for the same dynamic path " + f"in {route} and {new_route} " + f"('{existing_segment}' != '{new_segment}')" + ) + raise RouteValueError(msg) + + @staticmethod + def _find_route_conflict( + existing_routes: Collection[str], new_route: str + ) -> tuple[str, str, str] | None: + """Find an existing route with incompatible dynamic segment names. + + Args: + existing_routes: Routes already occupying the router tree. + new_route: The route being added. + + Returns: + The conflicting route and differing segment names, or ``None``. + """ if "[" not in new_route: - return + return None segments = ( constants.RouteRegex.SINGLE_SEGMENT, constants.RouteRegex.DOUBLE_SEGMENT, constants.RouteRegex.DOUBLE_CATCHALL_SEGMENT, ) - for route in self._pages: + for route in existing_routes: replaced_route = replace_brackets_with_keywords(route) for rw, r, nr in zip( replaced_route.split("/"), @@ -1074,15 +1378,14 @@ def _check_routes_conflict(self, new_route: str): strict=False, ): if rw in segments and r != nr: - # If the slugs in the segments of both routes are not the same, then the route is invalid - msg = f"You cannot use different slug names for the same dynamic path in {route} and {new_route} ('{r}' != '{nr}')" - raise RouteValueError(msg) + return route, r, nr if rw not in segments and r != nr: # if the section being compared in both routes is not a dynamic segment(i.e not wrapped in brackets) # then we are guaranteed that the route is valid and there's no need checking the rest. # eg. /posts/[id]/info/[slug1] and /posts/[id]/info1/[slug1] is always going to be valid since # info1 will break away into its own tree. break + return None def _setup_admin_dash(self): """Setup the admin dash.""" diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 4e521d47b92..c95f7652839 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1090,6 +1090,19 @@ def _resolve_radix_themes_plugin( return plugin_chain, radix_plugin +def _register_plugin_routes(app: App, plugins: Sequence[Plugin]) -> None: + """Run plugin ``register_route`` hooks at their point in the compile lifecycle. + + Fires after app-defined pages are collected and before any page is + evaluated. The staging and atomic-commit machinery lives on ``App``. + + Args: + app: The app being compiled. + plugins: The active plugins, in configuration order. + """ + app._register_plugin_pages(plugins) + + def compile_app( app: App, *, @@ -1107,6 +1120,8 @@ def compile_app( from reflex_base.utils.exceptions import ReflexRuntimeError app._apply_decorated_pages() + config = get_config() + _register_plugin_routes(app, config.plugins) app._pages = {} should_compile = app._should_compile() @@ -1126,7 +1141,6 @@ def compile_app( app.add_page(route=constants.Page404.SLUG) app.style = evaluate_style_namespaces(app.style) - config = get_config() if not should_compile and not dry_run: with console.timing("Evaluate Pages (Backend)"): diff --git a/reflex/plugins/__init__.py b/reflex/plugins/__init__.py index e28a0c3cdde..ba9c0775f54 100644 --- a/reflex/plugins/__init__.py +++ b/reflex/plugins/__init__.py @@ -10,11 +10,13 @@ PageContext, Plugin, PreCompileContext, + RegisterRouteContext, SitemapPlugin, TailwindV3Plugin, TailwindV4Plugin, _ScreenshotPlugin, embed, + get_plugin, sitemap, tailwind_v3, tailwind_v4, @@ -32,11 +34,13 @@ "Plugin", "PreCompileContext", "RadixThemesPlugin", + "RegisterRouteContext", "SitemapPlugin", "TailwindV3Plugin", "TailwindV4Plugin", "_ScreenshotPlugin", "embed", + "get_plugin", "sitemap", "tailwind_v3", "tailwind_v4", diff --git a/reflex/state.py b/reflex/state.py index 3762ad0ac2a..1e288355fb0 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -1346,6 +1346,23 @@ def _update_substate_inherited_vars(cls, vars_to_add: dict[str, Var]): # Reinitialize dependency tracking dicts. cls._init_var_dependency_dicts() + @classmethod + def _dynamic_route_arg_types(cls) -> dict[str, str]: + """Map installed dynamic route argument names to their route arg type. + + Returns: + A mapping of dynamic route argument name to ``RouteArgType`` value. + """ + return { + name: ( + constants.RouteArgType.LIST + if computed_var._var_type == list[str] + else constants.RouteArgType.SINGLE + ) + for name, computed_var in cls.computed_vars.items() + if isinstance(computed_var, DynamicRouteVar) + } + @classmethod def setup_dynamic_args(cls, args: dict[str, str]): """Set up args for easy access in renderer. @@ -1354,14 +1371,8 @@ def setup_dynamic_args(cls, args: dict[str, str]): args: a dict of args """ # Skip dynamic args that have already been registered by a previous route. - args = { - k: v - for k, v in args.items() - if not ( - (computed_var := cls.computed_vars.get(k)) is not None - and isinstance(computed_var, DynamicRouteVar) - ) - } + installed = cls._dynamic_route_arg_types() + args = {k: v for k, v in args.items() if k not in installed} if not args: return diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 1dbb4ab27bc..124fc39b805 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1,4 +1,6 @@ +import dataclasses import importlib.util +import json import os from pathlib import Path @@ -7,6 +9,11 @@ from reflex_base import constants from reflex_base.components.dynamic import bundle_library, reset_bundled_libraries from reflex_base.constants.compiler import PageNames +from reflex_base.utils.exceptions import ( + DynamicRouteArgShadowsStateVarError, + PageValueError, + RouteValueError, +) from reflex_base.utils.imports import ImportVar, ParsedImportDict from reflex_base.vars.base import Var from reflex_base.vars.sequence import LiteralStringVar @@ -17,6 +24,7 @@ import reflex as rx from reflex.compiler import compiler, utils +from reflex.state import BaseState @pytest.mark.parametrize( @@ -535,3 +543,707 @@ def test_create_document_root_with_meta_viewport(): assert str(root.children[0].children[2].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue] assert str(root.children[0].children[2].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue] assert str(root.children[0].children[3].char_set) == '"utf-8"' # pyright: ignore [reportAttributeAccessIssue] + + +class _RoutePlugin(rx.plugins.Plugin): + """Plugin that records how often it contributes its route.""" + + calls = 0 + + def register_route(self, *, add_page, **_): + """Contribute a page through the staged hook capability. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + self.calls += 1 + add_page(lambda: rx.el.div("plugin page"), route="/plugin-page") + + +def test_register_plugin_routes_runs_once_per_app(): + """Route hooks run once and retain their contributed pages.""" + app = rx.App() + plugin = _RoutePlugin() + + compiler._register_plugin_routes(app, [plugin]) + compiler._register_plugin_routes(app, [plugin]) + + assert plugin.calls == 1 + assert "plugin-page" in app._unevaluated_pages + + +@pytest.mark.parametrize("with_stateful_marker", [False, True]) +def test_compile_registers_plugin_routes_on_backend_early_return( + tmp_path: Path, + mocker: MockerFixture, + with_stateful_marker: bool, +): + """Backend-only compiles register routes before either early-return path.""" + app = rx.App() + plugin = _RoutePlugin() + config = rx.Config(app_name="testing", plugins=[plugin]) + mocker.patch.object(app, "_apply_decorated_pages") + mocker.patch.object(app, "_should_compile", return_value=False) + compile_page = mocker.patch.object(app, "_compile_page") + mocker.patch.object(app, "_add_optional_endpoints") + mocker.patch.object( + compiler.prerequisites, "get_backend_dir", return_value=tmp_path + ) + mocker.patch.object(compiler, "get_config", return_value=config) + + if with_stateful_marker: + (tmp_path / constants.Dirs.STATEFUL_PAGES).write_text( + json.dumps(["plugin-page"]) + ) + + assert compiler.compile_app(app, use_rich=False) is False + assert "plugin-page" in app._unevaluated_pages + if with_stateful_marker: + compile_page.assert_called_once_with("plugin-page", save_page=False) + else: + compile_page.assert_not_called() + + +def test_register_plugin_routes_exposes_app_type_not_mutable_app(): + """The hook can validate the app class without bypassing staged writes.""" + observed: dict[str, object] = {} + + class InspectingPlugin(rx.plugins.Plugin): + """Plugin recording its route-registration context.""" + + def register_route(self, **context): + """Record the context without contributing a page. + + Args: + context: The route-registration capabilities. + """ + observed.update(context) + + app = rx.App() + + compiler._register_plugin_routes(app, [InspectingPlugin()]) + + assert observed["app_type"] is type(app) + assert "app" not in observed + + +def test_register_plugin_routes_hook_failure_is_atomic(): + """A hook failure leaves no staged pages or dynamic route variables.""" + + class PluginRouteState(BaseState): + """State root isolated from the global state tree in this test.""" + + class FirstPlugin(rx.plugins.Plugin): + """Plugin whose contribution is collected before the failing hook.""" + + def register_route(self, *, add_page, **_): + """Contribute a page before the failing hook runs. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/first-plugin-page") + + class FailingPlugin(rx.plugins.Plugin): + """Plugin that raises after staging a dynamic route.""" + + def register_route(self, *, add_page, **_): + """Contribute a route and then fail. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + + Raises: + RuntimeError: Always after contributing the route. + """ + add_page(lambda: rx.el.div(), route="/failed/[failed_plugin_arg]") + msg = "plugin route registration failed" + raise RuntimeError(msg) + + app = rx.App(_state=PluginRouteState) + + with pytest.raises(RuntimeError, match="plugin route registration failed"): + compiler._register_plugin_routes(app, [FirstPlugin(), FailingPlugin()]) + + assert not app._plugin_routes_registered + assert "first-plugin-page" not in app._unevaluated_pages + assert "failed/[failed_plugin_arg]" not in app._unevaluated_pages + assert "failed_plugin_arg" not in PluginRouteState.computed_vars + + compiler._register_plugin_routes(app, [FirstPlugin()]) + + assert app._plugin_routes_registered + assert "first-plugin-page" in app._unevaluated_pages + + +def test_register_plugin_routes_rejects_app_route_conflict_atomically(): + """An app-owned route cannot be replaced by a plugin contribution.""" + + class ConflictingPlugin(rx.plugins.Plugin): + """Plugin claiming an app-owned route.""" + + def register_route(self, *, add_page, **_): + """Contribute the conflicting page. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div("plugin"), route="/shared") + + app = rx.App() + app.add_page(lambda: rx.el.div("app"), route="/shared") + + with pytest.raises( + RouteValueError, + match=r"Plugin ConflictingPlugin.*`shared`.*defined by the app", + ): + compiler._register_plugin_routes(app, [ConflictingPlugin()]) + + assert not app._plugin_routes_registered + assert len(app._unevaluated_pages) == 1 + + +def test_register_plugin_routes_rejects_plugin_conflict_atomically(): + """Two plugins cannot silently claim the same route.""" + + class FirstPlugin(rx.plugins.Plugin): + """First owner of the shared route.""" + + def register_route(self, *, add_page, **_): + """Contribute the shared page. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div("first"), route="/shared") + + class SecondPlugin(rx.plugins.Plugin): + """Second claimant of the shared route.""" + + def register_route(self, *, add_page, **_): + """Contribute the conflicting shared page. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div("second"), route="/shared") + + app = rx.App() + + with pytest.raises( + RouteValueError, + match=r"Plugin SecondPlugin.*`shared`.*plugin FirstPlugin", + ): + compiler._register_plugin_routes(app, [FirstPlugin(), SecondPlugin()]) + + assert not app._plugin_routes_registered + assert "shared" not in app._unevaluated_pages + + +def test_register_plugin_routes_rejects_structural_app_conflict_atomically(): + """A plugin cannot change a dynamic slug name already owned by the app.""" + + class StructuralRouteState(BaseState): + """State root isolated from the global state tree in this test.""" + + class ConflictingPlugin(rx.plugins.Plugin): + """Plugin contributing a structurally conflicting dynamic route.""" + + def register_route(self, *, add_page, **_): + """Contribute the conflicting route. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/posts/[slug]") + + app = rx.App(_state=StructuralRouteState) + app.add_page(lambda: rx.el.div(), route="/posts/[id]") + + with pytest.raises( + RouteValueError, + match=r"Plugin ConflictingPlugin.*`posts/\[slug\]`.*`posts/\[id\]`.*app", + ): + compiler._register_plugin_routes(app, [ConflictingPlugin()]) + + assert set(app._unevaluated_pages) == {"posts/[id]"} + assert not app._plugin_routes_registered + + +def test_register_plugin_routes_rejects_structural_plugin_conflict_atomically(): + """Two plugins cannot use different slug names for one dynamic path.""" + + class FirstPlugin(rx.plugins.Plugin): + """Plugin owning the first dynamic route.""" + + def register_route(self, *, add_page, **_): + """Contribute the first route. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/posts/[id]") + + class SecondPlugin(rx.plugins.Plugin): + """Plugin contributing the conflicting route.""" + + def register_route(self, *, add_page, **_): + """Contribute the second route. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/posts/[slug]") + + app = rx.App() + + with pytest.raises( + RouteValueError, + match=r"Plugin SecondPlugin.*plugin FirstPlugin.*dynamic segment names", + ): + compiler._register_plugin_routes(app, [FirstPlugin(), SecondPlugin()]) + + assert app._unevaluated_pages == {} + assert not app._plugin_routes_registered + + +def test_register_plugin_routes_has_app_page_excludes_plugin_contributions(): + """The route probe sees app pages but not staged plugin pages.""" + observed: list[tuple[bool, bool]] = [] + + class ContributingPlugin(rx.plugins.Plugin): + """Plugin staging a route before the probing plugin runs.""" + + def register_route(self, *, add_page, **_): + """Contribute a plugin-owned page. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/plugin-page") + + class ProbingPlugin(rx.plugins.Plugin): + """Plugin observing app and plugin route visibility.""" + + def register_route(self, *, has_app_page, **_): + """Record route-presence results. + + Args: + has_app_page: The app-owned route probe. + _: Additional hook context. + """ + observed.append(( + has_app_page("/app-page/"), + has_app_page("/plugin-page"), + )) + + app = rx.App() + app.add_page(lambda: rx.el.div(), route="/app-page") + + compiler._register_plugin_routes( + app, + [ContributingPlugin(), ProbingPlugin()], + ) + + assert observed == [(True, False)] + + +def test_register_plugin_routes_invalidates_router_cache(): + """Committing a plugin page refreshes a previously cached router.""" + app = rx.App() + assert app.router("/plugin-page") is None + + compiler._register_plugin_routes(app, [_RoutePlugin()]) + + assert app.router("/plugin-page") == "plugin-page" + + +def test_register_plugin_routes_preflights_missing_component(): + """An invalid contribution fails before the app commit begins.""" + + class CountingApp(rx.App): + """App recording committed prepared pages.""" + + commit_calls = 0 + + def _commit_page(self, prepared, **kwargs): + """Record and commit a prepared page. + + Args: + prepared: The prepared page descriptor. + kwargs: Commit options. + + Returns: + The result of the base commit. + """ + self.commit_calls += 1 + return super()._commit_page(prepared, **kwargs) + + class InvalidPlugin(rx.plugins.Plugin): + """Plugin omitting the component for a normal page.""" + + def register_route(self, *, add_page, **_): + """Stage the invalid contribution. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(route="/missing-component") + + app = CountingApp() + + with pytest.raises(PageValueError, match="Component must be set"): + compiler._register_plugin_routes(app, [InvalidPlugin()]) + + assert app.commit_calls == 0 + assert not app._plugin_routes_registered + + +def test_register_plugin_routes_preflights_dynamic_arg_conflicts(): + """Dynamic route conflicts fail before any contribution is committed.""" + + class PluginRouteState(BaseState): + """State with a variable that a plugin route must not shadow.""" + + reserved_arg: str = "" + + class CountingApp(rx.App): + """App recording committed prepared pages.""" + + commit_calls = 0 + + def _commit_page(self, prepared, **kwargs): + """Record and commit a prepared page. + + Args: + prepared: The prepared page descriptor. + kwargs: Commit options. + + Returns: + The result of the base commit. + """ + self.commit_calls += 1 + return super()._commit_page(prepared, **kwargs) + + class InvalidPlugin(rx.plugins.Plugin): + """Plugin contributing a route that shadows a state variable.""" + + def register_route(self, *, add_page, **_): + """Stage a valid page followed by a conflicting dynamic page. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/valid") + add_page(lambda: rx.el.div(), route="/[reserved_arg]") + + app = CountingApp(_state=PluginRouteState) + + with pytest.raises( + DynamicRouteArgShadowsStateVarError, + match="reserved_arg", + ): + compiler._register_plugin_routes(app, [InvalidPlugin()]) + + assert app.commit_calls == 0 + assert not app._plugin_routes_registered + assert app._unevaluated_pages == {} + + +def test_register_plugin_routes_rejects_inconsistent_dynamic_arg_types(): + """One dynamic argument cannot be both a scalar and a catch-all list.""" + + class ScalarPlugin(rx.plugins.Plugin): + """Plugin contributing the scalar form of a dynamic argument.""" + + def register_route(self, *, add_page, **_): + """Contribute the scalar route. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/one/[splat]") + + class ListPlugin(rx.plugins.Plugin): + """Plugin contributing the catch-all form of the same argument.""" + + def register_route(self, *, add_page, **_): + """Contribute the catch-all route. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/two/[[...splat]]") + + app = rx.App() + + with pytest.raises( + RouteValueError, + match=r"Plugin ListPlugin.*`splat`.*type `list`.*type `single`", + ): + compiler._register_plugin_routes(app, [ScalarPlugin(), ListPlugin()]) + + assert app._unevaluated_pages == {} + assert not app._plugin_routes_registered + + +def test_register_plugin_routes_rejects_stale_dynamic_arg_type_from_prior_app(): + """A fresh app cannot reuse a stale DynamicRouteVar with another type.""" + + class RouteState(BaseState): + """State root deliberately shared by both app instances.""" + + class ScalarPlugin(rx.plugins.Plugin): + """Plugin installing a scalar dynamic argument.""" + + def register_route(self, *, add_page, **_): + """Contribute the scalar route. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/one/[splat]") + + class ListPlugin(rx.plugins.Plugin): + """Plugin attempting to reuse the argument as a catch-all list.""" + + def register_route(self, *, add_page, **_): + """Contribute the catch-all route. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/two/[[...splat]]") + + first_app = rx.App(_state=RouteState) + compiler._register_plugin_routes(first_app, [ScalarPlugin()]) + + second_app = rx.App(_state=RouteState) + with pytest.raises( + RouteValueError, + match=r"Plugin ListPlugin.*`splat`.*type `list`.*already.*type `single`", + ): + compiler._register_plugin_routes(second_app, [ListPlugin()]) + + assert second_app._unevaluated_pages == {} + assert not second_app._plugin_routes_registered + + +def test_register_plugin_routes_uses_concrete_prepare_extension(): + """Staged pages use the concrete app's pure preparation extension.""" + + class ExtendedApp(rx.App): + """App accepting an extra page-registration argument.""" + + def _prepare_page(self, component=None, *args, marker=None, **kwargs): + """Store the custom marker in the prepared page context. + + Args: + component: The page component. + args: Base page arguments. + marker: Custom page metadata. + kwargs: Base page keyword arguments. + + Returns: + The prepared page descriptor. + """ + context = {**(kwargs.pop("context", None) or {}), "marker": marker} + prepared = super()._prepare_page( + component, + *args, + context=context, + **kwargs, + ) + return dataclasses.replace( + prepared, + page=dataclasses.replace( + prepared.page, + _source_module="app_extension", + ), + ) + + class ExtendedPlugin(rx.plugins.Plugin): + """Plugin using the concrete app's page extension.""" + + def register_route(self, *, add_page, **_): + """Contribute a page with custom metadata. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route="/extended", marker="plugin") + + app = ExtendedApp() + + compiler._register_plugin_routes(app, [ExtendedPlugin()]) + + assert app._unevaluated_pages["extended"].context == {"marker": "plugin"} + # The concrete extension's explicit source-module rewrite is preserved. + assert app._unevaluated_pages["extended"]._source_module == "app_extension" + + +def test_register_plugin_routes_checks_routes_after_concrete_preparation(): + """Concrete route rewriting cannot turn distinct inputs into last-wins.""" + + class RewritingApp(rx.App): + """App mapping every contributed route to one canonical location.""" + + def _prepare_page(self, component=None, route=None, *args, **kwargs): + """Rewrite the route before base preparation. + + Args: + component: The page component. + route: The ignored input route. + args: Base page arguments. + kwargs: Base page keyword arguments. + + Returns: + The prepared page using the canonical route. + """ + return super()._prepare_page( + component, + "/canonical", + *args, + **kwargs, + ) + + class RoutePlugin(rx.plugins.Plugin): + """Plugin contributing a configurable input route.""" + + def __init__(self, route: str): + self.route = route + + def register_route(self, *, add_page, **_): + """Contribute the input route. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div(), route=self.route) + + app = RewritingApp() + + with pytest.raises( + RouteValueError, + match=r"`canonical`.*plugin RoutePlugin \(plugins\[0\]\)", + ): + compiler._register_plugin_routes( + app, + [RoutePlugin("/first"), RoutePlugin("/second")], + ) + + assert app._unevaluated_pages == {} + + +def test_register_plugin_routes_does_not_evaluate_404_during_collection(): + """A later hook failure cannot trigger a staged 404 page callable.""" + calls: list[None] = [] + + def not_found_page(): + calls.append(None) + return rx.el.div("not found") + + class NotFoundPlugin(rx.plugins.Plugin): + """Plugin contributing a custom 404 callable.""" + + def register_route(self, *, add_page, **_): + """Contribute the custom 404. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(not_found_page, route="/404") + + class FailingPlugin(rx.plugins.Plugin): + """Plugin aborting the batch after 404 preparation.""" + + def register_route(self, **_): + """Abort route collection. + + Args: + _: Hook context. + + Raises: + RuntimeError: Always. + """ + msg = "abort registration" + raise RuntimeError(msg) + + app = rx.App() + + with pytest.raises(RuntimeError, match="abort registration"): + compiler._register_plugin_routes(app, [NotFoundPlugin(), FailingPlugin()]) + + assert calls == [] + assert app._unevaluated_pages == {} + + +def test_register_plugin_routes_uses_framework_commit_implementation(): + """A concrete commit override cannot make the staged batch partial.""" + + class OverridingApp(rx.App): + """App with a commit override that staged registration must bypass.""" + + commit_calls = 0 + + def _commit_page(self, prepared, **kwargs): + """Reject direct commits. + + Args: + prepared: The prepared page descriptor. + kwargs: Commit options. + + Raises: + RuntimeError: Always. + """ + self.commit_calls += 1 + msg = "custom commit rejected page" + raise RuntimeError(msg) + + app = OverridingApp() + + compiler._register_plugin_routes(app, [_RoutePlugin()]) + + assert app.commit_calls == 0 + assert "plugin-page" in app._unevaluated_pages + + +def test_register_plugin_routes_preserves_component_source_module(): + """A prebuilt Component keeps the plugin hook's source-module provenance.""" + + class ComponentPlugin(rx.plugins.Plugin): + """Plugin contributing a prebuilt component rather than a callable.""" + + def register_route(self, *, add_page, **_): + """Contribute the prebuilt page. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(rx.el.div("component page"), route="/component-page") + + app = rx.App() + + compiler._register_plugin_routes(app, [ComponentPlugin()]) + + assert app._unevaluated_pages["component-page"]._source_module == __name__ diff --git a/tests/units/reflex_base/plugins/__init__.py b/tests/units/reflex_base/plugins/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_base/plugins/test_base.py b/tests/units/reflex_base/plugins/test_base.py new file mode 100644 index 00000000000..7f4e9aa466b --- /dev/null +++ b/tests/units/reflex_base/plugins/test_base.py @@ -0,0 +1,72 @@ +"""Tests for plugin base helpers.""" + +import pytest +from pytest_mock import MockerFixture +from reflex_base.plugins import Plugin, get_plugin +from reflex_base.utils.exceptions import ConfigError + + +class ConfiguredPlugin(Plugin): + """Plugin type configured by the tests.""" + + +class ConfiguredSubPlugin(ConfiguredPlugin): + """Configured plugin subtype.""" + + +class MissingPlugin(Plugin): + """Plugin type absent from the test configuration.""" + + +def _configure(mocker: MockerFixture, *plugins: Plugin) -> None: + """Install a config containing the given plugins. + + Args: + mocker: The pytest-mock fixture. + plugins: Plugin instances to configure. + """ + config = mocker.Mock(plugins=plugins) + mocker.patch("reflex_base.config.get_config", return_value=config) + + +def test_get_plugin_returns_configured_instance(mocker: MockerFixture): + """The configured instance is returned by its type.""" + plugin = ConfiguredPlugin() + _configure(mocker, plugin) + + assert get_plugin(ConfiguredPlugin) is plugin + + +def test_get_plugin_returns_none_when_missing(mocker: MockerFixture): + """An absent plugin type resolves to None.""" + _configure(mocker, ConfiguredPlugin()) + + assert get_plugin(MissingPlugin) is None + + +def test_get_plugin_matches_subclasses(mocker: MockerFixture): + """A subclass instance satisfies a base-plugin lookup.""" + plugin = ConfiguredSubPlugin() + _configure(mocker, plugin) + + assert get_plugin(ConfiguredPlugin) is plugin + + +def test_get_plugin_rejects_ambiguous_matches(mocker: MockerFixture): + """Multiple matching plugins raise instead of depending on list order.""" + _configure(mocker, ConfiguredPlugin(), ConfiguredSubPlugin()) + + with pytest.raises(ConfigError, match="Multiple ConfiguredPlugin instances"): + get_plugin(ConfiguredPlugin) + + +def test_register_route_default_is_noop(): + """The base route hook accepts the staged context and contributes nothing.""" + assert ( + Plugin().register_route( + app_type=object, # pyright: ignore[reportArgumentType] + add_page=lambda *args, **kwargs: None, + has_app_page=lambda route: False, + ) + is None + ) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 270f040f684..7b5c000295e 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -23,7 +23,7 @@ from reflex_base.event import Event from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor -from reflex_base.plugins import CompileContext, CompilerHooks, PageContext +from reflex_base.plugins import CompileContext, CompilerHooks, PageContext, Plugin from reflex_base.registry import RegistrationContext from reflex_base.style import Style from reflex_base.utils import console, exceptions, format, memo_paths @@ -3868,3 +3868,45 @@ def test_call_ignores_stale_marker_without_dev_backend_reload( compile_mock.assert_called_once() assert compile_mock.call_args.kwargs["trigger"] == "backend_startup" + + +def test_add_page_invalidates_router_cache(): + """Adding a page refreshes a router built from the old route set.""" + app = App() + assert app.router("/late") is None + + app.add_page(lambda: rx.el.div(), route="/late") + + assert app.router("/late") == "late" + + +def test_compile_registers_plugin_routes( + compilable_app: tuple[App, Path], + mocker: MockerFixture, +): + """Compilation includes pages contributed by configured plugins.""" + + class RoutePlugin(Plugin): + """Plugin contributing one page for the compile test.""" + + def register_route(self, *, add_page, **_): + """Contribute the test page through the staged capability. + + Args: + add_page: The staged page-adding capability. + _: Additional hook context. + """ + add_page(lambda: rx.el.div("plugin page"), route="/from-plugin") + + plugin = RoutePlugin() + conf = rx.Config(app_name="testing", plugins=[plugin]) + mocker.patch("reflex_base.config._get_config", return_value=conf) + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + app.add_page(lambda: rx.el.div("Index"), route="/") + + app._compile(dry_run=True, use_rich=False) + + assert "from-plugin" in app._unevaluated_pages + assert "from-plugin" in app._pages + assert app._plugin_routes_registered