Skip to content

feature: Router Manager #5961

@iBuitron

Description

@iBuitron

Duplicate Check

Describe the requested feature

import asyncio
from dataclasses import dataclass
from types import ModuleType
from typing import Optional

import flet as ft


@dataclass
@ft.observable
class RouterModel:
    """
    Internal model to manage routing state reactively.

    This observable model tracks the current route and handles navigation events.
    When the route changes, all components subscribed to this model will re-render.

    **Design Pattern: Observer Pattern**
    - RouterModel is the subject (observable)
    - Components using Router.App() are observers
    - Route changes trigger reactive updates
    """

    route: str

    def route_change(self, e: ft.RouteChangeEvent) -> None:
        """Handle route change events from ft.Page"""
        print(f"Route changed from: {self.route} to: {e.route}")
        self.route = e.route

    async def view_popped(self, e: ft.ViewPopEvent) -> None:
        """Handle back button / view pop events"""
        print("View popped")
        views = ft.unwrap_component(ft.context.page.views)
        if len(views) > 1:
            await ft.context.page.push_route("/")


class Router:
    """
    A module-based routing system designed for Flet's declarative architecture.

    ═══════════════════════════════════════════════════════════════════════════
    DESIGN PATTERN: Module-Based Routing with Template Matching
    ═══════════════════════════════════════════════════════════════════════════

    **Core Concepts:**

    1. **Module-Based Architecture**:
       - Each page/view is a separate Python module (file)
       - Promotes separation of concerns and code organization
       - Makes it easy to add/remove pages without touching routing logic

    2. **Declarative Route Definitions**:
       - Routes are defined as module-level constants (ROUTE = "/path")
       - Views are defined as @ft.component functions
       - Router automatically discovers and registers routes

    3. **Two-Tier Routing System**:
       - **Static Routes**: Exact path matches (O(1) lookup)
         Example: ROUTE = "/home", ROUTE = "/about"
       - **Template Routes**: Parameterized paths with pattern matching
         Example: ROUTE = "/books/:id", ROUTE = "/users/:userId/posts/:postId"

    4. **View Stack Management**:
       - Maintains navigation history (view stack)
       - Supports browser back/forward buttons
       - Handles view pop events automatically

    ═══════════════════════════════════════════════════════════════════════════
    WHY USE A MODULE-LEVEL `ROUTE` CONSTANT?
    ═══════════════════════════════════════════════════════════════════════════

    Defining `ROUTE` as a constant outside the view function is crucial because:

    **1. Performance - Lazy View Instantiation:**
       - Router scans and registers routes WITHOUT executing view functions
       - Views are only instantiated when actually navigated to
       - Avoids loading unnecessary dependencies at startup

    **2. Prevents Circular Dependencies:**
       - Router doesn't need to call view() to discover its path
       - View can import other modules without import order issues

    **3. Route Parsing & Validation:**
       - Router can pre-parse dynamic routes (like /books/:id) at startup
       - Extracts parameter names before any view logic runs
       - Enables route validation and autocomplete in IDEs

    **4. Separation of Concerns:**
       - Route definition (path) is separate from view logic (rendering)
       - Clear contract: "This module handles this path"
       - Makes refactoring and testing easier

    ═══════════════════════════════════════════════════════════════════════════
    IMPLEMENTATION GUIDE FOR DEVELOPERS
    ═══════════════════════════════════════════════════════════════════════════

    **Step 1: Create Page Modules**

    Each page module must define:
    - `ROUTE`: String constant for the path
    - `view()`: Function returning an ft.View

    Example - Static Route (pages/home.py):
        ```python
        import flet as ft

        ROUTE = "/"  # Module-level constant

        @ft.component
        def view() -> ft.View:
            return ft.View(
                route=ROUTE,
                appbar=ft.AppBar(title=ft.Text("Home")),
                controls=[ft.Text("Welcome Home!")]
            )
        ```

    Example - Parameterized Route (pages/book_detail.py):
        ```python
        import flet as ft

        ROUTE = "/books/:id"  # Parameter marked with :

        @ft.component
        def view(id: str = None) -> ft.View:  # Parameters MUST have defaults
            return ft.View(
                route=ROUTE,
                appbar=ft.AppBar(title=ft.Text(f"Book {id}")),
                controls=[ft.Text(f"Showing details for book ID: {id}")]
            )
        ```

    **Step 2: Initialize Router in Main App**

    ```python
    import flet as ft
    from pages import home, books, book_detail
    from utils.router import Router

    def main(page: ft.Page):
        # Create router with list of page modules
        router = Router(routes=[home, books, book_detail])

        # Render router as root component
        page.add(router.App())

    ft.app(target=main, view=ft.AppView.WEB_BROWSER)
    ```

    **Step 3: Navigate Between Pages**

    ```python
    import asyncio
    import flet as ft

    # Navigate to static route
    ft.ElevatedButton(
        "Go to Home",
        on_click=lambda _: asyncio.create_task(
            ft.context.page.push_route("/")
        )
    )

    # Navigate to parameterized route
    ft.ElevatedButton(
        "View Book 42",
        on_click=lambda _: asyncio.create_task(
            ft.context.page.push_route("/books/42")
        )
    )
    ```

    ═══════════════════════════════════════════════════════════════════════════
    ADVANCED FEATURES
    ═══════════════════════════════════════════════════════════════════════════

    **Multiple Route Parameters:**

    ```python
    # pages/user_post.py
    ROUTE = "/users/:userId/posts/:postId"

    @ft.component
    def view(userId: str = None, postId: str = None) -> ft.View:
        return ft.View(
            route=ROUTE,
            controls=[ft.Text(f"User {userId}, Post {postId}")]
        )
    ```

    **Route Validation:**

    ```python
    router = Router(routes=[...])

    # Check if route exists before navigating
    if router.route_exists("/books/123"):
        await page.push_route("/books/123")

    # Get all registered routes
    all_routes = router.get_all_routes()
    print(all_routes)  # ["/", "/books", "/books/:id", ...]
    ```

    **Custom 404 Page:**

    Override the NotFoundView method in your router subclass:

    ```python
    class MyRouter(Router):
        @staticmethod
        @ft.component
        def NotFoundView(route: str) -> ft.View:
            return ft.View(
                route=route,
                controls=[ft.Text("Custom 404 - Page not found!")]
            )
    ```

    ═══════════════════════════════════════════════════════════════════════════
    TECHNICAL IMPLEMENTATION DETAILS
    ═══════════════════════════════════════════════════════════════════════════

    **Routing Algorithm:**

    1. **Static Route Lookup (O(1))**:
       - Uses dictionary for exact path matches
       - Example: "/home" → direct lookup in _static_routes dict

    2. **Template Route Matching (O(n))**:
       - Uses ft.TemplateRoute for pattern matching
       - Iterates through _template_routes until match found
       - Extracts parameters and passes to view function

    **View Stack Management:**

    - Router maintains a navigation stack (list of ft.View)
    - Root view ("/") is always at the bottom of the stack
    - Current route view is stacked on top
    - Back button pops views from stack

    **Reactive Updates:**

    - RouterModel is @ft.observable
    - When route changes, RouterModel.route updates
    - Components using Router.App() re-render automatically
    - Uses Flet's reactive system under the hood

    **Memory Management:**

    - Views are created on-demand (not pre-instantiated)
    - Old views are garbage collected when popped
    - Router only stores module references, not view instances

    ═══════════════════════════════════════════════════════════════════════════
    COMPARISON WITH OTHER ROUTING APPROACHES
    ═══════════════════════════════════════════════════════════════════════════

    | Approach | Pros | Cons | Use Case |
    |----------|------|------|----------|
    | **Manual Route Handling** | Simple, explicit | Verbose, hard to scale | Small apps (1-3 pages) |
    | **Dictionary-Based Router** | Flexible | No type safety, manual registration | Medium apps |
    | **Module-Based Router** | Organized, scalable, auto-discovery | Requires file structure | Large apps (this router) |
    | **File-System Router (Next.js)** | Zero config | Inflexible structure | Web frameworks |

    ═══════════════════════════════════════════════════════════════════════════
    POTENTIAL FLET CORE INTEGRATION
    ═══════════════════════════════════════════════════════════════════════════

    This declarative routing system could be integrated as: `flet.Router`

    **Proposed API:**

    ```python
    import flet as ft

    # Native router as app parameter
    ft.app(
        target=main,
        router=ft.Router(routes=[home, books, product])
    )

    # Or as a built-in method
    def main(page: ft.Page):
        page.register_routes([home, books, product])
        # Routes automatically handled by page
    ```

    **Benefits of Integration:**
    - No manual on_route_change / on_view_pop wiring
    - Built-in route validation and type checking
    - Optimized view caching and transitions
    - Official file structure conventions
    - Better error messages and debugging
    - IDE support for route autocomplete

    **This would make Flet competitive with frameworks like:**
    - Next.js (React) - File-system routing
    - Flask (Python) - Decorator-based routing
    - Vue Router (Vue.js) - Declarative routing
    """

    def __init__(self, routes: list[ModuleType]) -> None:
        """
        Initializes the router with a list of route modules.

        This constructor scans all provided modules, extracts their ROUTE constants,
        and builds optimized data structures for fast route lookup.

        Args:
            routes: List of modules, where each module must have:
                   - ROUTE: str constant defining the path
                   - view: callable returning ft.View

        Raises:
            AttributeError: If any module is missing ROUTE or view
            TypeError: If ROUTE is not a string

        Example:
            from pages import home, books, book_detail
            router = Router(routes=[home, books, book_detail])
        """
        self.routes = routes
        self._static_routes = {}  # Exact match routes for O(1) lookup
        self._template_routes = []  # Template routes with parameters
        self._build_route_map()

    def _build_route_map(self) -> None:
        """
        Builds optimized route mappings from module ROUTE constants.

        This method separates routes into two categories for performance:
        1. Static routes → O(1) dictionary lookup
        2. Template routes → O(n) pattern matching with cached parameters

        Called automatically by __init__.
        """
        for module in self.routes:
            if not hasattr(module, "view"):
                raise AttributeError(
                    f"Module {module.__name__} does not have a 'view()' function"
                )

            if not hasattr(module, "ROUTE"):
                raise AttributeError(
                    f"Module {module.__name__} does not have a 'ROUTE' constant. "
                    f"Add 'ROUTE = \"/your/path\"' at the module level."
                )

            route_path = module.ROUTE

            if not isinstance(route_path, str):
                raise TypeError(
                    f"Module {module.__name__}: ROUTE must be a string (got {type(route_path).__name__})"
                )

            # Separate static routes from template routes
            if ":" in route_path:
                # Extract parameter names from template
                param_names = [
                    part[1:] for part in route_path.split("/") if part.startswith(":")
                ]
                self._template_routes.append(
                    {
                        "path": route_path,
                        "module": module,
                        "params": param_names,
                    }
                )
            else:
                self._static_routes[route_path] = module

    def get_view_component(self, route: str) -> Optional[ft.View]:
        """
        Retrieves the view component corresponding to a given route.

        **Lookup Algorithm:**
        1. Try exact match in static routes (O(1))
        2. If not found, try template matching (O(n))
        3. Extract parameters from URL and pass to view()

        Args:
            route: The requested route path (e.g., "/books/42")

        Returns:
            ft.View instance if route exists, None otherwise

        Example:
            view = router.get_view_component("/books/42")
            # Calls book_detail.view(id="42")
        """
        # Try exact match first (O(1) lookup)
        module = self._static_routes.get(route)
        if module:
            return module.view()

        # Try template route matching for parameterized routes
        troute = ft.TemplateRoute(route)
        for template_route in self._template_routes:
            if troute.match(template_route["path"]):
                # Extract parameters using cached parameter names
                params = {
                    param: getattr(troute, param)
                    for param in template_route["params"]
                    if hasattr(troute, param)
                }
                return template_route["module"].view(**params)

        return None

    def get_all_routes(self) -> list[str]:
        """
        Returns all registered route paths.

        Useful for debugging, generating sitemaps, or building navigation menus.

        Returns:
            List of all route paths (both static and template routes)

        Example:
            routes = router.get_all_routes()
            # ["/", "/books", "/books/:id", "/users/:id/posts/:postId"]
        """
        static = list(self._static_routes.keys())
        template = [tr["path"] for tr in self._template_routes]
        return static + template

    def route_exists(self, route: str) -> bool:
        """
        Checks if a route exists in the router.

        Validates both static and template routes. Useful for preventing
        navigation to non-existent pages.

        Args:
            route: The route path to check (e.g., "/books/42")

        Returns:
            True if route exists (either exact match or template match)

        Example:
            if router.route_exists("/books/42"):
                await page.push_route("/books/42")
            else:
                print("Invalid route!")
        """
        # Check static routes first
        if route in self._static_routes:
            return True

        # Check template routes
        troute = ft.TemplateRoute(route)
        for template_route in self._template_routes:
            if troute.match(template_route["path"]):
                return True

        return False

    @staticmethod
    @ft.component
    def NotFoundView(route: str) -> ft.View:
        """
        Returns a 404 error view component for non-existent routes.

        This is called automatically when navigation to an invalid route occurs.
        Override this method in a subclass to customize the 404 page.

        Args:
            route: The invalid route that was requested

        Returns:
            ft.View with 404 error message and navigation options

        Example - Custom 404:
            class MyRouter(Router):
                @staticmethod
                @ft.component
                def NotFoundView(route: str) -> ft.View:
                    return ft.View(controls=[ft.Text("Custom 404!")])
        """
        return ft.View(
            route=route,
            appbar=ft.AppBar(title=ft.Text("Error 404")),
            controls=[
                ft.Container(
                    content=ft.Column(
                        [
                            ft.Text(
                                "404 - Page Not Found",
                                size=24,
                                weight=ft.FontWeight.BOLD,
                            ),
                            ft.Text(
                                f"Route: {route}",
                                size=14,
                                color=ft.Colors.GREY,
                            ),
                            ft.Button(
                                "Go to Home",
                                on_click=lambda _: asyncio.create_task(
                                    ft.context.page.push_route("/")
                                ),
                            ),
                        ],
                        alignment=ft.MainAxisAlignment.CENTER,
                        horizontal_alignment=ft.CrossAxisAlignment.CENTER,
                        spacing=20,
                    ),
                    padding=40,
                    alignment=ft.Alignment.CENTER,
                ),
            ],
        )

    @ft.component
    def App(self) -> list[ft.View]:
        """
        The main router component - use this as your app's root.

        **What this component does:**

        1. **Initializes Router State**:
           - Creates RouterModel with current route
           - Subscribes to page navigation events

        2. **Manages View Stack**:
           - Always includes root view ("/") at bottom
           - Stacks current route view on top
           - Enables back button navigation

        3. **Handles Route Changes**:
           - Listens to page.on_route_change events
           - Re-renders when route changes (reactive)
           - Shows 404 view for invalid routes

        **Usage:**

        ```python
        def main(page: ft.Page):
            router = Router(routes=[home, books])
            page.add(router.App())  # Add router as root component

        ft.app(target=main)
        ```

        Returns:
            List of ft.View objects representing the navigation stack

        Note:
            This component is reactive - it re-renders automatically when
            the route changes via page.push_route() or back button.
        """

        # Initialize router model with current route
        router_model, _ = ft.use_state(RouterModel(route=ft.context.page.route))

        # Subscribe to page events
        ft.context.page.on_route_change = router_model.route_change
        ft.context.page.on_view_pop = router_model.view_popped

        # Build navigation stack
        views = []

        # Always add root view as base of the stack
        root_view = self.get_view_component("/")
        if root_view is not None:
            views.append(root_view)

        # If current route is different from root, stack it on top
        if router_model.route != "/":
            current_view = self.get_view_component(router_model.route)

            if current_view is None:
                current_view = self.NotFoundView(router_model.route)

            views.append(current_view)

        # Fallback: if no root view exists and we're at root
        if len(views) == 0:
            views.append(self.NotFoundView(router_model.route))

        return views

Suggest a solution

No response

Screenshots

No response

Additional details

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature requestSuggestion/Request for additional feature

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions