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 $sid="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 $sid="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.$sid('search').on('input', () => this.reload());
}
}
Decorator Syntax:
@route(pattern)
URL pattern with optional :param segments
Example: '/contacts/:id', '/users/:id/posts/:post_id'
Multiple routes per action:
@route('/contacts/add')
@route('/contacts/:id/edit')
The best matching route is selected based on provided parameters.
Enables single action to handle add/edit in one class.
@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
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:
Requirements:
- Must have element with $sid="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
SUBLAYOUTS
Sublayouts allow nesting multiple persistent layouts. Each layout in the
chain persists independently based on navigation context.
Use Case:
A settings section with its own sidebar that persists across all
settings pages, while the main application header/footer (outer layout)
also persists. Navigating between settings pages preserves both layouts;
navigating to dashboard destroys the settings sublayout but keeps the
outer layout.
Defining Sublayouts:
Add multiple @layout decorators - first is outermost, subsequent are nested:
@route('/frontend/settings/profile')
@layout('Frontend_Spa_Layout') // Outermost (header/footer)
@layout('Settings_Layout') // Nested inside Frontend_Spa_Layout
@spa('Frontend_Spa_Controller::index')
class Settings_Profile_Action extends Spa_Action { }
Creating a Sublayout:
Sublayouts are Spa_Layout classes with $sid="content":
// /rsx/app/frontend/settings/Settings_Layout.js
class Settings_Layout extends Spa_Layout {
async on_action(url, action_name, args) {
// Update sidebar highlighting based on current action
this._update_active_nav(action_name);
}
}
Chain Resolution:
When navigating, Spa compares current layout chain to target:
- Matching layouts from the top are reused
- First mismatched layout and everything below is destroyed
- New layouts/action created from divergence point down
Example:
Current: Frontend_Spa_Layout > Settings_Layout > Profile_Action
Target: Frontend_Spa_Layout > Settings_Layout > Security_Action
Result: Reuse both layouts, only replace action
Current: Frontend_Spa_Layout > Settings_Layout > Profile_Action
Target: Frontend_Spa_Layout > Dashboard_Action
Result: Reuse Frontend_Spa_Layout, destroy Settings_Layout, create Dashboard_Action
on_action Propagation:
All layouts in the chain receive on_action() with the final action's info:
- url: The navigated URL
- action_name: Name of the action class (bottom of chain)
- args: URL parameters
This allows each layout to update its UI (sidebar highlighting, breadcrumbs)
based on which action is currently active.
References:
Spa.layout Always the top-level layout
Spa.action Always the bottom-level action (not a layout)
To access intermediate layouts, use DOM traversal or layout hooks.
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
SESSION VALIDATION
After each SPA navigation (except initial load and back/forward), the client
validates its state against the server by calling Rsx.validate_session().
This detects three types of staleness:
1. Codebase updates - build_key changed due to new deployment
2. User changes - account details modified (name, role, permissions)
3. Session changes - logged out, ACLs updated, session invalidated
On mismatch, triggers location.replace() for a transparent page refresh
that doesn't pollute browser history. The user sees the page reload with
fresh state, or is redirected to login if session was invalidated.
Validation is fire-and-forget (non-blocking) and fails silently on network
errors to avoid disrupting navigation. Stale state will be detected on
subsequent navigations or when the 30-minute SPA timeout triggers.
Manual validation:
// Returns true if valid, false if refresh triggered
const valid = await Rsx.validate_session();
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) { %>
<% } %>
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 $sid="content" element
- Test Spa.dispatch() directly
Layout Not Persisting:
- Verify all actions in module use same @layout()
- Check layout template has $sid="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