Files
rspade_system/app/RSpade/man/spa.txt
root 9ebcc359ae Fix code quality violations and enhance ROUTE-EXISTS-01 rule
Implement JQHTML function cache ID system and fix bundle compilation
Implement underscore prefix for system tables
Fix JS syntax linter to support decorators and grant exception to Task system
SPA: Update planning docs and wishlists with remaining features
SPA: Document Navigation API abandonment and future enhancements
Implement SPA browser integration with History API (Phase 1)
Convert contacts view page to SPA action
Convert clients pages to SPA actions and document conversion procedure
SPA: Merge GET parameters and update documentation
Implement SPA route URL generation in JavaScript and PHP
Implement SPA bootstrap controller architecture
Add SPA routing manual page (rsx:man spa)
Add SPA routing documentation to CLAUDE.md
Phase 4 Complete: Client-side SPA routing implementation
Update get_routes() consumers for unified route structure
Complete SPA Phase 3: PHP-side route type detection and is_spa flag
Restore unified routes structure and Manifest_Query class
Refactor route indexing and add SPA infrastructure
Phase 3 Complete: SPA route registration in manifest
Implement SPA Phase 2: Extract router code and test decorators
Rename Jqhtml_Component to Component and complete SPA foundation setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 17:48:15 +00:00

689 lines
25 KiB
Plaintext
Executable File

SPA(3) RSX Framework Manual SPA(3)
NAME
spa - Single Page Application routing for authenticated areas
SYNOPSIS
PHP Bootstrap Controller (one per SPA module):
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
{
#[SPA]
#[Auth('Permission::authenticated()')]
public static function index(Request $request, array $params = [])
{
return rsx_view(SPA);
}
}
JavaScript Action (many per module):
@route('/contacts')
@layout('Frontend_Layout')
@spa('Frontend_Spa_Controller::index')
class Contacts_Index_Action extends Spa_Action {
async on_load() {
this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch();
}
}
DESCRIPTION
The RSX SPA system provides client-side routing for authenticated application
areas, enabling navigation without page reloads. Unlike traditional Laravel
views where each navigation triggers a full page load, SPA modules bootstrap
once and handle all subsequent navigation client-side through JavaScript
actions.
This approach is fundamentally different from traditional server-side routing:
- Traditional: Every navigation loads new HTML from server
- SPA: First request bootstraps application, subsequent navigation is client-side
Key differences from Laravel:
- Laravel: Full page reload per navigation, routes in routes/ files
- RSX: Bootstrap once, routes defined in JavaScript with @route() decorator
- Laravel: Separate frontend frameworks (Vue, React) require build tooling
- RSX: Integrated JQHTML component system, no external build required
- Laravel: API routes separate from view routes
- RSX: Controllers provide Ajax endpoints, actions consume them
Benefits:
- No page reloads after initial bootstrap
- Persistent layout across navigation
- Automatic browser history integration
- Same Rsx::Route() syntax for both traditional and SPA routes
- No frontend build tooling required
When to use:
- Authenticated areas (dashboards, admin panels)
- Applications with frequent navigation
- Features requiring persistent state
When to avoid:
- Public pages requiring SEO
- Simple static content
- Forms without complex interactions
SPA ARCHITECTURE
SPA Module Structure:
An SPA module consists of six component types:
1. PHP Bootstrap Controller (ONE per feature/bundle)
- Single entry point marked with #[SPA]
- Performs authentication check with failure/redirect
- Renders SPA bootstrap layout
- Referenced by all actions via @spa() decorator
- One #[SPA] per feature/bundle (e.g., /app/frontend, /app/root, /app/login)
- Bundles segregate code for security and performance:
* Save bandwidth by loading only needed features
* Reduce processing time by smaller bundle sizes
* Protect confidential code (e.g., root admin from unauthorized users)
- Typically one #[SPA] per feature at rsx/app/(feature)/(feature)_spa_controller::index
2. Feature Controllers (Ajax Endpoints Only)
- Provide data via #[Ajax_Endpoint] methods
- No #[SPA] or #[Route] methods
- Called by actions to fetch/save data
3. JavaScript Actions (MANY per module)
- Represent individual pages/routes
- Define route patterns with @route()
- Load data in on_load() lifecycle method
- Access URL parameters via this.args
4. Action Templates (.jqhtml)
- Render action content
- Standard JQHTML component templates
- Replace traditional Blade views
5. Layout Template (.jqhtml)
- Persistent wrapper around actions
- Must have element with $id="content"
- Persists across action navigation
6. Layout Class (.js)
- Extends Spa_Layout
- Optional on_action() hook for navigation tracking
Execution Flow:
First Request:
1. User navigates to SPA route (e.g., /contacts)
2. Dispatcher calls bootstrap controller
3. Bootstrap returns rsx_view(SPA) with window.rsxapp.is_spa = true
4. Client JavaScript discovers all actions via manifest
5. Router matches URL to action class
6. Creates layout on <body>
7. Creates action inside layout $id="content" area
Subsequent Navigation:
1. User clicks link or calls Spa.dispatch()
2. Router matches URL to action class
3. Destroys old action, creates new action
4. Layout persists - no re-render
5. No server request, no page reload
BOOTSTRAP CONTROLLER
Creating SPA Entry Point:
// /rsx/app/frontend/Frontend_Spa_Controller.php
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
{
#[SPA]
#[Auth('Permission::authenticated()')]
public static function index(Request $request, array $params = [])
{
return rsx_view(SPA);
}
}
Key Points:
- One bootstrap controller per feature/bundle
- #[SPA] attribute marks as SPA entry point
- #[Auth] performs server-side authentication with failure/redirect
- Must return rsx_view(SPA) - special constant
- All actions in the same bundle reference this via @spa() decorator
Bundle Segregation:
- Separate bundles for different features: /app/frontend, /app/root, /app/login
- Each bundle has its own #[SPA] bootstrap
- Benefits:
* Bandwidth: Users only download code for authorized features
* Performance: Smaller bundles = faster load times
* Security: Root admin code never sent to unauthorized users
- Example: Frontend users never receive root admin JavaScript/CSS
Location:
- Place at feature root: /rsx/app/{feature}/
- Not in subdirectories
- One per feature/bundle
- Naming: {Feature}_Spa_Controller::index
Multiple SPA Bootstraps:
Different features can have separate SPA bootstraps:
- /app/frontend/Frontend_Spa_Controller::index (regular users)
- /app/root/Root_Spa_Controller::index (root admins)
- /app/login/Login_Spa_Controller::index (authentication flow)
Each bootstrap controls access to its feature's actions via #[Auth].
FEATURE CONTROLLERS
Ajax Endpoint Pattern:
Feature controllers provide data endpoints only, no routes or SPA entry:
// /rsx/app/frontend/contacts/frontend_contacts_controller.php
class Frontend_Contacts_Controller extends Rsx_Controller_Abstract
{
#[Ajax_Endpoint]
public static function datagrid_fetch(Request $request, array $params = []): array
{
return Contacts_DataGrid::fetch($params);
}
#[Ajax_Endpoint]
public static function save(Request $request, array $params = [])
{
// Validation and save logic
$contact->save();
Flash_Alert::success('Contact saved successfully');
return [
'contact_id' => $contact->id,
'redirect' => Rsx::Route('Contacts_View_Action', $contact->id),
];
}
}
Guidelines:
- All methods are #[Ajax_Endpoint]
- No #[Route] methods in SPA feature controllers
- No #[SPA] methods (only in bootstrap controller)
- Return arrays/data, not views
- Use Flash_Alert with redirects for success messages
See Also:
ajax_error_handling(3) for error patterns
controller(3) for #[Ajax_Endpoint] details
JAVASCRIPT ACTIONS
Creating Actions:
// /rsx/app/frontend/contacts/Contacts_Index_Action.js
@route('/contacts')
@layout('Frontend_Layout')
@spa('Frontend_Spa_Controller::index')
class Contacts_Index_Action extends Spa_Action {
async on_load() {
this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch();
}
on_create() {
// Component initialization
}
on_ready() {
// DOM is ready, setup event handlers
this.$id('search').on('input', () => this.reload());
}
}
Decorator Syntax:
@route(pattern)
URL pattern with optional :param segments
Example: '/contacts/:id', '/users/:id/posts/:post_id'
@layout(class_name)
Layout class to render within
Example: 'Frontend_Layout', 'Dashboard_Layout'
@spa(controller::method)
References bootstrap controller
Example: 'Frontend_Spa_Controller::index'
@title(page_title)
Browser page title for this action (optional, clears if not present)
Alternatively, set dynamically in on_ready() via document.title = "value"
Example: @title('Contacts - RSX')
URL Parameters:
Parameters from route pattern and query string available in this.args:
// URL: /contacts/123?tab=history
@route('/contacts/:id')
class Contacts_View_Action extends Spa_Action {
on_create() {
console.log(this.args.id); // "123"
console.log(this.args.tab); // "history"
}
}
Lifecycle Methods:
on_create() Component construction, setup this.state
on_load() Fetch data, populate this.data (read-only this.args)
on_render() Template rendering
on_ready() DOM ready, setup event handlers
See Also:
jqhtml(3) for complete lifecycle documentation
ACTION TEMPLATES
Creating Templates:
Action templates are standard JQHTML components:
<!-- /rsx/app/frontend/contacts/Contacts_Index_Action.jqhtml -->
<Define:Contacts_Index_Action tag="main">
<h1>Contacts</h1>
<div class="contacts-list">
<% for (let contact of this.data.contacts) { %>
<div class="contact-item">
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>">
<%= contact.name %>
</a>
</div>
<% } %>
</div>
</Define:Contacts_Index_Action>
Key Points:
- Use Rsx.Route() for all URLs, never hardcode
- Access data via this.data (loaded in on_load)
- Use standard JQHTML template syntax
- Can reference other components
LAYOUTS
Layout Template:
Layout provides persistent wrapper around actions:
<!-- /rsx/app/frontend/Frontend_Layout.jqhtml -->
<Define:Frontend_Layout>
<div class="frontend-layout">
<header class="mb-3">
<h1>My App</h1>
<nav>
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
<a href="<%= Rsx.Route('Projects_Index_Action') %>">Projects</a>
</nav>
</header>
<main $id="content">
<!-- Actions render here -->
</main>
<footer class="mt-3">
<p>&copy; 2024 My Company</p>
</footer>
</div>
</Define:Frontend_Layout>
Requirements:
- Must have element with $id="content"
- Content area is where actions render
- Layout persists across navigation
Layout Class:
Optional JavaScript class for navigation tracking:
// /rsx/app/frontend/Frontend_Layout.js
class Frontend_Layout extends Spa_Layout {
on_action(url, action_name, args) {
// Called AFTER action component created and booted
// Called BEFORE action reaches on_ready()
// Can immediately access this.action properties
// Update active nav highlighting
this.$('.nav-link').removeClass('active');
this.$(`[data-action="${action_name}"]`).addClass('active');
// If you need to wait for action's full lifecycle:
// await this.action.ready();
}
}
on_action() Lifecycle Timing:
- Called after action component is created and booted
- Called before action reaches on_ready()
- Can immediately access this.action properties
- Use await this.action.ready() to wait for action's full loading
URL GENERATION
CRITICAL: All URLs must use Rsx::Route() or Rsx.Route(). Raw URLs like
"/contacts" will produce errors.
Signature:
Rsx::Route($action, $params = null)
Rsx.Route(action, params = null)
Where:
- $action: Controller class, SPA action, or "Class::method"
Defaults to 'index' method if no :: present
- $params: Integer sets 'id', array/object provides named params
PHP Syntax:
// Works for both controller routes and SPA actions
Rsx::Route('Contacts_Index_Action') // /contacts
Rsx::Route('Contacts_View_Action', 123) // /contacts/123
JavaScript Syntax:
// Works for both controller routes and SPA actions
Rsx.Route('Contacts_Index_Action') // /contacts
Rsx.Route('Contacts_View_Action', 123) // /contacts/123
Automatic Detection:
Rsx::Route() automatically detects whether the class is:
- PHP controller with #[Route] attribute
- JavaScript SPA action with @route() decorator
Query Parameters:
Extra parameters become query string:
Rsx::Route('Contacts_Index_Action', ['filter' => 'active', 'sort' => 'name'])
// Result: /contacts?filter=active&sort=name
NAVIGATION
Automatic Browser Integration:
- Clicking links to SPA routes triggers client-side navigation
- Browser back/forward buttons handled without page reload
- URL updates in address bar via pushState()
- Links to external or non-SPA routes perform full page load
Programmatic Navigation:
Spa.dispatch(url)
Navigate to URL programmatically
If URL is part of current SPA, uses client-side routing
If URL is external or different SPA, performs full page load
Example:
// Navigate within SPA
Spa.dispatch('/contacts/123');
// Navigate to external URL (full page load)
Spa.dispatch('https://example.com');
Use Spa.dispatch() instead of window.location for all navigation.
Accessing Active Components:
Spa.layout Reference to current layout component instance
Spa.action Reference to current action component instance
Example:
// From anywhere in SPA context
Spa.action.reload(); // Reload current action
Spa.layout.update_nav(); // Call layout method
FILE ORGANIZATION
Standard SPA Module Structure:
/rsx/app/frontend/
|-- Frontend_Spa_Controller.php # Bootstrap controller
|-- Frontend_Layout.js # Layout class
|-- Frontend_Layout.jqhtml # Layout template
|-- frontend_bundle.php # Bundle definition
|
|-- contacts/
| |-- frontend_contacts_controller.php # Ajax endpoints
| |-- Contacts_Index_Action.js # /contacts
| |-- Contacts_Index_Action.jqhtml
| |-- Contacts_View_Action.js # /contacts/:id
| `-- Contacts_View_Action.jqhtml
|
`-- projects/
|-- frontend_projects_controller.php
|-- Projects_Index_Action.js
`-- Projects_Index_Action.jqhtml
Naming Conventions:
- Bootstrap controller: {Module}_Spa_Controller.php
- Feature controllers: {feature}_{module}_controller.php
- Actions: {Feature}_{Action}_Action.js
- Templates: {Feature}_{Action}_Action.jqhtml
- Layout: {Module}_Layout.js and .jqhtml
EXAMPLES
Complete Contacts Module:
1. Bootstrap Controller:
// /rsx/app/frontend/Frontend_Spa_Controller.php
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
{
#[SPA]
#[Auth('Permission::authenticated()')]
public static function index(Request $request, array $params = [])
{
return rsx_view(SPA);
}
}
2. Feature Controller:
// /rsx/app/frontend/contacts/frontend_contacts_controller.php
class Frontend_Contacts_Controller extends Rsx_Controller_Abstract
{
#[Ajax_Endpoint]
public static function fetch_all(Request $request, array $params = []): array
{
$contacts = Contact_Model::query()
->where('is_active', 1)
->orderBy('name')
->get();
return $contacts->toArray();
}
#[Ajax_Endpoint]
public static function save(Request $request, array $params = [])
{
$contact = Contact_Model::findOrNew($params['id'] ?? null);
$contact->fill($params);
$contact->save();
Flash_Alert::success('Contact saved');
return [
'contact_id' => $contact->id,
'redirect' => Rsx::Route('Contacts_View_Action', $contact->id),
];
}
}
3. List Action:
// /rsx/app/frontend/contacts/Contacts_Index_Action.js
@route('/contacts')
@layout('Frontend_Layout')
@spa('Frontend_Spa_Controller::index')
class Contacts_Index_Action extends Spa_Action {
async on_load() {
this.data.contacts = await Frontend_Contacts_Controller.fetch_all();
}
}
4. List Template:
<!-- /rsx/app/frontend/contacts/Contacts_Index_Action.jqhtml -->
<Define:Contacts_Index_Action tag="main">
<h1>Contacts</h1>
<% for (let contact of this.data.contacts) { %>
<div>
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>">
<%= contact.name %>
</a>
</div>
<% } %>
</Define:Contacts_Index_Action>
5. View Action with Parameters:
// /rsx/app/frontend/contacts/Contacts_View_Action.js
@route('/contacts/:id')
@layout('Frontend_Layout')
@spa('Frontend_Spa_Controller::index')
class Contacts_View_Action extends Spa_Action {
async on_load() {
const contact_id = this.args.id;
this.data.contact = await Contact_Model.fetch(contact_id);
}
}
6. Layout:
<!-- /rsx/app/frontend/Frontend_Layout.jqhtml -->
<Define:Frontend_Layout>
<div class="app">
<header>
<nav>
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
</nav>
</header>
<main $id="content"></main>
</div>
</Define:Frontend_Layout>
SPA VS TRADITIONAL ROUTES
Architecture Comparison:
Traditional Route:
Every navigation:
1. Browser sends HTTP request to server
2. Controller executes, queries database
3. Blade template renders full HTML page
4. Server sends complete HTML to browser
5. Browser parses and renders new page
6. JavaScript re-initializes
7. User sees page flash/reload
SPA Route:
First navigation:
1. Browser sends HTTP request to server
2. Bootstrap controller renders SPA shell
3. JavaScript initializes, discovers actions
4. Router matches URL to action
5. Action loads data via Ajax
6. Action renders in persistent layout
Subsequent navigation:
1. Router matches URL to action (client-side)
2. New action loads data via Ajax
3. New action renders in existing layout
4. No server request, no page reload
5. Layout persists (header/nav/footer stay)
Code Comparison:
Traditional:
// Controller
#[Route('/contacts')]
public static function index(Request $request, array $params = []) {
$contacts = Contact_Model::all();
return rsx_view('Contacts_List', ['contacts' => $contacts]);
}
// Blade view
@extends('layout')
@section('content')
<h1>Contacts</h1>
@foreach($contacts as $contact)
<div>{{ $contact->name }}</div>
@endforeach
@endsection
SPA:
// Bootstrap (once)
#[SPA]
public static function index(Request $request, array $params = []) {
return rsx_view(SPA);
}
// Ajax endpoint
#[Ajax_Endpoint]
public static function fetch_all(Request $request, array $params = []): array {
return Contact_Model::all()->toArray();
}
// Action
@route('/contacts')
@spa('Frontend_Spa_Controller::index')
class Contacts_Index_Action extends Spa_Action {
async on_load() {
this.data.contacts = await Frontend_Contacts_Controller.fetch_all();
}
}
// Template
<Define:Contacts_Index_Action>
<h1>Contacts</h1>
<% for (let contact of this.data.contacts) { %>
<div><%= contact.name %></div>
<% } %>
</Define:Contacts_Index_Action>
TROUBLESHOOTING
SPA Not Initializing:
- Verify bootstrap controller has #[SPA] attribute
- Check controller returns rsx_view(SPA)
- Ensure bundle includes SPA directory
- Verify window.rsxapp.is_spa is true in HTML
Action Not Found:
- Check @route() decorator matches URL pattern
- Verify action extends Spa_Action
- Ensure action file included in bundle
- Run: php artisan rsx:manifest:build
Navigation Not Working:
- Verify using Rsx.Route() not hardcoded URLs
- Check @spa() decorator references correct bootstrap controller
- Ensure layout has $id="content" element
- Test Spa.dispatch() directly
Layout Not Persisting:
- Verify all actions in module use same @layout()
- Check layout template has $id="content"
- Ensure not mixing SPA and traditional routes
this.args Empty:
- Check route pattern includes :param for dynamic segments
- Verify URL matches route pattern exactly
- Remember query params (?key=value) also in this.args
Data Not Loading:
- Verify Ajax endpoint has #[Ajax_Endpoint]
- Check endpoint returns array, not view
- Ensure await used in on_load() for async calls
- Verify controller included in bundle
COMMON PATTERNS
Conditional Navigation:
// Navigate based on condition
if (user.is_admin) {
Spa.dispatch(Rsx.Route('Admin_Dashboard_Action'));
} else {
Spa.dispatch(Rsx.Route('User_Dashboard_Action'));
}
Reloading Current Action:
// Refresh current action data
Spa.action.reload();
Form Submission with Redirect:
async on_submit() {
const data = this.vals();
const result = await Controller.save(data);
if (result.errors) {
Form_Utils.apply_form_errors(this.$, result.errors);
return;
}
// Flash alert set server-side, redirect to view
Spa.dispatch(result.redirect);
}
Multiple Layouts:
// Different layouts for different areas
@layout('Admin_Layout')
class Admin_Users_Action extends Spa_Action { }
@layout('Frontend_Layout')
class Frontend_Contacts_Action extends Spa_Action { }
SEE ALSO
controller(3) - Controller patterns and Ajax endpoints
jqhtml(3) - Component lifecycle and templates
routing(3) - URL generation and route patterns
modals(3) - Modal dialogs in SPA context
ajax_error_handling(3) - Error handling patterns