/** * Spa - Single Page Application orchestrator for RSpade * * This class manages the Spa lifecycle: * - Auto-discovers action classes extending Spa_Action * - Extracts route information from decorator metadata * - Registers routes with the router * - Dispatches to the current URL * * Initialization happens automatically during the on_app_init phase when * window.rsxapp.is_spa === true. * * Unlike JQHTML, this is a static class (not a Component) following the RS3 pattern. */ class Spa { // Registered routes: { pattern: action_class } static routes = {}; // Current layout instance static layout = null; // Current action instance static action = null; // Current route instance static route = null; // Current route 'params' static params = null; // Flag to prevent re-entrant dispatch static is_dispatching = false; /** * Framework module initialization hook called during framework boot * Only runs when window.rsxapp.is_spa === true */ static _on_framework_modules_init() { // Only initialize Spa if we're in a Spa route if (!window.rsxapp || !window.rsxapp.is_spa) { return; } console_debug('Spa', 'Initializing Spa system'); // Discover and register all action classes Spa.discover_actions(); // Setup browser integration using History API // Note: Navigation API evaluated but not mature enough for production use // See: /docs.dev/SPA_BROWSER_INTEGRATION.md for details console.log('[Spa] Using History API for browser integration'); Spa.setup_browser_integration(); // Dispatch to current URL (including hash for initial load) const initial_url = window.location.pathname + window.location.search + window.location.hash; console_debug('Spa', 'Dispatching to initial URL: ' + initial_url); Spa.dispatch(initial_url, { history: 'none' }); } /** * Discover all classes extending Spa_Action and register their routes */ static discover_actions() { const all_classes = Manifest.get_all_classes(); let action_count = 0; for (const class_info of all_classes) { const class_object = class_info.class_object; const class_name = class_info.class_name; // Check if this class extends Spa_Action if (class_object.prototype instanceof Spa_Action || class_object === Spa_Action) { // Skip the base class itself if (class_object === Spa_Action) { continue; } // Extract route patterns from decorator metadata const routes = class_object._spa_routes || []; if (routes.length === 0) { console.warn(`Spa: Action ${class_name} has no routes defined`); continue; } // Register each route pattern for (const pattern of routes) { Spa.register_route(pattern, class_object); console_debug('Spa', `Registered route: ${pattern} → ${class_name}`); } action_count++; } } console_debug('Spa', `Discovered ${action_count} action classes`); } /** * Register a route pattern to an action class */ static register_route(pattern, action_class) { // Normalize pattern - remove trailing /index if (pattern.endsWith('/index')) { pattern = pattern.slice(0, -6) || '/'; } // Check for duplicates in dev mode if (Rsx.is_dev() && Spa.routes[pattern]) { console.error(`Spa: Duplicate route '${pattern}' - ${action_class.name} conflicts with ${Spa.routes[pattern].name}`); } Spa.routes[pattern] = action_class; } /** * Match URL to a route and extract parameters * Returns: { action_class, args, layout } or null */ static match_url_to_route(url) { // Parse URL to get path and query params const parsed = Spa.parse_url(url); let path = parsed.path; // Normalize path - remove leading/trailing slashes for matching path = path.substring(1); // Remove leading / // Remove /index suffix if (path === 'index' || path.endsWith('/index')) { path = path.slice(0, -5) || ''; } // Try exact match first const exact_pattern = '/' + path; if (Spa.routes[exact_pattern]) { return { action_class: Spa.routes[exact_pattern], args: parsed.query_params, layout: Spa.routes[exact_pattern]._spa_layout || 'Default_Layout', }; } // Try pattern matching with :param segments for (const pattern in Spa.routes) { const match = Spa.match_pattern(path, pattern); if (match) { // Merge parameters with correct priority order: // 1. GET parameters (from query string, lowest priority) // 2. URL route parameters (extracted from route pattern like :id, highest priority) // This matches the PHP Dispatcher behavior where route params override GET params const args = { ...parsed.query_params, ...match }; return { action_class: Spa.routes[pattern], args: args, layout: Spa.routes[pattern]._spa_layout || 'Default_Layout', }; } } // No match found return null; } /** * Match a path against a pattern with :param segments * Returns object with extracted params or null if no match */ static match_pattern(path, pattern) { // Remove leading / from both path = path.replace(/^\//, ''); pattern = pattern.replace(/^\//, ''); // Split into segments const path_segments = path.split('/'); const pattern_segments = pattern.split('/'); // Must have same number of segments if (path_segments.length !== pattern_segments.length) { return null; } const params = {}; for (let i = 0; i < pattern_segments.length; i++) { const pattern_seg = pattern_segments[i]; const path_seg = path_segments[i]; if (pattern_seg.startsWith(':')) { // This is a parameter - extract it const param_name = pattern_seg.substring(1); params[param_name] = decodeURIComponent(path_seg); } else { // This is a literal - must match exactly if (pattern_seg !== path_seg) { return null; } } } return params; } /** * Parse URL into components */ static parse_url(url) { let parsed_url; try { if (url.startsWith('http://') || url.startsWith('https://')) { parsed_url = new URL(url); } else { parsed_url = new URL(url, window.location.href); } } catch (e) { parsed_url = new URL(window.location.href); } const path = parsed_url.pathname; const search = parsed_url.search; // Parse query string const query_params = {}; if (search && search !== '?') { const query = search.startsWith('?') ? search.substring(1) : search; const pairs = query.split('&'); for (const pair of pairs) { const [key, value] = pair.split('='); if (key) { query_params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; } } } return { path, search, query_params }; } /** * Generate URL from pattern and parameters */ static generate_url_from_pattern(pattern, params = {}) { let url = pattern; const used_params = new Set(); // Replace :param placeholders url = url.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, param_name) => { if (params.hasOwnProperty(param_name)) { used_params.add(param_name); return encodeURIComponent(params[param_name]); } return ''; }); // Collect unused parameters for query string const query_params = {}; for (const key in params) { if (!used_params.has(key)) { query_params[key] = params[key]; } } // Add query string if needed if (Object.keys(query_params).length > 0) { const query_string = Object.entries(query_params) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); url += '?' + query_string; } return url; } /** * Setup browser integration for back/forward and link interception * * This implements Phase 1 of browser integration using the History API. * See: /docs.dev/SPA_BROWSER_INTEGRATION.md for complete documentation. * * Key Behaviors: * - Intercepts clicks on tags for same-domain SPA routes * - Preserves standard browser behaviors (Ctrl+click, target="_blank", etc.) * - Handles back/forward navigation with scroll restoration * - Hash-only changes don't create history entries * - Defers to server for edge cases (external links, non-SPA routes, etc.) */ static setup_browser_integration() { console_debug('Spa', 'Setting up browser integration (History API mode)'); // Handle browser back/forward buttons window.addEventListener('popstate', (e) => { console_debug('Spa', 'popstate event fired (back/forward navigation)'); console.warn('[Spa.dispatch] Handling history popstate event', { url: window.location.pathname + window.location.search + window.location.hash, state: e.state }); // Get target URL (browser has already updated location) const url = window.location.pathname + window.location.search + window.location.hash; // Retrieve scroll position from history state const scroll = e.state?.scroll || null; // TODO: Form Data Restoration // Retrieve form data from history state and restore after action renders // Implementation notes: // - Get form_data from e.state?.form_data // - After action on_ready(), find all form inputs // - Restore values from form_data object // - Trigger change events for restored fields // - Handle edge cases: // * Dynamic forms (loaded via Ajax) - need to wait for form to exist // * File inputs (cannot be programmatically set for security) // * Custom components (need vals() method for restoration) // * Timing (must restore after form renders, possibly in on_ready) // const form_data = e.state?.form_data || {}; // Dispatch without modifying history (we're already at the target URL) Spa.dispatch(url, { history: 'none', scroll: scroll }); }); // Intercept link clicks using event delegation document.addEventListener('click', (e) => { // Find tag in event path (handles clicks on child elements) let link = e.target.closest('a'); if (!link) { return; } const href = link.getAttribute('href'); // Ignore if: // - No href // - Ctrl/Cmd/Meta is pressed (open in new tab) // - Has target attribute // - Not left click (button 0) // - Is empty or hash-only (#) if (!href || e.ctrlKey || e.metaKey || link.getAttribute('target') || e.button !== 0 || href === '' || href === '#') { return; } // Parse URLs for comparison const current_parsed = Spa.parse_url(window.location.href); const target_parsed = Spa.parse_url(href); // If same page (same path + search), let browser handle (causes reload) // This mimics non-SPA behavior where clicking current page refreshes if (current_parsed.path === target_parsed.path && current_parsed.search === target_parsed.search) { console_debug('Spa', 'Same page click, letting browser reload'); return; } // Only intercept same-domain links if (current_parsed.host !== target_parsed.host) { console_debug('Spa', 'External domain link, letting browser handle: ' + href); return; } // Check if target URL matches a Spa route if (Spa.match_url_to_route(href)) { console_debug('Spa', 'Intercepting link click: ' + href); e.preventDefault(); Spa.dispatch(href, { history: 'auto' }); } else { console_debug('Spa', 'No SPA route match, letting server handle: ' + href); } }); } /** * Main dispatch method - navigate to a URL * * This is the single entry point for all navigation within the SPA. * Handles route matching, history management, layout/action lifecycle, and scroll restoration. * * @param {string} url - Target URL (relative or absolute) * @param {object} options - Navigation options * @param {string} options.history - 'auto'|'push'|'replace'|'none' (default: 'auto') * - 'auto': Push for new URLs, replace for same URL * - 'push': Always create new history entry * - 'replace': Replace current history entry * - 'none': Don't modify history (used for back/forward) * @param {object|null} options.scroll - Scroll position {x, y} to restore (default: null = scroll to top) * @param {boolean} options.triggers - Fire before/after dispatch events (default: true) */ static async dispatch(url, options = {}) { if (Spa.is_dispatching) { console.warn('Spa: Already dispatching, ignoring nested dispatch'); return; } Spa.is_dispatching = true; try { const opts = { history: options.history || 'auto', scroll: options.scroll || null, triggers: options.triggers !== false, }; console_debug('Spa', 'Dispatching to: ' + url + ' (history: ' + opts.history + ')'); // Handle fully qualified URLs const current_domain = window.location.hostname; if (url.startsWith('http://') || url.startsWith('https://')) { try { const parsed_url = new URL(url); // Check if different domain if (parsed_url.hostname !== current_domain) { // External domain - navigate away console_debug('Spa', 'External domain, navigating: ' + url); console.warn('[Spa.dispatch] Executing document.location.href (external domain)', { url: url, reason: 'External domain' }); document.location.href = url; Spa.is_dispatching = false; return; } // Same domain - strip to relative URL url = parsed_url.pathname + parsed_url.search + parsed_url.hash; console_debug('Spa', 'Same domain, stripped to relative: ' + url); } catch (e) { console.error('Spa: Invalid URL format:', url); Spa.is_dispatching = false; return; } } // Parse the (now relative) URL const parsed = Spa.parse_url(url); // CRITICAL: Strip hash from URL before route matching // Hash represents page state (e.g., DataGrid page number), not routing state // Hash is preserved in browser URL bar but not used for route matching const url_without_hash = parsed.path + parsed.search; console_debug('Spa', 'URL for route matching (hash stripped): ' + url_without_hash); // Try to match URL to a route (without hash) const route_match = Spa.match_url_to_route(url_without_hash); // Check if this is the same URL we're currently on (without hash) const current_url = window.location.pathname + window.location.search; const is_same_url = url_without_hash === current_url; // Same URL navigation with history: 'auto' should reload via server // This mimics non-SPA behavior where clicking current page refreshes if (is_same_url && opts.history === 'auto') { console_debug('Spa', 'Same URL with auto history, letting browser reload'); console.warn('[Spa.dispatch] Executing document.location.href (same URL reload)', { url: url, reason: 'Same URL with auto history - mimics browser reload behavior' }); document.location.href = url; Spa.is_dispatching = false; return; } if (is_same_url && !route_match) { // We're being asked to navigate to the current URL, but it doesn't match // any known route. This shouldn't happen - prevents infinite redirect loop. Spa.spa_unknown_route_fatal(parsed.path); Spa.is_dispatching = false; return; } // If no route match and we're not on this URL, let server handle it if (!route_match) { console_debug('Spa', 'No route matched, letting server handle: ' + url); console.warn('[Spa.dispatch] Executing document.location.href (no route match)', { url: url, reason: 'URL does not match any registered SPA routes' }); document.location.href = url; Spa.is_dispatching = false; return; } console_debug('Spa', 'Route match:', { action_class: route_match?.action_class?.name, args: route_match?.args, layout: route_match?.layout, }); // Check if action's @spa() attribute matches current SPA bootstrap const action_spa_controller = route_match.action_class._spa_controller_method; const current_spa_controller = window.rsxapp.current_controller + '::' + window.rsxapp.current_action; if (action_spa_controller && action_spa_controller !== current_spa_controller) { // Different SPA module - let server bootstrap it console_debug('Spa', 'Different SPA module, letting server handle: ' + url); console_debug('Spa', ` Action uses: ${action_spa_controller}`); console_debug('Spa', ` Current SPA: ${current_spa_controller}`); console.warn('[Spa.dispatch] Executing document.location.href (different SPA module)', { url: url, reason: 'Action belongs to different SPA module/bundle', action_spa_controller: action_spa_controller, current_spa_controller: current_spa_controller }); document.location.href = url; Spa.is_dispatching = false; return; } // Update browser history with scroll position storage if (opts.history !== 'none') { // Store current scroll position before navigation const current_scroll = { x: window.scrollX || window.pageXOffset, y: window.scrollY || window.pageYOffset }; // Build history state object const state = { scroll: current_scroll, form_data: {} // Reserved for future form state restoration }; // Construct full URL with hash (hash is preserved in browser URL bar) const new_url = parsed.path + parsed.search + (parsed.hash || ''); if (opts.history === 'push' || (opts.history === 'auto' && !is_same_url)) { console_debug('Spa', 'Pushing history state'); history.pushState(state, '', new_url); } else if (opts.history === 'replace' || (opts.history === 'auto' && is_same_url)) { console_debug('Spa', 'Replacing history state'); history.replaceState(state, '', new_url); } } // Set global Spa state Spa.route = route_match; Spa.path = parsed.path; Spa.params = route_match.args; // Get layout name and action info const layout_name = route_match.layout; const action_class = route_match.action_class; const action_name = action_class.name; // Log successful SPA navigation console.warn('[Spa.dispatch] Executing SPA navigation', { url: url, path: parsed.path, params: route_match.args, action: action_name, layout: layout_name, history_mode: opts.history }); // Check if we need a new layout if (!Spa.layout || Spa.layout.constructor.name !== layout_name) { // Stop old layout if exists (auto-stops children) if (Spa.layout) { await Spa.layout.trigger('unload'); Spa.layout.stop(); } // Clear body and create new layout $('body').empty(); $('body').attr('class', ''); // Create layout using component system Spa.layout = $('body').component(layout_name, {}).component(); // Wait for layout to be ready await Spa.layout.ready(); console_debug('Spa', `Created layout: ${layout_name}`); } else { // Wait for layout to finish previous action if still loading await Spa.layout.ready(); } // Tell layout to run the action Spa.layout._set_action(action_name, route_match.args, url); await Spa.layout._run_action(); // Scroll Restoration #1: Immediate (after action starts) // This occurs synchronously after the action component is created // May fail if page height is insufficient - that's okay, we'll retry later if (opts.scroll) { console_debug('Spa', 'Restoring scroll position (immediate): ' + opts.scroll.x + ', ' + opts.scroll.y); window.scrollTo(opts.scroll.x, opts.scroll.y); } else if (opts.scroll === undefined) { // Default: scroll to top for new navigation (only if scroll not explicitly set) console_debug('Spa', 'Scrolling to top (new navigation)'); window.scrollTo(0, 0); } // If opts.scroll === null, don't scroll (let Navigation API or browser handle it) // TODO: Scroll Restoration #2 - After action on_ready() completes // This requires an action lifecycle event system to detect when on_ready() finishes. // Implementation notes: // - Listen for action on_ready completion event // - Only retry scroll if restoration #1 failed (page wasn't tall enough) // - Check if page height has increased since first attempt // - Avoid infinite retry loops (track attempts) // - This ensures scroll restoration works even when content loads asynchronously // // Additional context: The action may load data in on_load() that increases page height, // making the target scroll position accessible. The first restoration happens before // this content renders, so we need a second attempt after the page is fully ready. console_debug('Spa', `Rendered ${action_name} in ${layout_name}`); } catch (error) { console.error('[Spa] Dispatch error:', error); // TODO: Better error handling - show error UI to user throw error; } finally { Spa.is_dispatching = false; } } /** * Fatal error when trying to navigate to unknown route on current URL * This shouldn't happen - prevents infinite redirect loops */ static spa_unknown_route_fatal(path) { console.error(`Unknown route for path ${path} - this shouldn't happen`); } }