Files
rspade_system/app/RSpade/Core/SPA/Spa.js
root 9ebcc359ae Fix code quality violations and enhance ROUTE-EXISTS-01 rule
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>
2025-11-19 17:48:15 +00:00

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`);
}
}