🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
945 lines
38 KiB
JavaScript
Executable File
945 lines
38 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
|
|
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;
|
|
|
|
/**
|
|
* 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();
|
|
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)
|
|
* @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;
|
|
|
|
try {
|
|
const opts = {
|
|
history: options.history || 'auto',
|
|
scroll: 'scroll' in options ? options.scroll : undefined,
|
|
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,
|
|
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;
|
|
|
|
// Log successful SPA navigation
|
|
console.warn('[Spa.dispatch] Executing SPA navigation', {
|
|
url: url,
|
|
path: parsed.path,
|
|
params: route_match.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, route_match.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
|
|
const $existing = $current_container.children().first();
|
|
|
|
if ($existing.length && $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();
|
|
|
|
// Wait for it to be ready
|
|
await component.ready();
|
|
|
|
if (is_last) {
|
|
// This is the action
|
|
Spa.action = component;
|
|
} else {
|
|
// This is a layout
|
|
if (i === 0) {
|
|
// Top-level layout
|
|
Spa.layout = component;
|
|
}
|
|
|
|
// 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`);
|
|
}
|
|
}
|