Implement JQHTML function cache ID system and fix bundle compilation Implement underscore prefix for system tables Fix JS syntax linter to support decorators and grant exception to Task system SPA: Update planning docs and wishlists with remaining features SPA: Document Navigation API abandonment and future enhancements Implement SPA browser integration with History API (Phase 1) Convert contacts view page to SPA action Convert clients pages to SPA actions and document conversion procedure SPA: Merge GET parameters and update documentation Implement SPA route URL generation in JavaScript and PHP Implement SPA bootstrap controller architecture Add SPA routing manual page (rsx:man spa) Add SPA routing documentation to CLAUDE.md Phase 4 Complete: Client-side SPA routing implementation Update get_routes() consumers for unified route structure Complete SPA Phase 3: PHP-side route type detection and is_spa flag Restore unified routes structure and Manifest_Query class Refactor route indexing and add SPA infrastructure Phase 3 Complete: SPA route registration in manifest Implement SPA Phase 2: Extract router code and test decorators Rename Jqhtml_Component to Component and complete SPA foundation setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
641 lines
25 KiB
JavaScript
Executable File
641 lines
25 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;
|
|
|
|
/**
|
|
* Framework module initialization hook called during framework boot
|
|
* Only runs when window.rsxapp.is_spa === true
|
|
*/
|
|
static _on_framework_modules_init() {
|
|
// Only initialize Spa if we're in a Spa route
|
|
if (!window.rsxapp || !window.rsxapp.is_spa) {
|
|
return;
|
|
}
|
|
|
|
console_debug('Spa', 'Initializing Spa system');
|
|
|
|
// Discover and register all action classes
|
|
Spa.discover_actions();
|
|
|
|
// Setup browser integration using History API
|
|
// Note: Navigation API evaluated but not mature enough for production use
|
|
// See: /docs.dev/SPA_BROWSER_INTEGRATION.md for details
|
|
console.log('[Spa] Using History API for browser integration');
|
|
Spa.setup_browser_integration();
|
|
|
|
// Dispatch to current URL (including hash for initial load)
|
|
const initial_url = window.location.pathname + window.location.search + window.location.hash;
|
|
console_debug('Spa', 'Dispatching to initial URL: ' + initial_url);
|
|
Spa.dispatch(initial_url, { history: 'none' });
|
|
}
|
|
|
|
/**
|
|
* Discover all classes extending Spa_Action and register their routes
|
|
*/
|
|
static discover_actions() {
|
|
const all_classes = Manifest.get_all_classes();
|
|
let action_count = 0;
|
|
|
|
for (const class_info of all_classes) {
|
|
const class_object = class_info.class_object;
|
|
const class_name = class_info.class_name;
|
|
|
|
// Check if this class extends Spa_Action
|
|
if (class_object.prototype instanceof Spa_Action || class_object === Spa_Action) {
|
|
// Skip the base class itself
|
|
if (class_object === Spa_Action) {
|
|
continue;
|
|
}
|
|
|
|
// Extract route patterns from decorator metadata
|
|
const routes = class_object._spa_routes || [];
|
|
|
|
if (routes.length === 0) {
|
|
console.warn(`Spa: Action ${class_name} has no routes defined`);
|
|
continue;
|
|
}
|
|
|
|
// Register each route pattern
|
|
for (const pattern of routes) {
|
|
Spa.register_route(pattern, class_object);
|
|
console_debug('Spa', `Registered route: ${pattern} → ${class_name}`);
|
|
}
|
|
|
|
action_count++;
|
|
}
|
|
}
|
|
|
|
console_debug('Spa', `Discovered ${action_count} action classes`);
|
|
}
|
|
|
|
/**
|
|
* Register a route pattern to an action class
|
|
*/
|
|
static register_route(pattern, action_class) {
|
|
// Normalize pattern - remove trailing /index
|
|
if (pattern.endsWith('/index')) {
|
|
pattern = pattern.slice(0, -6) || '/';
|
|
}
|
|
|
|
// Check for duplicates in dev mode
|
|
if (Rsx.is_dev() && Spa.routes[pattern]) {
|
|
console.error(`Spa: Duplicate route '${pattern}' - ${action_class.name} conflicts with ${Spa.routes[pattern].name}`);
|
|
}
|
|
|
|
Spa.routes[pattern] = action_class;
|
|
}
|
|
|
|
/**
|
|
* Match URL to a route and extract parameters
|
|
* Returns: { action_class, args, layout } or null
|
|
*/
|
|
static match_url_to_route(url) {
|
|
// Parse URL to get path and query params
|
|
const parsed = Spa.parse_url(url);
|
|
let path = parsed.path;
|
|
|
|
// Normalize path - remove leading/trailing slashes for matching
|
|
path = path.substring(1); // Remove leading /
|
|
|
|
// Remove /index suffix
|
|
if (path === 'index' || path.endsWith('/index')) {
|
|
path = path.slice(0, -5) || '';
|
|
}
|
|
|
|
// Try exact match first
|
|
const exact_pattern = '/' + path;
|
|
if (Spa.routes[exact_pattern]) {
|
|
return {
|
|
action_class: Spa.routes[exact_pattern],
|
|
args: parsed.query_params,
|
|
layout: Spa.routes[exact_pattern]._spa_layout || 'Default_Layout',
|
|
};
|
|
}
|
|
|
|
// Try pattern matching with :param segments
|
|
for (const pattern in Spa.routes) {
|
|
const match = Spa.match_pattern(path, pattern);
|
|
if (match) {
|
|
// Merge parameters with correct priority order:
|
|
// 1. GET parameters (from query string, lowest priority)
|
|
// 2. URL route parameters (extracted from route pattern like :id, highest priority)
|
|
// This matches the PHP Dispatcher behavior where route params override GET params
|
|
const args = { ...parsed.query_params, ...match };
|
|
|
|
return {
|
|
action_class: Spa.routes[pattern],
|
|
args: args,
|
|
layout: Spa.routes[pattern]._spa_layout || 'Default_Layout',
|
|
};
|
|
}
|
|
}
|
|
|
|
// No match found
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Match a path against a pattern with :param segments
|
|
* Returns object with extracted params or null if no match
|
|
*/
|
|
static match_pattern(path, pattern) {
|
|
// Remove leading / from both
|
|
path = path.replace(/^\//, '');
|
|
pattern = pattern.replace(/^\//, '');
|
|
|
|
// Split into segments
|
|
const path_segments = path.split('/');
|
|
const pattern_segments = pattern.split('/');
|
|
|
|
// Must have same number of segments
|
|
if (path_segments.length !== pattern_segments.length) {
|
|
return null;
|
|
}
|
|
|
|
const params = {};
|
|
|
|
for (let i = 0; i < pattern_segments.length; i++) {
|
|
const pattern_seg = pattern_segments[i];
|
|
const path_seg = path_segments[i];
|
|
|
|
if (pattern_seg.startsWith(':')) {
|
|
// This is a parameter - extract it
|
|
const param_name = pattern_seg.substring(1);
|
|
params[param_name] = decodeURIComponent(path_seg);
|
|
} else {
|
|
// This is a literal - must match exactly
|
|
if (pattern_seg !== path_seg) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* Parse URL into components
|
|
*/
|
|
static parse_url(url) {
|
|
let parsed_url;
|
|
|
|
try {
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
parsed_url = new URL(url);
|
|
} else {
|
|
parsed_url = new URL(url, window.location.href);
|
|
}
|
|
} catch (e) {
|
|
parsed_url = new URL(window.location.href);
|
|
}
|
|
|
|
const path = parsed_url.pathname;
|
|
const search = parsed_url.search;
|
|
|
|
// Parse query string
|
|
const query_params = {};
|
|
if (search && search !== '?') {
|
|
const query = search.startsWith('?') ? search.substring(1) : search;
|
|
const pairs = query.split('&');
|
|
|
|
for (const pair of pairs) {
|
|
const [key, value] = pair.split('=');
|
|
if (key) {
|
|
query_params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
|
|
}
|
|
}
|
|
}
|
|
|
|
return { path, search, query_params };
|
|
}
|
|
|
|
/**
|
|
* Generate URL from pattern and parameters
|
|
*/
|
|
static generate_url_from_pattern(pattern, params = {}) {
|
|
let url = pattern;
|
|
const used_params = new Set();
|
|
|
|
// Replace :param placeholders
|
|
url = url.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, param_name) => {
|
|
if (params.hasOwnProperty(param_name)) {
|
|
used_params.add(param_name);
|
|
return encodeURIComponent(params[param_name]);
|
|
}
|
|
return '';
|
|
});
|
|
|
|
// Collect unused parameters for query string
|
|
const query_params = {};
|
|
for (const key in params) {
|
|
if (!used_params.has(key)) {
|
|
query_params[key] = params[key];
|
|
}
|
|
}
|
|
|
|
// Add query string if needed
|
|
if (Object.keys(query_params).length > 0) {
|
|
const query_string = Object.entries(query_params)
|
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
.join('&');
|
|
url += '?' + query_string;
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Setup browser integration for back/forward and link interception
|
|
*
|
|
* This implements Phase 1 of browser integration using the History API.
|
|
* See: /docs.dev/SPA_BROWSER_INTEGRATION.md for complete documentation.
|
|
*
|
|
* Key Behaviors:
|
|
* - Intercepts clicks on <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)
|
|
Spa.dispatch(url, {
|
|
history: 'none',
|
|
scroll: scroll
|
|
});
|
|
});
|
|
|
|
// 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)) {
|
|
console_debug('Spa', 'Intercepting link click: ' + href);
|
|
e.preventDefault();
|
|
Spa.dispatch(href, { history: 'auto' });
|
|
} else {
|
|
console_debug('Spa', 'No SPA route match, letting server handle: ' + href);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Main dispatch method - navigate to a URL
|
|
*
|
|
* This is the single entry point for all navigation within the SPA.
|
|
* Handles route matching, history management, layout/action lifecycle, and scroll restoration.
|
|
*
|
|
* @param {string} url - Target URL (relative or absolute)
|
|
* @param {object} options - Navigation options
|
|
* @param {string} options.history - 'auto'|'push'|'replace'|'none' (default: 'auto')
|
|
* - 'auto': Push for new URLs, replace for same URL
|
|
* - 'push': Always create new history entry
|
|
* - 'replace': Replace current history entry
|
|
* - 'none': Don't modify history (used for back/forward)
|
|
* @param {object|null} options.scroll - Scroll position {x, y} to restore (default: null = scroll to top)
|
|
* @param {boolean} options.triggers - Fire before/after dispatch events (default: true)
|
|
*/
|
|
static async dispatch(url, options = {}) {
|
|
if (Spa.is_dispatching) {
|
|
console.warn('Spa: Already dispatching, ignoring nested dispatch');
|
|
return;
|
|
}
|
|
|
|
Spa.is_dispatching = true;
|
|
|
|
try {
|
|
const opts = {
|
|
history: options.history || 'auto',
|
|
scroll: options.scroll || null,
|
|
triggers: options.triggers !== false,
|
|
};
|
|
|
|
console_debug('Spa', 'Dispatching to: ' + url + ' (history: ' + opts.history + ')');
|
|
|
|
// Handle fully qualified URLs
|
|
const current_domain = window.location.hostname;
|
|
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
try {
|
|
const parsed_url = new URL(url);
|
|
|
|
// Check if different domain
|
|
if (parsed_url.hostname !== current_domain) {
|
|
// External domain - navigate away
|
|
console_debug('Spa', 'External domain, navigating: ' + url);
|
|
console.warn('[Spa.dispatch] Executing document.location.href (external domain)', {
|
|
url: url,
|
|
reason: 'External domain'
|
|
});
|
|
document.location.href = url;
|
|
Spa.is_dispatching = false;
|
|
return;
|
|
}
|
|
|
|
// Same domain - strip to relative URL
|
|
url = parsed_url.pathname + parsed_url.search + parsed_url.hash;
|
|
console_debug('Spa', 'Same domain, stripped to relative: ' + url);
|
|
} catch (e) {
|
|
console.error('Spa: Invalid URL format:', url);
|
|
Spa.is_dispatching = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Parse the (now relative) URL
|
|
const parsed = Spa.parse_url(url);
|
|
|
|
// CRITICAL: Strip hash from URL before route matching
|
|
// Hash represents page state (e.g., DataGrid page number), not routing state
|
|
// Hash is preserved in browser URL bar but not used for route matching
|
|
const url_without_hash = parsed.path + parsed.search;
|
|
|
|
console_debug('Spa', 'URL for route matching (hash stripped): ' + url_without_hash);
|
|
|
|
// Try to match URL to a route (without hash)
|
|
const route_match = Spa.match_url_to_route(url_without_hash);
|
|
|
|
// Check if this is the same URL we're currently on (without hash)
|
|
const current_url = window.location.pathname + window.location.search;
|
|
const is_same_url = url_without_hash === current_url;
|
|
|
|
// Same URL navigation with history: 'auto' should reload via server
|
|
// This mimics non-SPA behavior where clicking current page refreshes
|
|
if (is_same_url && opts.history === 'auto') {
|
|
console_debug('Spa', 'Same URL with auto history, letting browser reload');
|
|
console.warn('[Spa.dispatch] Executing document.location.href (same URL reload)', {
|
|
url: url,
|
|
reason: 'Same URL with auto history - mimics browser reload behavior'
|
|
});
|
|
document.location.href = url;
|
|
Spa.is_dispatching = false;
|
|
return;
|
|
}
|
|
|
|
if (is_same_url && !route_match) {
|
|
// We're being asked to navigate to the current URL, but it doesn't match
|
|
// any known route. This shouldn't happen - prevents infinite redirect loop.
|
|
Spa.spa_unknown_route_fatal(parsed.path);
|
|
Spa.is_dispatching = false;
|
|
return;
|
|
}
|
|
|
|
// If no route match and we're not on this URL, let server handle it
|
|
if (!route_match) {
|
|
console_debug('Spa', 'No route matched, letting server handle: ' + url);
|
|
console.warn('[Spa.dispatch] Executing document.location.href (no route match)', {
|
|
url: url,
|
|
reason: 'URL does not match any registered SPA routes'
|
|
});
|
|
document.location.href = url;
|
|
Spa.is_dispatching = false;
|
|
return;
|
|
}
|
|
|
|
console_debug('Spa', 'Route match:', {
|
|
action_class: route_match?.action_class?.name,
|
|
args: route_match?.args,
|
|
layout: route_match?.layout,
|
|
});
|
|
|
|
// Check if action's @spa() attribute matches current SPA bootstrap
|
|
const action_spa_controller = route_match.action_class._spa_controller_method;
|
|
const current_spa_controller = window.rsxapp.current_controller + '::' + window.rsxapp.current_action;
|
|
|
|
if (action_spa_controller && action_spa_controller !== current_spa_controller) {
|
|
// Different SPA module - let server bootstrap it
|
|
console_debug('Spa', 'Different SPA module, letting server handle: ' + url);
|
|
console_debug('Spa', ` Action uses: ${action_spa_controller}`);
|
|
console_debug('Spa', ` Current SPA: ${current_spa_controller}`);
|
|
console.warn('[Spa.dispatch] Executing document.location.href (different SPA module)', {
|
|
url: url,
|
|
reason: 'Action belongs to different SPA module/bundle',
|
|
action_spa_controller: action_spa_controller,
|
|
current_spa_controller: current_spa_controller
|
|
});
|
|
document.location.href = url;
|
|
Spa.is_dispatching = false;
|
|
return;
|
|
}
|
|
|
|
// Update browser history with scroll position storage
|
|
if (opts.history !== 'none') {
|
|
// Store current scroll position before navigation
|
|
const current_scroll = {
|
|
x: window.scrollX || window.pageXOffset,
|
|
y: window.scrollY || window.pageYOffset
|
|
};
|
|
|
|
// Build history state object
|
|
const state = {
|
|
scroll: current_scroll,
|
|
form_data: {} // Reserved for future form state restoration
|
|
};
|
|
|
|
// Construct full URL with hash (hash is preserved in browser URL bar)
|
|
const new_url = parsed.path + parsed.search + (parsed.hash || '');
|
|
|
|
if (opts.history === 'push' || (opts.history === 'auto' && !is_same_url)) {
|
|
console_debug('Spa', 'Pushing history state');
|
|
history.pushState(state, '', new_url);
|
|
} else if (opts.history === 'replace' || (opts.history === 'auto' && is_same_url)) {
|
|
console_debug('Spa', 'Replacing history state');
|
|
history.replaceState(state, '', new_url);
|
|
}
|
|
}
|
|
|
|
// Set global Spa state
|
|
Spa.route = route_match;
|
|
Spa.path = parsed.path;
|
|
Spa.params = route_match.args;
|
|
|
|
// Get layout name and action info
|
|
const layout_name = route_match.layout;
|
|
const action_class = route_match.action_class;
|
|
const action_name = action_class.name;
|
|
|
|
// Log successful SPA navigation
|
|
console.warn('[Spa.dispatch] Executing SPA navigation', {
|
|
url: url,
|
|
path: parsed.path,
|
|
params: route_match.args,
|
|
action: action_name,
|
|
layout: layout_name,
|
|
history_mode: opts.history
|
|
});
|
|
|
|
// Check if we need a new layout
|
|
if (!Spa.layout || Spa.layout.constructor.name !== layout_name) {
|
|
// Stop old layout if exists (auto-stops children)
|
|
if (Spa.layout) {
|
|
await Spa.layout.trigger('unload');
|
|
Spa.layout.stop();
|
|
}
|
|
|
|
// Clear body and create new layout
|
|
$('body').empty();
|
|
$('body').attr('class', '');
|
|
|
|
// Create layout using component system
|
|
Spa.layout = $('body').component(layout_name, {}).component();
|
|
|
|
// Wait for layout to be ready
|
|
await Spa.layout.ready();
|
|
|
|
console_debug('Spa', `Created layout: ${layout_name}`);
|
|
} else {
|
|
// Wait for layout to finish previous action if still loading
|
|
await Spa.layout.ready();
|
|
}
|
|
|
|
// Tell layout to run the action
|
|
Spa.layout._set_action(action_name, route_match.args, url);
|
|
await Spa.layout._run_action();
|
|
|
|
// Scroll Restoration #1: Immediate (after action starts)
|
|
// This occurs synchronously after the action component is created
|
|
// May fail if page height is insufficient - that's okay, we'll retry later
|
|
if (opts.scroll) {
|
|
console_debug('Spa', 'Restoring scroll position (immediate): ' + opts.scroll.x + ', ' + opts.scroll.y);
|
|
window.scrollTo(opts.scroll.x, opts.scroll.y);
|
|
} else if (opts.scroll === undefined) {
|
|
// Default: scroll to top for new navigation (only if scroll not explicitly set)
|
|
console_debug('Spa', 'Scrolling to top (new navigation)');
|
|
window.scrollTo(0, 0);
|
|
}
|
|
// If opts.scroll === null, don't scroll (let Navigation API or browser handle it)
|
|
|
|
// TODO: Scroll Restoration #2 - After action on_ready() completes
|
|
// This requires an action lifecycle event system to detect when on_ready() finishes.
|
|
// Implementation notes:
|
|
// - Listen for action on_ready completion event
|
|
// - Only retry scroll if restoration #1 failed (page wasn't tall enough)
|
|
// - Check if page height has increased since first attempt
|
|
// - Avoid infinite retry loops (track attempts)
|
|
// - This ensures scroll restoration works even when content loads asynchronously
|
|
//
|
|
// Additional context: The action may load data in on_load() that increases page height,
|
|
// making the target scroll position accessible. The first restoration happens before
|
|
// this content renders, so we need a second attempt after the page is fully ready.
|
|
|
|
console_debug('Spa', `Rendered ${action_name} in ${layout_name}`);
|
|
} catch (error) {
|
|
console.error('[Spa] Dispatch error:', error);
|
|
// TODO: Better error handling - show error UI to user
|
|
throw error;
|
|
} finally {
|
|
Spa.is_dispatching = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fatal error when trying to navigate to unknown route on current URL
|
|
* This shouldn't happen - prevents infinite redirect loops
|
|
*/
|
|
static spa_unknown_route_fatal(path) {
|
|
console.error(`Unknown route for path ${path} - this shouldn't happen`);
|
|
}
|
|
}
|