Document application modes (development/debug/production) Add global file drop handler, order column normalization, SPA hash fix Serve CDN assets via /_vendor/ URLs instead of merging into bundles Add production minification with license preservation Improve JSON formatting for debugging and production optimization Add CDN asset caching with CSS URL inlining for production builds Add three-mode system (development, debug, production) Update Manifest CLAUDE.md to reflect helper class architecture Refactor Manifest.php into helper classes for better organization Pre-manifest-refactor checkpoint: Add app_mode documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1104 lines
45 KiB
JavaScript
Executable File
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, hash: parsed_url.hash };
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|