diff --git a/app/RSpade/Core/Js/Debugger.js b/app/RSpade/Core/Js/Debugger.js index 99e331184..bdc5c1a2d 100755 --- a/app/RSpade/Core/Js/Debugger.js +++ b/app/RSpade/Core/Js/Debugger.js @@ -29,25 +29,9 @@ class Debugger { static _on_framework_core_init() { // Check if browser error logging is enabled if (window.rsxapp && window.rsxapp.log_browser_errors) { - // Register global error handler - window.addEventListener('error', function (event) { - Debugger._handle_browser_error({ - message: event.message, - filename: event.filename, - lineno: event.lineno, - colno: event.colno, - stack: event.error ? event.error.stack : null, - type: 'error', - }); - }); - - // Register unhandled promise rejection handler - window.addEventListener('unhandledrejection', function (event) { - Debugger._handle_browser_error({ - message: event.reason ? event.reason.message || String(event.reason) : 'Unhandled promise rejection', - stack: event.reason && event.reason.stack ? event.reason.stack : null, - type: 'unhandledrejection', - }); + // Listen for unhandled exceptions from Rsx event system + Rsx.on('unhandled_exception', function (error_data) { + Debugger._handle_browser_error(error_data); }); } diff --git a/app/RSpade/Core/Js/Rsx.js b/app/RSpade/Core/Js/Rsx.js index 7f52f87a5..e4cca5936 100755 --- a/app/RSpade/Core/Js/Rsx.js +++ b/app/RSpade/Core/Js/Rsx.js @@ -50,6 +50,9 @@ class Rsx { // Gets set to true to interupt startup sequence static __stopped = false; + // Timestamp of last flash alert for unhandled exceptions (rate limiting) + static _last_exception_flash = 0; + // Initialize event handlers storage static _init_events() { if (typeof Rsx._event_handlers === 'undefined') { @@ -107,6 +110,75 @@ class Rsx { this.trigger('refresh'); } + /** + * Setup global unhandled exception handlers + * Must be called before framework initialization begins + */ + static _setup_exception_handlers() { + // Handle uncaught JavaScript errors + window.addEventListener('error', function (event) { + Rsx._handle_unhandled_exception({ + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + type: 'error', + error: event.error, + }); + }); + + // Handle unhandled promise rejections + window.addEventListener('unhandledrejection', function (event) { + Rsx._handle_unhandled_exception({ + message: event.reason ? event.reason.message || String(event.reason) : 'Unhandled promise rejection', + stack: event.reason && event.reason.stack ? event.reason.stack : null, + type: 'unhandledrejection', + error: event.reason, + }); + }); + } + + /** + * Internal handler for unhandled exceptions + * Triggers event, shows flash alert (rate limited), disables SPA, and logs to console + */ + static _handle_unhandled_exception(error_data) { + // Always log to console + console.error('[Rsx] Unhandled exception:', error_data); + + // Trigger event for listeners (e.g., Debugger for server logging) + Rsx.trigger('unhandled_exception', error_data); + + // Disable SPA navigation if in SPA mode + // This allows user to navigate away from broken page using normal browser navigation + if (typeof Spa !== 'undefined' && window.rsxapp && window.rsxapp.is_spa) { + Spa.disable(); + } + + // Show flash alert (rate limited to 1 per second) + const now = Date.now(); + if (now - Rsx._last_exception_flash >= 1000) { + Rsx._last_exception_flash = now; + + // Determine message based on dev/prod mode + let message; + if (window.rsxapp && window.rsxapp.debug) { + // Dev mode: Show actual error (shortened to 300 chars) + const error_text = error_data.message || 'Unknown error'; + message = error_text.length > 300 ? error_text.substring(0, 300) + '...' : error_text; + } else { + // Production mode: Generic message + message = 'An unhandled error has occurred, you may need to refresh your page'; + } + + // Show flash alert if Flash_Alert is available + if (typeof Flash_Alert !== 'undefined') { + Flash_Alert.error(message); + } + } + } + // Log to server that an event happened static log(type, message = 'notice') { Core_Log.log(type, message); @@ -502,6 +574,9 @@ class Rsx { Rsx.__booted = true; + // Setup exception handlers first, before any initialization phases + Rsx._setup_exception_handlers(); + // Get all registered classes from the manifest const all_classes = Manifest.get_all_classes(); diff --git a/app/RSpade/Core/SPA/Spa.js b/app/RSpade/Core/SPA/Spa.js index c65a5d89d..9ddfc605e 100755 --- a/app/RSpade/Core/SPA/Spa.js +++ b/app/RSpade/Core/SPA/Spa.js @@ -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() diff --git a/app/RSpade/Core/SPA/Spa_Layout.js b/app/RSpade/Core/SPA/Spa_Layout.js index cdbb71773..10b5760ea 100755 --- a/app/RSpade/Core/SPA/Spa_Layout.js +++ b/app/RSpade/Core/SPA/Spa_Layout.js @@ -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(` +
An error occurred while loading this page. You can navigate back or to another page.
++ Go Back +
+