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 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:

Contacts

<% for (let contact of this.data.contacts) { %> <% } %>
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:

My App

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:

Contacts

<% for (let contact of this.data.contacts) { %>
<%= contact.name %>
<% } %>
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:
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')

Contacts

@foreach($contacts as $contact)
{{ $contact->name }}
@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

Contacts

<% for (let contact of this.data.contacts) { %>
<%= contact.name %>
<% } %>
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