Add SPA enable/disable functionality and graceful error handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-20 20:51:17 +00:00
parent c60cd84e57
commit 081fc0b88e
4 changed files with 184 additions and 49 deletions

View File

@@ -34,6 +34,27 @@ class Spa {
// Pending redirect that occurred during dispatch (e.g., in action on_load)
static pending_redirect = null;
// Flag to track if SPA is enabled (can be disabled on errors or dirty forms)
static _spa_enabled = true;
/**
* Disable SPA navigation - all navigation becomes full page loads
* Call this when errors occur or forms are dirty
*/
static disable() {
console.warn('[Spa] SPA navigation disabled - browser navigation mode active');
Spa._spa_enabled = false;
}
/**
* Re-enable SPA navigation after it was disabled
* Call this after forms are saved or errors are resolved
*/
static enable() {
console.log('[Spa] SPA navigation enabled');
Spa._spa_enabled = true;
}
/**
* Framework module initialization hook called during framework boot
* Only runs when window.rsxapp.is_spa === true
@@ -303,6 +324,13 @@ class Spa {
// Get target URL (browser has already updated location)
const url = window.location.pathname + window.location.search + window.location.hash;
// If SPA is disabled, still handle back/forward as SPA navigation
// (We can't convert existing history entries to full page loads)
// Only forward navigation (link clicks) will become full page loads
if (!Spa._spa_enabled) {
console_debug('Spa', 'SPA disabled but handling popstate as SPA navigation (back/forward)');
}
// Retrieve scroll position from history state
const scroll = e.state?.scroll || null;
@@ -374,6 +402,12 @@ class Spa {
// Check if target URL matches a Spa route
if (Spa.match_url_to_route(href)) {
// Check if SPA is enabled
if (!Spa._spa_enabled) {
console_debug('Spa', 'SPA disabled, letting browser handle: ' + href);
return; // Don't preventDefault - browser navigates normally
}
console_debug('Spa', 'Intercepting link click: ' + href);
e.preventDefault();
Spa.dispatch(href, { history: 'auto' });
@@ -401,6 +435,13 @@ class Spa {
* @param {boolean} options.triggers - Fire before/after dispatch events (default: true)
*/
static async dispatch(url, options = {}) {
// Check if SPA is disabled - do full page load
if (!Spa._spa_enabled) {
console.warn('[Spa.dispatch] SPA disabled, forcing full page load');
document.location.href = url;
return;
}
if (Spa.is_dispatching) {
// Already dispatching - queue this as a pending redirect
// This commonly happens when an action redirects in on_load()

View File

@@ -77,38 +77,73 @@ class Spa_Layout extends Component {
// Clear content area
$content.empty();
// Get the action class to check for @title decorator
const action_class = Manifest.get_class_by_name(action_name);
try {
// Get the action class to check for @title decorator
const action_class = Manifest.get_class_by_name(action_name);
// Update page title if @title decorator is present (optional), clear if not
if (action_class._spa_title) {
document.title = action_class._spa_title;
} else {
document.title = '';
// Update page title if @title decorator is present (optional), clear if not
if (action_class._spa_title) {
document.title = action_class._spa_title;
} else {
document.title = '';
}
// Create new action component
console.log('[Spa_Layout] Creating action:', action_name, 'with args:', args);
console.log('[Spa_Layout] Args keys:', Object.keys(args || {}));
console.warn(args);
const action = $content.component(action_name, args).component();
// Store reference
Spa.action = action;
this.action = action;
// Call on_action hook (can be overridden by subclasses)
this.on_action(url, action_name, args);
this.trigger('action');
// Setup event forwarding from action to layout
// Action triggers 'init' -> Layout triggers 'action_init'
this._setup_action_events(action);
// Wait for action to be ready
await action.ready();
} catch (error) {
// Action lifecycle failed - log error, trigger event, disable SPA, show error UI
console.error('[Spa_Layout] Action lifecycle failed:', error);
// Trigger global exception event (goes to Debugger for server logging, Flash_Alert, etc)
if (typeof Rsx !== 'undefined') {
Rsx.trigger('unhandled_exception', {
message: error.message,
stack: error.stack,
type: 'action_lifecycle_error',
action_name: action_name,
error: error,
});
}
// Disable SPA so forward navigation becomes full page loads
// (Back/forward still work as SPA to allow user to navigate away)
if (typeof Spa !== 'undefined') {
Spa.disable();
}
// Show error UI in content area so user can navigate away
$content.html(`
<div class="alert alert-danger m-4">
<h4>Page Failed to Load</h4>
<p>An error occurred while loading this page. You can navigate back or to another page.</p>
<p class="mb-0">
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
</p>
</div>
`);
// Don't re-throw - allow navigation to continue working
}
// Create new action component
console.log('[Spa_Layout] Creating action:', action_name, 'with args:', args);
console.log('[Spa_Layout] Args keys:', Object.keys(args || {}));
console.warn(args);
const action = $content.component(action_name, args).component();
// Store reference
Spa.action = action;
this.action = action;
// Call on_action hook (can be overridden by subclasses)
this.on_action(url, action_name, args);
this.trigger('action');
// Setup event forwarding from action to layout
// Action triggers 'init' -> Layout triggers 'action_init'
this._setup_action_events(action);
// Wait for action to be ready
await action.ready();
}
/**