Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/api-reference/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions news/6728.feature.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6728.feature.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
PostBuildContext,
PostCompileContext,
PreCompileContext,
RegisterRouteContext,
get_plugin,
)
from .compiler import (
BaseContext,
Expand Down Expand Up @@ -35,11 +37,13 @@
"PostBuildContext",
"PostCompileContext",
"PreCompileContext",
"RegisterRouteContext",
"SitemapPlugin",
"TailwindV3Plugin",
"TailwindV4Plugin",
"_ScreenshotPlugin",
"embed",
"get_plugin",
"sitemap",
"tailwind_v3",
"tailwind_v4",
Expand Down
103 changes: 100 additions & 3 deletions packages/reflex-base/src/reflex_base/plugins/base.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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):
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Loading
Loading