-
Notifications
You must be signed in to change notification settings - Fork 605
Open
Labels
feature requestSuggestion/Request for additional featureSuggestion/Request for additional feature
Description
Duplicate Check
- I have searched the opened issues and there are no duplicates
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 viewsSuggest a solution
No response
Screenshots
No response
Additional details
No response
Metadata
Metadata
Assignees
Labels
feature requestSuggestion/Request for additional featureSuggestion/Request for additional feature