SCSS File Pairing:
Each action/layout can have a companion SCSS file with all styles
scoped to the component class:
// rsx/app/frontend/contacts/contacts_index_action.scss
.Contacts_Index_Action {
.filters { margin-bottom: 1rem; }
.contact-list { ... }
}
// rsx/app/frontend/frontend_layout.scss
.Frontend_Layout {
.app-sidebar { width: 250px; }
.app-content { margin-left: 250px; }
}
Enforcement:
SCSS files in rsx/app/ must wrap all rules in a single class
matching the action/layout name. This is enforced by the manifest
scanner and prevents CSS conflicts between pages.
See scss man page for complete scoping rules and philosophy.
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
Loader Title Hint:
When navigating from a list to a detail page, you can provide a hint
for the page title to display while the action loads its data. This
provides immediate visual feedback instead of showing a blank or
generic title during the loading state.
Add data-loader-title-hint attribute to links:
<%= contact.name %>
When the link is clicked:
1. document.title is immediately set to the hint value
2. The hint is passed to the action as this.args._loader_title_hint
3. Action can use the hint while loading, then replace with real title
Using the Hint in Actions:
async page_title() {
// Show hint while loading, real title when data is ready
if (this.is_loading() && this.args._loader_title_hint) {
return this.args._loader_title_hint;
}
return `Contact: ${this.data.contact.name}`;
}
The _loader_title_hint parameter is automatically filtered from URLs
generated by Rsx.Route(), so it never appears in the browser address
bar or generated links.
SPA EVENTS
The SPA system fires global events that can be used to hook into the
navigation lifecycle. Register handlers using Rsx.on():
spa_dispatch_start
Fired at the beginning of navigation, before the old action is destroyed.
Use for cleanup before page transition.
Rsx.on('spa_dispatch_start', (data) => {
console.log('Navigating to:', data.url);
// Close modals, cancel pending operations, etc.
});
Data payload:
url: Target URL being navigated to
Common uses:
- Close open modals (Modal system does this automatically)
- Cancel pending Ajax requests
- Save unsaved form state
- Stop video/audio playback
spa_dispatch_ready
Fired after the new action has fully loaded (on_ready complete).
Use for post-navigation setup that needs the action to be ready.
Rsx.on('spa_dispatch_ready', (data) => {
console.log('Action ready:', data.action.constructor.name);
// Perform post-navigation actions
});
Data payload:
url: Current URL
action: The action component instance (fully loaded)
Common uses:
- Analytics page view tracking
- Focus management after navigation
- Lazy-load additional resources
Example: Custom navigation tracking
class Analytics_Tracker {
static on_app_modules_init() {
Rsx.on('spa_dispatch_start', () => {
Analytics.track_page_exit();
});
Rsx.on('spa_dispatch_ready', (data) => {
Analytics.track_page_view(data.url);
});
}
}
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 { }
DETACHED ACTION LOADING
Spa.load_detached_action(url, extra_args, options) loads an action without
affecting the live SPA state. Returns a fully-loaded component instance.
Parameters:
url - URL to resolve and load
extra_args - Optional args merged with URL-extracted params
options - Optional behavior options:
load_children: false (default) Action on_load() only (_load_only)
load_children: true Full tree: render children, all on_load()s
fire (_load_render_only). Use for preloading.
Use cases:
- Extracting action metadata (titles, breadcrumbs) for navigation UI
- Pre-fetching action data before navigation
- Preloading entire page tree to warm ORM/Ajax cache
Basic Usage:
const action = await Spa.load_detached_action('/contacts/123');
if (action) {
const title = action.get_title?.() ?? action.constructor.name;
console.log('Page title:', title);
action.stop();
}
With Cached Data:
const action = await Spa.load_detached_action('/contacts/123', {
use_cached_data: true
});
Preload With Children (warm full page cache):
// Renders children (datagrids, views, etc.) so their on_load()
// fires and populates the cache. No DOM hooks execute.
const action = await Spa.load_detached_action('/contacts/123',
{}, { load_children: true });
action.stop();
What It Does NOT Affect:
- Spa.action() (current live action remains unchanged)
- Spa.layout (current live layout remains unchanged)
- Spa.route / Spa.params (current route state unchanged)
- Browser history
- The visible DOM
Returns:
- Fully-initialized Spa_Action instance if route matches
- null if no route matches the URL
IMPORTANT: The caller MUST call action.stop() when done with the detached
action to clean up event listeners and prevent memory leaks.
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
pagedata(3) - Passing server-side data to JavaScript
rsxapp(3) - Global JavaScript runtime object
scss(3) - SCSS scoping conventions and component-first philosophy