Files
rspade_system/app/RSpade/Core/SPA/Spa.js
root bded711d1c Convert Spa.action property to Spa.action() method
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-29 05:49:17 +00:00

1104 lines
45 KiB
JavaScript
Executable File

/**
* 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 (use Spa.action() to access)
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;
// 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;
// Timer ID for 30-minute auto-disable
static _spa_timeout_timer = null;
// Flag to track if initial load is complete (for session validation)
static _initial_load_complete = false;
// Timestamp of last navigation start - used for error suppression grace period
static _navigation_timestamp = 0;
// Grace period in milliseconds for suppressing errors after navigation
static NAVIGATION_GRACE_PERIOD_MS = 10000;
/**
* Get the current action component instance
*
* Returns the cached action if available, otherwise finds it from the DOM.
* This method should be used instead of direct property access to ensure
* the action is always found even if the cache was cleared.
*
* @returns {Spa_Action|null} The current action instance, or null if none
*/
static action() {
if (Spa._action) {
return Spa._action;
}
const $spa_action = $('.Spa_Action').first();
if (!$spa_action.exists()) {
return null;
}
Spa._action = $spa_action.component();
return Spa._action;
}
/**
* Check if we're within the navigation grace period
*
* During the 10 seconds after navigation starts, pending AJAX requests from the
* previous page may error out (because their context is gone). These errors should
* not be shown to the user as they're not relevant to the new page.
*
* @returns {boolean} True if within grace period, false otherwise
*/
static is_within_navigation_grace_period() {
if (Spa._navigation_timestamp === 0) {
return false;
}
return (Date.now() - Spa._navigation_timestamp) < Spa.NAVIGATION_GRACE_PERIOD_MS;
}
/**
* 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;
}
/**
* Start 30-minute timeout to auto-disable SPA
* Prevents users from working with stale code for more than 30 minutes
*/
static _start_spa_timeout() {
// 30-minute timeout to auto-disable SPA navigation
//
// WHY: When the application is deployed with updated code, users who have the
// SPA already loaded in their browser will continue using the old JavaScript
// bundle indefinitely. This can cause:
// - API mismatches (stale client code calling updated server endpoints)
// - Missing features or UI changes
// - Bugs from stale client-side logic
//
// FUTURE: A future version of RSpade will use WebSockets to trigger all clients
// to automatically reload their pages on deploy. However, this timeout serves as
// a secondary line of defense against:
// - Failures in the WebSocket notification system
// - Memory leaks in long-running SPA sessions
// - Other unforeseen issues that may arise
// This ensures that users will eventually and periodically get a fresh state,
// regardless of any other system failures.
//
// SOLUTION: After 30 minutes, automatically disable SPA navigation. The next
// forward navigation (link click, manual dispatch) will do a full page reload,
// fetching the new bundle. Back/forward buttons continue to work via SPA
// (force: true) to preserve form state and scroll position.
//
// 30 MINUTES: Chosen as a balance between:
// - Short enough that users don't work with stale code for too long
// - Long enough that users aren't interrupted during active work sessions
//
// TODO: Make this timeout value configurable by developers via:
// - window.rsxapp.spa_timeout_minutes (set in PHP)
// - Default to 30 if not specified
// - Allow 0 to disable timeout entirely (for dev/testing)
const timeout_ms = 30 * 60 * 1000;
Spa._spa_timeout_timer = setTimeout(() => {
console.warn('[Spa] 30-minute timeout reached - disabling SPA navigation');
Spa.disable();
}, timeout_ms);
console_debug('Spa', '30-minute auto-disable timer started');
}
/**
* 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');
// Start 30-minute auto-disable timer
Spa._start_spa_timeout();
// 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, layouts } or null
*
* layouts is an array of layout class names for sublayout chain support.
* First element = outermost layout, last = innermost (closest to action).
*/
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]) {
const action_class = Spa.routes[exact_pattern];
return {
action_class: action_class,
args: parsed.query_params,
layouts: action_class._spa_layouts || ['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 };
const action_class = Spa.routes[pattern];
return {
action_class: action_class,
args: args,
layouts: action_class._spa_layouts || ['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 <a> 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)
// Force SPA dispatch even if disabled - popstate navigates to cached history state
Spa.dispatch(url, {
history: 'none',
scroll: scroll,
force: true
});
});
// Intercept link clicks using event delegation
document.addEventListener('click', (e) => {
// Find <a> 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)) {
// 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();
// Check for loader title hint - provides immediate title feedback while page loads
const loader_title_hint = link.getAttribute('data-loader-title-hint');
const dispatch_options = { history: 'auto' };
if (loader_title_hint) {
// Set document title immediately for instant feedback
document.title = loader_title_hint;
dispatch_options.loader_title_hint = loader_title_hint;
console_debug('Spa', 'Loader title hint: ' + loader_title_hint);
}
Spa.dispatch(href, dispatch_options);
} 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)
* @param {boolean} options.force - Force SPA dispatch even if disabled (used by popstate) (default: false)
*/
static async dispatch(url, options = {}) {
// Check if SPA is disabled - do full page load
// Exception: popstate events always attempt SPA dispatch (force: true)
if (!Spa._spa_enabled && !options.force) {
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()
console_debug('Spa', 'Nested dispatch detected, queuing pending redirect: ' + url);
Spa.pending_redirect = { url, options };
return;
}
Spa.is_dispatching = true;
// Clear cached action - will be set when new action is created
Spa._action = null;
// Record navigation timestamp for error suppression grace period
// Errors from previous page's pending requests should be ignored for 10 seconds
Spa._navigation_timestamp = Date.now();
try {
const opts = {
history: options.history || 'auto',
scroll: 'scroll' in options ? options.scroll : undefined,
triggers: options.triggers !== false,
loader_title_hint: options.loader_title_hint || null,
};
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,
layouts: route_match?.layouts,
});
// 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 chain and action info
const target_layouts = route_match.layouts;
const action_class = route_match.action_class;
const action_name = action_class.name;
// Merge loader title hint into action args if provided
// This allows the action to show a placeholder title while loading
let action_args = route_match.args;
if (opts.loader_title_hint) {
action_args = { ...route_match.args, _loader_title_hint: opts.loader_title_hint };
}
// Log successful SPA navigation
console.warn('[Spa.dispatch] Executing SPA navigation', {
url: url,
path: parsed.path,
params: action_args,
action: action_name,
layouts: target_layouts,
history_mode: opts.history
});
// Resolve layout chain - find divergence point and reuse matching layouts
await Spa._resolve_layout_chain(target_layouts, action_name, action_args, url);
// 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
// Use behavior: 'instant' to override Bootstrap's scroll-behavior: smooth on :root
if (opts.scroll) {
console_debug('Spa', 'Restoring scroll position (immediate): ' + opts.scroll.x + ', ' + opts.scroll.y);
window.scrollTo({ left: opts.scroll.x, top: opts.scroll.y, behavior: 'instant' });
} else if (opts.scroll === undefined) {
// Default: scroll to top for new navigation (only if scroll not explicitly set)
// BUT skip on page reload - let Rsx._restore_scroll_on_refresh handle it
const nav_entries = performance.getEntriesByType('navigation');
const is_reload = nav_entries.length > 0 && nav_entries[0].type === 'reload';
if (is_reload) {
console_debug('Spa', 'Skipping scroll-to-top on page reload (Rsx handles refresh scroll restore)');
} else {
console_debug('Spa', 'Scrolling to top (new navigation)');
window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
}
}
// 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} with ${target_layouts.length} layout(s)`);
// Session validation after navigation
// Validates that client state (window.rsxapp) matches server state.
// Detects:
// 1. Codebase updates (build_key changed) - new deployment
// 2. User changes (name, role, permissions updated)
// 3. Session changes (logged out, ACLs modified)
//
// Only runs on subsequent navigations, not:
// - Initial page load (window.rsxapp is fresh from server)
// - Back/forward navigation (force: true) - restoring cached state
//
// On mismatch, triggers location.replace() for transparent refresh
// that doesn't pollute browser history.
if (Spa._initial_load_complete && !options.force) {
// Fire and forget - don't block navigation on validation
Rsx.validate_session();
}
// Mark initial load complete for subsequent navigations
if (!Spa._initial_load_complete) {
Spa._initial_load_complete = true;
}
} catch (error) {
console.error('[Spa] Dispatch error:', error);
// TODO: Better error handling - show error UI to user
throw error;
} finally {
Spa.is_dispatching = false;
// Check if a redirect was queued during this dispatch
if (Spa.pending_redirect) {
const redirect = Spa.pending_redirect;
Spa.pending_redirect = null;
console_debug('Spa', 'Processing pending redirect: ' + redirect.url);
// Execute the queued redirect immediately
// This will destroy the action that triggered the redirect
await Spa.dispatch(redirect.url, redirect.options);
}
}
}
/**
* Resolve layout chain - find divergence point and reuse matching layouts
*
* Walks down from the top-level layout comparing current DOM chain to target chain.
* Reuses layouts that match, destroys from the first mismatch point down,
* then creates new layouts/action from that point.
*
* @param {string[]} target_layouts - Array of layout class names (outermost first)
* @param {string} action_name - The action class name to render at the bottom
* @param {object} args - URL parameters for the action
* @param {string} url - The full URL being navigated to
*/
static async _resolve_layout_chain(target_layouts, action_name, args, url) {
// Build target chain: layouts + action at the end
const target_chain = [...target_layouts, action_name];
console_debug('Spa', 'Resolving layout chain:', target_chain);
// Find the divergence point by walking the current DOM
let $current_container = $('#spa-root');
if (!$current_container.length) {
throw new Error('[Spa] #spa-root element not found - check Spa_App.blade.php');
}
let divergence_index = 0;
let reusable_layouts = [];
// Walk down current chain, checking each level against target
for (let i = 0; i < target_chain.length; i++) {
const target_name = target_chain[i];
const is_last = (i === target_chain.length - 1);
// Check if current container has a component with target class name
// jqhtml adds class names to component root elements automatically
//
// Special case for first iteration (i=0):
// The top-level layout is rendered ON #spa-root itself, not as a child.
// $.component() converts the container element into the component.
// So we check if the container itself has the target class.
//
// For subsequent iterations:
// Sublayouts and actions are rendered into the parent's $content area.
// The $content element becomes the component (same pattern).
// So we still check if the container itself has the target class.
const $existing = $current_container;
if ($existing.hasClass(target_name)) {
// Match found - can potentially reuse this level
const existing_component = $existing.component();
if (is_last) {
// This is the action level - actions are never reused, always replaced
divergence_index = i;
break;
}
// This is a layout level - reuse it
reusable_layouts.push(existing_component);
divergence_index = i + 1;
// Move to next level - look in this layout's $content
const $content = existing_component.$sid ? existing_component.$sid('content') : null;
if (!$content || !$content.length) {
// Layout doesn't have content area - can't go deeper
break;
}
$current_container = $content;
} else {
// No match - divergence point found
divergence_index = i;
break;
}
}
console_debug('Spa', `Divergence at index ${divergence_index}, reusing ${reusable_layouts.length} layouts`);
// Destroy everything from divergence point down
if (divergence_index === 0) {
// Complete replacement - destroy top-level layout
if (Spa.layout) {
await Spa.layout.trigger('unload');
Spa.layout.stop();
Spa.layout = null;
}
$current_container = $('#spa-root');
$current_container.empty();
Spa._clear_container_attributes($current_container);
} else {
// Partial replacement - clear from the reusable layout's $content
const last_reusable = reusable_layouts[reusable_layouts.length - 1];
$current_container = last_reusable.$sid('content');
$current_container.empty();
Spa._clear_container_attributes($current_container);
}
// Create new layouts/action from divergence point
for (let i = divergence_index; i < target_chain.length; i++) {
const component_name = target_chain[i];
const is_last = (i === target_chain.length - 1);
console_debug('Spa', `Creating ${is_last ? 'action' : 'layout'}: ${component_name}`);
// Create component
const component = $current_container.component(component_name, is_last ? args : {}).component();
if (i === 0) {
// Top-level layout - set reference immediately
Spa.layout = component;
}
if (is_last) {
// This is the action - set reference but don't wait
Spa._action = component;
} else {
// This is a layout
// Wait for render to complete (not full ready - we don't need child data to load)
// This allows layout navigation to update immediately while action loads
await component.rendered();
// Move container to this layout's $content for next iteration
$current_container = component.$sid('content');
if (!$current_container || !$current_container.length) {
throw new Error(`[Spa] Layout ${component_name} must have an element with $sid="content"`);
}
}
}
// Propagate on_action to all layouts in the chain
// All layouts receive the same action info (final action's url, name, args)
const layouts_for_on_action = Spa._collect_all_layouts();
for (const layout of layouts_for_on_action) {
// Set action reference before calling on_action so layouts can access it
layout.action = Spa.action();
if (layout.on_action) {
layout.on_action(url, action_name, args);
}
layout.trigger('action');
}
console_debug('Spa', `Rendered ${action_name} with ${target_layouts.length} layout(s)`);
}
/**
* Collect all layouts from Spa.layout down through nested $content elements
* @returns {Array} Array of layout instances from top to bottom
*/
static _collect_all_layouts() {
const layouts = [];
let current = Spa.layout;
while (current && current instanceof Spa_Layout) {
layouts.push(current);
// Look for nested layout in $content
// Note: Sublayouts are placed directly ON the $content element (container becomes component)
const $content = current.$sid ? current.$sid('content') : null;
if (!$content || !$content.length) break;
const child_component = $content.component();
if (child_component && child_component !== current && child_component instanceof Spa_Layout) {
current = child_component;
} else {
break;
}
}
return layouts;
}
/**
* Clear all attributes except id from a container element
* Called before loading new content to ensure clean state
* @param {jQuery} $container - The container element to clear
*/
static _clear_container_attributes($container) {
if (!$container || !$container.length) return;
const el = $container[0];
const attrs_to_remove = [];
for (const attr of el.attributes) {
if (attr.name !== 'id' && attr.name !== 'data-id') {
attrs_to_remove.push(attr.name);
}
}
for (const attr_name of attrs_to_remove) {
el.removeAttribute(attr_name);
}
}
/**
* 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`);
}
/**
* Load an action in detached mode without affecting the live SPA state
*
* This method resolves a URL to an action, instantiates it on a detached DOM element
* (not in the actual document), runs its full lifecycle including on_load(), and
* returns the fully-initialized component instance.
*
* Use cases:
* - Getting action metadata (title, breadcrumbs) for navigation UI
* - Pre-fetching action data before navigation
* - Inspecting action state without displaying it
*
* IMPORTANT: The caller is responsible for calling action.stop() when done
* to prevent memory leaks. The detached action holds references and may have
* event listeners that need cleanup.
*
* @param {string} url - The URL to resolve and load
* @param {object} extra_args - Optional extra parameters to pass to the action component.
* These are merged with URL-extracted args (extra_args take precedence).
* Pass {use_cached_data: true} to have the action load with cached data
* without revalidation if cached data is available.
* @returns {Promise<Spa_Action|null>} The fully-loaded action instance, or null if route not found
*
* @example
* // 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(); // Clean up when done
* }
*
* @example
* // With cached data (faster, no network request if cached)
* const action = await Spa.load_detached_action('/contacts/123', {use_cached_data: true});
*/
static async load_detached_action(url, extra_args = {}) {
// Parse URL and match to route
const parsed = Spa.parse_url(url);
const url_without_hash = parsed.path + parsed.search;
const route_match = Spa.match_url_to_route(url_without_hash);
if (!route_match) {
console_debug('Spa', 'load_detached_action: No route match for ' + url);
return null;
}
const action_class = route_match.action_class;
const action_name = action_class.name;
// Merge URL args with extra_args (extra_args take precedence)
// Include skip_render_and_ready to prevent side effects from on_ready()
// (e.g., Spa.dispatch() which would cause redirect loops)
const args = {
...route_match.args,
...extra_args,
skip_render_and_ready: true
};
console_debug('Spa', `load_detached_action: Loading ${action_name} with args:`, args);
// Create a detached container (not in DOM)
const $detached = $('<div>');
// Instantiate the action on the detached element
$detached.component(action_name, args);
const action = $detached.component();
// Wait for on_load to complete (data fetching)
await action.ready();
console_debug('Spa', `load_detached_action: ${action_name} ready`);
return action;
}
}