/** * LiveComponent Client-Side Implementation * * Handles: * - Component initialization and lifecycle * - AJAX action dispatching * - Server-sent events from component updates * - Polling for Pollable components * - Event-driven component-to-component communication * - Fragment-based partial rendering with DOM patching * - Real-time SSE updates for reactive components */ import { domPatcher } from './DomPatcher.js'; import { SseClient } from '../sse/index.js'; import { LazyComponentLoader } from './LazyComponentLoader.js'; import { NestedComponentHandler } from './NestedComponentHandler.js'; import { ComponentPlayground } from './ComponentPlayground.js'; import { ComponentFileUploader } from './ComponentFileUploader.js'; import { FileUploadWidget } from './FileUploadWidget.js'; import { optimisticStateManager } from './OptimisticStateManager.js'; import { accessibilityManager } from './AccessibilityManager.js'; import { ErrorBoundary } from './ErrorBoundary.js'; import { RequestDeduplicator } from './RequestDeduplicator.js'; import * as StateSerializer from './StateSerializer.js'; import { UIEventHandler } from './UIEventHandler.js'; import { triggerManager } from './TriggerManager.js'; import { sharedConfig } from './SharedConfig.js'; import { tooltipManager } from './TooltipManager.js'; import { actionLoadingManager } from './ActionLoadingManager.js'; import { LiveComponentUIHelper } from './LiveComponentUIHelper.js'; import { LoadingStateManager } from './LoadingStateManager.js'; import { ProgressiveEnhancement } from './ProgressiveEnhancement.js'; import { urlManager } from './UrlManager.js'; import { LiveComponentCoreAttributes, LiveComponentFeatureAttributes, LiveComponentLazyAttributes, toDatasetKey } from '../../core/DataAttributes.js'; class LiveComponentManager { constructor() { this.components = new Map(); this.eventHandlers = new Map(); this.debounceTimers = new Map(); this.domPatcher = domPatcher; this.accessibilityManager = accessibilityManager; // Note: No global CSRF token - each component has its own token // SSE Integration this.sseClients = new Map(); // channel → SseClient this.componentChannels = new Map(); // componentId → channel // Lazy Loading Integration this.lazyLoader = null; // Initialized in init() or initLazyLoading() // Nested Components Integration this.nestedHandler = null; // Initialized in initNestedComponents() // DevTools Integration this.devTools = null; // Will be set by DevTools when available // Error Handling this.errorBoundary = new ErrorBoundary(this); // Request Deduplication this.requestDeduplicator = new RequestDeduplicator(); // Shared Configuration this.config = sharedConfig; // Tooltip Manager this.tooltipManager = tooltipManager; // Action Loading Manager this.actionLoadingManager = actionLoadingManager; // UI Helper this.uiHelper = new LiveComponentUIHelper(this); // UI Event Handler this.uiEventHandler = new UIEventHandler(this); this.uiEventHandler.init(); // Loading State Manager this.loadingStateManager = new LoadingStateManager( this.actionLoadingManager, optimisticStateManager ); // Progressive Enhancement this.progressiveEnhancement = new ProgressiveEnhancement(this); // URL Manager urlManager.init(); } /** * Enable DevTools integration * Called by LiveComponentDevTools during initialization */ enableDevTools(devToolsInstance) { this.devTools = devToolsInstance; console.log('[LiveComponent] DevTools integration enabled'); } /** * Log action to DevTools if enabled */ logActionToDevTools(componentId, actionName, params, startTime, endTime, success, error = null) { if (!this.devTools) return; this.devTools.logAction(componentId, actionName, params, startTime, endTime, success, error); } /** * Log event to DevTools if enabled */ logEventToDevTools(eventName, data, source = 'server') { if (!this.devTools) return; this.devTools.logEvent(eventName, data, source); } /** * Initialize Lazy Loading system * Should be called once during application startup */ initLazyLoading() { if (this.lazyLoader) { console.warn('[LiveComponent] Lazy loading already initialized'); return; } this.lazyLoader = new LazyComponentLoader(this); this.lazyLoader.init(); console.log('[LiveComponent] Lazy loading system initialized'); } /** * Initialize Nested Components system * Should be called once during application startup */ initNestedComponents() { if (this.nestedHandler) { console.warn('[LiveComponent] Nested components already initialized'); return; } this.nestedHandler = new NestedComponentHandler(this); this.nestedHandler.init(); console.log('[LiveComponent] Nested components system initialized'); } /** * Initialize LiveComponent * * @param {HTMLElement} element - Component element * @param {Object} options - Initialization options * @param {boolean} options.isolated - If true, component is isolated (Island) and won't receive parent events */ init(element, options = {}) { const componentId = element.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_COMPONENT)]; if (!componentId) return; const isIsland = options.isolated === true || element.dataset[toDatasetKey(LiveComponentLazyAttributes.ISLAND_COMPONENT)] === 'true'; const config = { id: componentId, element, pollInterval: parseInt(element.dataset[toDatasetKey(LiveComponentCoreAttributes.POLL_INTERVAL)]) || null, pollTimer: null, observer: null, isolated: isIsland }; this.components.set(componentId, config); // Setup action handlers this.setupActionHandlers(element); // Setup file upload handlers this.setupFileUploadHandlers(element); // Setup polling if component is pollable if (config.pollInterval) { this.startPolling(componentId); } // Setup lifecycle observer for onDestroy hook this.setupLifecycleObserver(element, componentId); // Setup SSE connection if component has a channel // Islands have isolated SSE channels (no parent events) this.setupSseConnection(element, componentId, { isolated: isIsland }); // Setup accessibility features this.setupAccessibility(componentId, element); // Initialize tooltips for component this.tooltipManager.initComponent(element); // Initialize progressive enhancement if not already done if (!this.progressiveEnhancement.initialized) { this.progressiveEnhancement.init(); } console.log(`[LiveComponent] Initialized: ${componentId}${isIsland ? ' (Island)' : ''}`); } /** * Setup accessibility features for component */ setupAccessibility(componentId, element) { // Create component-specific ARIA live region const politeness = element.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_POLITE)] === 'assertive' ? 'assertive' : 'polite'; this.accessibilityManager.createComponentLiveRegion(componentId, element, politeness); console.log(`[LiveComponent] Accessibility features enabled for ${componentId}`); } /** * Setup MutationObserver to detect component removal from DOM * Calls onDestroy() lifecycle hook on server when component is removed */ setupLifecycleObserver(element, componentId) { const config = this.components.get(componentId); if (!config) return; // Create observer to watch for element removal const observer = new MutationObserver((mutations) => { // Check if element is no longer in DOM if (!document.contains(element)) { console.log(`[LiveComponent] Element removed from DOM: ${componentId}`); this.callDestroyHook(componentId); observer.disconnect(); } }); // Observe parent node for childList changes if (element.parentNode) { observer.observe(element.parentNode, { childList: true, subtree: false }); } config.observer = observer; } /** * Setup SSE connection for component * Automatically connects to SSE if component has data-sse-channel attribute */ setupSseConnection(element, componentId, options = {}) { // Check if component has SSE channel const sseChannel = element.dataset[toDatasetKey(LiveComponentCoreAttributes.SSE_CHANNEL)]; if (!sseChannel) { return; // No SSE channel, skip } console.log(`[LiveComponent] Setting up SSE for ${componentId} on channel: ${sseChannel}`); // Track component-channel mapping this.componentChannels.set(componentId, sseChannel); // Get or create SSE client for this channel let sseClient = this.sseClients.get(sseChannel); if (!sseClient) { // Create new SSE client for this channel sseClient = new SseClient([sseChannel], { autoReconnect: true, heartbeatTimeout: 45000 }); // Register global handlers for this channel this.registerSseHandlers(sseClient, sseChannel); // Connect sseClient.connect(); this.sseClients.set(sseChannel, sseClient); console.log(`[LiveComponent] Created SSE client for channel: ${sseChannel}`); } console.log(`[LiveComponent] SSE connection established for ${componentId}`); } /** * Register SSE event handlers for a channel */ registerSseHandlers(sseClient, channel) { // Handle component-update events sseClient.on('component-update', (data) => { this.handleSseComponentUpdate(data, channel); }); // Handle component-fragments events sseClient.on('component-fragments', (data) => { this.handleSseComponentFragments(data, channel); }); // Handle connection state changes sseClient.on('connected', () => { console.log(`[LiveComponent] SSE connected to channel: ${channel}`); this.updateSseConnectionStatus(channel, 'connected'); }); sseClient.on('disconnected', () => { console.log(`[LiveComponent] SSE disconnected from channel: ${channel}`); this.updateSseConnectionStatus(channel, 'disconnected'); }); sseClient.on('reconnecting', (attempt) => { console.log(`[LiveComponent] SSE reconnecting to channel ${channel} (attempt ${attempt})`); this.updateSseConnectionStatus(channel, 'reconnecting'); }); sseClient.on('error', (error) => { console.error(`[LiveComponent] SSE error on channel ${channel}:`, error); this.updateSseConnectionStatus(channel, 'error'); }); } /** * Handle SSE component-update event */ handleSseComponentUpdate(data, channel) { const { componentId, state, html, events } = data; console.log(`[LiveComponent] SSE update for ${componentId}:`, { state, html, events }); const config = this.components.get(componentId); if (!config) { console.warn(`[LiveComponent] Received SSE update for unknown component: ${componentId}`); return; } try { // Capture focus before update this.accessibilityManager.captureFocusState(componentId, config.element); // Update full HTML if provided if (html) { config.element.innerHTML = html; this.setupActionHandlers(config.element); this.setupFileUploadHandlers(config.element); } // Update state using StateSerializer if (state) { StateSerializer.setStateOnElement(config.element, state); } // Restore focus after update this.accessibilityManager.restoreFocus(componentId, config.element); // Announce update to screen readers this.accessibilityManager.announceUpdate(componentId, 'full'); // Dispatch events if (events && Array.isArray(events)) { this.handleServerEvents(events); } console.log(`[LiveComponent] SSE update applied to ${componentId}`); } catch (error) { console.error(`[LiveComponent] Failed to apply SSE update for ${componentId}:`, error); } } /** * Handle SSE component-fragments event */ handleSseComponentFragments(data, channel) { const { componentId, fragments } = data; console.log(`[LiveComponent] SSE fragments for ${componentId}:`, fragments); const config = this.components.get(componentId); if (!config) { console.warn(`[LiveComponent] Received SSE fragments for unknown component: ${componentId}`); return; } try { // Update fragments using DOM patcher this.updateFragments(config.element, fragments); console.log(`[LiveComponent] SSE fragments applied to ${componentId}`); } catch (error) { console.error(`[LiveComponent] Failed to apply SSE fragments for ${componentId}:`, error); } } /** * Update SSE connection status indicators */ updateSseConnectionStatus(channel, status) { // Find all components on this channel this.componentChannels.forEach((componentChannel, componentId) => { if (componentChannel === channel) { const config = this.components.get(componentId); if (!config) return; // Update status attribute config.element.dataset.sseStatus = status; // Dispatch custom event for status change config.element.dispatchEvent(new CustomEvent('livecomponent:sse:status', { detail: { channel, status } })); } }); } /** * Call onDestroy() lifecycle hook on server * Uses navigator.sendBeacon for best-effort delivery even during page unload */ async callDestroyHook(componentId) { const config = this.components.get(componentId); if (!config) return; try { // Get current state const stateJson = config.element.dataset.state || '{}'; const state = JSON.parse(stateJson); // Get CSRF token const csrfToken = config.element.dataset.csrfToken; const payload = JSON.stringify({ state, _csrf_token: csrfToken }); // Try sendBeacon first for better reliability during page unload const url = `/live-component/${componentId}/destroy`; const blob = new Blob([payload], { type: 'application/json' }); if (navigator.sendBeacon && navigator.sendBeacon(url, blob)) { console.log(`[LiveComponent] onDestroy() called via sendBeacon: ${componentId}`); } else { // Fallback to fetch for normal removal await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: payload }); console.log(`[LiveComponent] onDestroy() called via fetch: ${componentId}`); } } catch (error) { // Don't throw - component is being destroyed anyway console.warn(`[LiveComponent] onDestroy() hook failed: ${componentId}`, error); } // Clean up local state this.destroy(componentId); } /** * Setup action click handlers */ setupActionHandlers(element) { element.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_ACTION}]`).forEach(actionEl => { // Find the component ID: check if action element has explicit component ID, // otherwise find closest parent component let componentId = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_COMPONENT)]; if (!componentId) { // Find the innermost (most nested) component element // We need to collect all ancestor elements with data-live-component // and use the first one (which is the innermost/closest one) let current = actionEl.parentElement; const componentElements = []; // Collect all ancestor elements with data-live-component while (current && current !== document.body) { if (current.hasAttribute && current.hasAttribute(LiveComponentCoreAttributes.LIVE_COMPONENT)) { componentElements.push(current); } current = current.parentElement; } // Use the first element (innermost/closest component) if (componentElements.length > 0) { componentId = componentElements[0].dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_COMPONENT)]; } else { // Fallback to closest() if manual traversal didn't work const fallbackElement = actionEl.closest(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`); if (fallbackElement) { componentId = fallbackElement.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_COMPONENT)]; } } } if (!componentId) { console.warn('[LiveComponent] No component found for action:', actionEl, actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)]); return; } // Debug logging to help diagnose issues if (import.meta.env.DEV && actionEl.dataset.liveAction === 'togglePreview') { const closestComp = actionEl.closest('[data-live-component]'); let current = actionEl.parentElement; const allComponents = []; while (current && current !== document.body) { if (current.hasAttribute && current.hasAttribute(LiveComponentCoreAttributes.LIVE_COMPONENT)) { allComponents.push(current.dataset.liveComponent); } current = current.parentElement; } console.log('[LiveComponent] Action setup:', { action: actionEl.dataset.liveAction, componentId: componentId, closestComponentId: closestComp?.dataset.liveComponent, allAncestorComponents: allComponents, actionElement: actionEl, }); } // Check if advanced trigger options are used const hasAdvancedTriggers = actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_DELAY)] || actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_THROTTLE)] || actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_ONCE)] === 'true' || actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_CHANGED)] === 'true' || actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_FROM)] || actionEl.dataset[toDatasetKey(LiveComponentFeatureAttributes.LC_TRIGGER_LOAD)] === 'true'; // Create action handler function const createActionHandler = async (e, paramsOverride = {}) => { const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)]; let params = { ...this.extractParams(actionEl), ...paramsOverride }; // For submit/navigation actions, collect all form values if (action === 'submit' || action === 'nextStep' || action === 'previousStep') { const formValues = this.collectFormValues(element); params = { ...formValues, ...params }; } // Extract fragments for partial rendering const fragments = this.extractFragments(actionEl); await this.executeAction(componentId, action, params, fragments, actionEl); }; // Use TriggerManager if advanced triggers are present if (hasAdvancedTriggers) { triggerManager.setupTrigger(actionEl, componentId, actionEl.dataset.liveAction, async (e) => { if (actionEl.tagName === 'FORM' && e) { e.preventDefault(); const formValues = {}; const formData = new FormData(actionEl); for (const [key, value] of formData.entries()) { formValues[key] = value; } await createActionHandler(e, formValues); } else { if (e) e.preventDefault(); await createActionHandler(e); } }, actionEl.tagName === 'FORM' ? 'submit' : 'click'); // Skip default handler setup - TriggerManager handles it } else { // Handle form submissions (default behavior) if (actionEl.tagName === 'FORM') { actionEl.addEventListener('submit', async (e) => { e.preventDefault(); const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)]; // Collect all form values const formValues = {}; const formData = new FormData(actionEl); for (const [key, value] of formData.entries()) { formValues[key] = value; } // Extract fragments for partial rendering const fragments = this.extractFragments(actionEl); await this.executeAction(componentId, action, formValues, fragments, actionEl); }); } // Handle input elements with debouncing else if (actionEl.tagName === 'INPUT' || actionEl.tagName === 'TEXTAREA' || actionEl.tagName === 'SELECT') { // Default debounce for text inputs: 300ms, others: 0ms const defaultDebounce = (actionEl.type === 'text' || actionEl.type === 'email' || actionEl.type === 'url' || actionEl.type === 'tel' || actionEl.tagName === 'TEXTAREA') ? 300 : 0; const debounceMs = parseInt(actionEl.dataset.liveDebounce) ?? defaultDebounce; // For SELECT elements, use 'change' event (SELECT doesn't fire 'input' event) if (actionEl.tagName === 'SELECT') { actionEl.addEventListener('change', async (e) => { const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)]; const params = this.extractParams(actionEl); // Add select value to params // Convert snake_case name to camelCase for PHP method parameter binding const name = actionEl.name || 'content_type_id'; const camelCaseName = name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); // Use camelCase for parameter name to match PHP method signature params[camelCaseName] = actionEl.value; // Also keep original name for backwards compatibility if (name !== camelCaseName) { params[name] = actionEl.value; } console.log('[LiveComponent] SELECT change:', { action, name, camelCaseName, value: actionEl.value, params }); // Extract fragments for partial rendering const fragments = this.extractFragments(actionEl); await this.executeAction(componentId, action, params, fragments, actionEl); }); } else { // For INPUT and TEXTAREA, use 'input' event actionEl.addEventListener('input', async (e) => { const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)]; const params = this.extractParams(actionEl); // Add input value to params if (actionEl.type === 'checkbox') { params[actionEl.name || 'value'] = actionEl.checked ? 'yes' : 'no'; } else if (actionEl.type === 'radio') { params[actionEl.name || 'value'] = actionEl.value; } else { params[actionEl.name || 'value'] = actionEl.value; } // Extract fragments for partial rendering const fragments = this.extractFragments(actionEl); if (debounceMs > 0) { this.debouncedAction(componentId, action, params, debounceMs, fragments, actionEl); } else { await this.executeAction(componentId, action, params, fragments, actionEl); } }); } // For radio buttons and checkboxes, also listen to 'change' event if (actionEl.type === 'radio' || actionEl.type === 'checkbox') { actionEl.addEventListener('change', async (e) => { const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)]; const params = this.extractParams(actionEl); if (actionEl.type === 'checkbox') { params[actionEl.name || 'value'] = actionEl.checked ? 'yes' : 'no'; } else if (actionEl.type === 'radio') { params[actionEl.name || 'value'] = actionEl.value; } await this.executeAction(componentId, action, params, null, actionEl); }); } } else { // Handle button clicks (default behavior) actionEl.addEventListener('click', async (e) => { e.preventDefault(); const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)]; let params = this.extractParams(actionEl); // For submit/navigation actions, collect all form values if (action === 'submit' || action === 'nextStep' || action === 'previousStep') { const formValues = this.collectFormValues(element); console.log('[LiveComponent] Collected form values:', formValues); params = { ...formValues, ...params }; console.log('[LiveComponent] Final params:', params); } // Extract fragments for partial rendering const fragments = this.extractFragments(actionEl); await this.executeAction(componentId, action, params, fragments, actionEl); }); } } }); } /** * Debounced action execution */ debouncedAction(componentId, action, params, delay, fragments = null, actionElement = null) { const timerKey = `${componentId}_${action}`; // Clear existing timer if (this.debounceTimers.has(timerKey)) { clearTimeout(this.debounceTimers.get(timerKey)); } // Set new timer const timer = setTimeout(async () => { await this.executeAction(componentId, action, params, fragments, actionElement); this.debounceTimers.delete(timerKey); }, delay); this.debounceTimers.set(timerKey, timer); } /** * Extract action parameters from element */ extractParams(element) { const params = {}; // Extract data-param-* attributes Object.keys(element.dataset).forEach(key => { if (key.startsWith('param')) { // Remove 'param' prefix and handle '-from' suffix const paramKey = key.replace('param', ''); // Check if this is a -from parameter (e.g., data-param-field-from="value") if (paramKey.endsWith('From')) { // Extract parameter name (e.g., "fieldFrom" -> "field") const paramName = paramKey.replace(/From$/, '').toLowerCase(); const fromAttribute = element.dataset[key]; // e.g., "value" // Get value from the specified attribute if (fromAttribute === 'value') { params[paramName] = element.value; } else { params[paramName] = element[fromAttribute] || element.getAttribute(fromAttribute); } } else { // Regular parameter (e.g., data-param-direction="desc") const paramName = paramKey.toLowerCase(); params[paramName] = element.dataset[key]; } } }); return params; } /** * Extract fragments from element * * Gets fragments to update from data-lc-fragments attribute. * Format: data-lc-fragments="user-stats,recent-activity" * * @param {HTMLElement} element - Action element * @returns {Array|null} - Array of fragment names or null */ extractFragments(element) { const fragmentsAttr = element.dataset.lcFragments; if (!fragmentsAttr) { return null; } // Split by comma and trim whitespace return fragmentsAttr.split(',').map(name => name.trim()).filter(Boolean); } /** * Collect all form input values from component */ collectFormValues(element) { const values = {}; // Find all inputs, textareas, and selects within the component const inputs = element.querySelectorAll('input, textarea, select'); inputs.forEach(input => { const name = input.name; if (!name) return; // Skip inputs without name if (input.type === 'checkbox') { values[name] = input.checked ? 'yes' : 'no'; } else if (input.type === 'radio') { if (input.checked) { values[name] = input.value; } } else { values[name] = input.value; } }); return values; } /** * Execute component action * * @param {string} componentId - Component ID * @param {string} method - Action method name * @param {Object} params - Action parameters * @param {Array} fragments - Optional fragment names for partial rendering * @param {HTMLElement} actionElement - Optional element that triggered the action (for target/swap support) */ async executeAction(componentId, method, params = {}, fragments = null, actionElement = null) { const config = this.components.get(componentId); if (!config) { console.error(`[LiveComponent] Unknown component: ${componentId}`); return; } // Check for pending duplicate request const pendingRequest = this.requestDeduplicator.getPendingRequest(componentId, method, params); if (pendingRequest) { console.log(`[LiveComponent] Deduplicating request: ${componentId}.${method}`); return await pendingRequest; } // Check for cached result const cachedResult = this.requestDeduplicator.getCachedResult(componentId, method, params); if (cachedResult) { console.log(`[LiveComponent] Using cached result: ${componentId}.${method}`); return cachedResult; } let operationId = null; const startTime = performance.now(); // Find the correct component element (may differ from config.element for nested components) let componentElement = config.element; if (componentElement.dataset.liveComponent !== componentId) { // If config.element doesn't match, find the correct element componentElement = document.querySelector(`[data-live-component="${componentId}"]`); if (!componentElement) { console.error(`[LiveComponent] Component element not found: ${componentId}`); return; } } // Show loading state (uses LoadingStateManager for configurable indicators) const loadingConfig = this.loadingStateManager.getConfig(componentId); this.loadingStateManager.showLoading(componentId, componentElement, { fragments, type: loadingConfig.type, optimistic: true // Check optimistic UI first }, actionElement); // Create request promise const requestPromise = (async () => { try { if (componentElement.dataset.liveComponent !== componentId) { // If config.element doesn't match, find the correct element componentElement = document.querySelector(`[data-live-component="${componentId}"]`); if (!componentElement) { console.error(`[LiveComponent] Component element not found: ${componentId}`); throw new Error(`Component element ${componentId} not found in DOM`); } } // Get current state from element using StateSerializer const stateWrapper = StateSerializer.getStateFromElement(componentElement) || { id: componentId, component: '', data: {}, version: 1 }; // Extract actual state data from wrapper format // Wrapper format: {id, component, data, version} // Server expects just the data object // Check if there are pending operations - if so, use the optimistic version const pendingOps = optimisticStateManager.getPendingOperations(componentId); // CRITICAL: Always read version from stateWrapper first let currentVersion = 1; if (stateWrapper && typeof stateWrapper.version === 'number') { currentVersion = stateWrapper.version; } // If there are pending operations, use the version from the latest optimistic state if (pendingOps.length > 0) { const latestOp = pendingOps[pendingOps.length - 1]; if (latestOp.optimisticState && typeof latestOp.optimisticState.version === 'number') { currentVersion = latestOp.optimisticState.version; } } // CRITICAL DEBUG: Always log, even in production console.error('[LiveComponent] VERSION DEBUG START', { stateWrapperVersion: stateWrapper?.version, stateWrapperVersionType: typeof stateWrapper?.version, currentVersion, pendingOpsCount: pendingOps.length, hasStateWrapper: !!stateWrapper, stateWrapperKeys: stateWrapper ? Object.keys(stateWrapper) : [] }); // If there are pending operations, use the version from the latest optimistic state // This ensures we always send the most recent version, even with optimistic updates if (pendingOps.length > 0) { // Get the latest optimistic state version // The optimistic state has version = currentVersion + 1, so we use that const latestOp = pendingOps[pendingOps.length - 1]; if (latestOp.optimisticState && latestOp.optimisticState.version) { currentVersion = latestOp.optimisticState.version; } } const state = stateWrapper.data || {}; // Apply optimistic update for immediate UI feedback // This updates the UI before server confirmation const optimisticState = optimisticStateManager.applyOptimisticUpdate( componentId, stateWrapper, (currentState) => { // Optimistic update function - predict state changes // For now, we just mark that an operation is pending // Component-specific logic could predict actual changes return { ...currentState, data: { ...currentState.data, _optimistic: true } }; }, { action: method, params } ); operationId = optimisticStateManager.getPendingOperations(componentId)[0]?.id; // Debug logging for version management if (import.meta.env.DEV) { console.log(`[LiveComponent] Executing ${method} for ${componentId}`, { currentVersion, pendingOpsCount: pendingOps.length, operationId }); } // Get component-specific CSRF token from the component element const csrfToken = componentElement.dataset.csrfToken; if (!csrfToken) { console.error(`[LiveComponent] Missing CSRF token for component: ${componentId}`); console.error('[LiveComponent] Component element:', componentElement); console.error('[LiveComponent] Component element dataset:', componentElement?.dataset); console.error('[LiveComponent] Component element HTML:', componentElement?.outerHTML?.substring(0, 500)); throw new Error('CSRF token is required for component actions'); } // Debug logging for CSRF token if (import.meta.env.DEV) { console.log(`[LiveComponent] CSRF token for ${componentId}:`, csrfToken?.substring(0, 20) + '...'); } // Build request body // CRITICAL: Always ensure version is a valid number // Read version from stateWrapper first, then check pending ops let versionToSend = 1; // Try to get version from stateWrapper if (stateWrapper && typeof stateWrapper.version === 'number') { versionToSend = stateWrapper.version; } // If there are pending operations, use the version from the latest optimistic state if (pendingOps.length > 0) { const latestOp = pendingOps[pendingOps.length - 1]; if (latestOp.optimisticState && typeof latestOp.optimisticState.version === 'number') { versionToSend = latestOp.optimisticState.version; } } // Ensure versionToSend is always a valid number if (typeof versionToSend !== 'number' || isNaN(versionToSend)) { versionToSend = 1; } // CRITICAL DEBUG: Always log, even in production console.error('[LiveComponent] VERSION DEBUG', { stateWrapperVersion: stateWrapper?.version, currentVersion, versionToSend, pendingOpsCount: pendingOps.length, hasStateWrapper: !!stateWrapper }); // CRITICAL: Build request body with version ALWAYS included as explicit property // Use object literal with explicit version to ensure it's never undefined const requestBody = {}; requestBody.method = method; requestBody.params = params; requestBody.state = state; requestBody.version = versionToSend; // CRITICAL: Always send version (never undefined) requestBody._csrf_token = csrfToken; // Add fragments parameter if specified if (fragments && Array.isArray(fragments) && fragments.length > 0) { requestBody.fragments = fragments; } // CRITICAL: Double-check version is still present after adding fragments if (typeof requestBody.version !== 'number' || isNaN(requestBody.version)) { requestBody.version = versionToSend || 1; } // Send AJAX request with CSRF token in body // Component ID is in the URL, not in the body // CRITICAL DEBUG: Log the exact request body before sending const requestBodyString = JSON.stringify(requestBody); // CRITICAL: Verify version is in JSON string - if not, force add it let finalRequestBody = requestBodyString; if (!requestBodyString.includes('"version"')) { console.error('[LiveComponent] ERROR: Version missing in JSON! Forcing version...', { jsonPreview: requestBodyString.substring(0, 300), versionToSend, requestBodyKeys: Object.keys(requestBody) }); // Parse and re-add version try { const parsed = JSON.parse(requestBodyString); parsed.version = versionToSend || 1; finalRequestBody = JSON.stringify(parsed); console.error('[LiveComponent] Corrected JSON preview:', finalRequestBody.substring(0, 300)); } catch (e) { console.error('[LiveComponent] Failed to parse/correct JSON:', e); // Last resort: manually add version to JSON string const jsonObj = JSON.parse(requestBodyString); jsonObj.version = versionToSend || 1; finalRequestBody = JSON.stringify(jsonObj); } } // CRITICAL: Final verification if (!finalRequestBody.includes('"version"')) { console.error('[LiveComponent] CRITICAL ERROR: Version still missing after correction!'); } const response = await fetch(`/live-component/${componentId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: finalRequestBody }); if (!response.ok) { const errorText = await response.text(); console.error('[LiveComponent] Server error response:', errorText); throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const responseText = await response.text(); console.log('[LiveComponent] Raw response:', responseText.substring(0, 200)); let data; try { data = JSON.parse(responseText); } catch (parseError) { console.error('[LiveComponent] JSON parse error:', parseError); console.error('[LiveComponent] Response text:', responseText); throw new Error(`Failed to parse JSON response: ${parseError.message}`); } // Check for version conflict const serverState = data.state || {}; const serverVersion = serverState.version || 1; if (serverVersion !== currentVersion + 1) { // Version conflict detected - server rejected our optimistic update console.warn(`[LiveComponent] Version conflict: expected ${currentVersion + 1}, got ${serverVersion}`); // Handle conflict via OptimisticStateManager if (operationId) { const conflictResolution = optimisticStateManager.handleConflict( componentId, operationId, serverState, { expectedVersion: currentVersion + 1, actualVersion: serverVersion } ); // Show conflict notification to user if (conflictResolution.notification) { this.showConflictNotification(componentId, conflictResolution.notification); } // Use server state (rollback) data.state = conflictResolution.state; } } else { // Success - confirm optimistic operation if (operationId) { optimisticStateManager.confirmOperation(componentId, operationId, serverState); } } // Resolve target element (supports data-lc-target) const targetElement = this.resolveTarget(actionElement, componentId); if (!targetElement) { throw new Error(`[LiveComponent] Could not resolve target element for component: ${componentId}`); } // Get swap strategy from action element or use default const swapStrategy = actionElement?.dataset.lcSwap || 'innerHTML'; // Handle fragment-based response (partial rendering) if (data.fragments) { // For fragments, always use the component element as container this.updateFragments(config.element, data.fragments); } // Handle full HTML response (fallback or no fragments requested) else if (data.html) { // Use swap strategy if specified, otherwise default to innerHTML if (swapStrategy === 'innerHTML') { // Default behavior - maintain backwards compatibility targetElement.innerHTML = data.html; } else { // Use swapElement for other strategies with transitions and scroll const success = this.domPatcher.swapElement(targetElement, data.html, swapStrategy, transition, scrollOptions); if (!success) { console.warn(`[LiveComponent] Swap failed, falling back to innerHTML`); targetElement.innerHTML = data.html; } } // Re-setup action handlers on the updated element // For outerHTML swap, targetElement might be replaced, so we need to find it again if (swapStrategy === 'outerHTML') { // Find the new element if it was replaced const newElement = document.querySelector(`[data-live-component="${componentId}"]`); if (newElement) { this.setupActionHandlers(newElement); } else { // Fallback: setup handlers on parent or document this.setupActionHandlers(targetElement.parentElement || document.body); } } else { this.setupActionHandlers(targetElement); } } // Re-initialize tooltips after DOM update // Use targetElement for tooltips (might differ from config.element if target was used) this.tooltipManager.initComponent(targetElement); // Update component state using StateSerializer if (data.state) { StateSerializer.setStateOnElement(config.element, data.state); } // Handle server events if (data.events && data.events.length > 0) { this.handleServerEvents(data.events); } // Handle URL updates (data-lc-push-url or data-lc-replace-url) const pushUrl = actionElement?.dataset.lcPushUrl; const replaceUrl = actionElement?.dataset.lcReplaceUrl; if (pushUrl) { urlManager.pushUrl(pushUrl, data.title); } else if (replaceUrl) { urlManager.replaceUrl(replaceUrl, data.title); } console.log(`[LiveComponent] Action executed: ${componentId}.${method}`, data); // Log successful action to DevTools const endTime = performance.now(); this.logActionToDevTools(componentId, method, params, startTime, endTime, true); // Cache successful result this.requestDeduplicator.cacheResult(componentId, method, params, data); // Hide loading state this.loadingStateManager.hideLoading(componentId, actionElement); return data; } catch (error) { console.error(`[LiveComponent] Action failed:`, error); // Log failed action to DevTools const endTime = performance.now(); this.logActionToDevTools(componentId, method, params, startTime, endTime, false, error.message); // Rollback optimistic update on error if (operationId) { const snapshot = optimisticStateManager.getSnapshot(componentId); if (snapshot) { StateSerializer.setStateOnElement(config.element, snapshot); optimisticStateManager.clearPendingOperations(componentId); optimisticStateManager.clearSnapshot(componentId); } } // Hide loading state on error this.loadingStateManager.hideLoading(componentId, null); // Handle error via ErrorBoundary await this.errorBoundary.handleError(componentId, method, error, { params, fragments }); throw error; } })(); // Register pending request for deduplication return this.requestDeduplicator.registerPendingRequest(componentId, method, params, requestPromise); } /** * Resolve target element for action updates * * Supports data-lc-target attribute to specify where updates should be applied. * Falls back to component element if no target is specified or target not found. * * @param {HTMLElement} actionElement - Element that triggered the action * @param {string} componentId - Component ID * @returns {HTMLElement|null} - Target element or null if not found */ resolveTarget(actionElement, componentId) { const targetSelector = actionElement?.dataset.lcTarget; // If no target specified, use component element if (!targetSelector) { const config = this.components.get(componentId); return config?.element || null; } // Try to find target element let targetElement = null; // First try: querySelector from document targetElement = document.querySelector(targetSelector); // Second try: querySelector from action element's closest component if (!targetElement && actionElement) { const componentElement = actionElement.closest(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`); if (componentElement) { targetElement = componentElement.querySelector(targetSelector); } } // Fallback: use component element if target not found if (!targetElement) { console.warn(`[LiveComponent] Target element not found: ${targetSelector}, falling back to component element`); const config = this.components.get(componentId); return config?.element || null; } return targetElement; } /** * Update specific fragments using DOM patching * * Efficiently patches only the fragments that changed. * Includes accessibility features: focus management and ARIA announcements. * * @param {HTMLElement} container - Component container element * @param {Object.} fragments - Map of fragment names to HTML */ updateFragments(container, fragments) { const componentId = container.dataset.liveComponent; // Capture focus state before patching (with data-lc-keep-focus support) if (componentId) { this.accessibilityManager.captureFocusState(componentId, container); } // Patch each fragment const results = this.domPatcher.patchFragments(container, fragments); // Restore focus after patching (respects data-lc-keep-focus) if (componentId) { this.accessibilityManager.restoreFocus(componentId, container); } // Re-setup action handlers for patched fragments Object.keys(fragments).forEach(fragmentName => { if (results[fragmentName]) { const fragmentElement = container.querySelector(`[data-lc-fragment="${fragmentName}"]`); if (fragmentElement) { this.setupActionHandlers(fragmentElement); } } }); // Announce update to screen readers if (componentId) { const fragmentNames = Object.keys(fragments).filter(name => results[name]); if (fragmentNames.length > 0) { this.accessibilityManager.announceUpdate(componentId, 'fragment', { fragmentName: fragmentNames.join(', ') }); } } // Log patching results const successCount = Object.values(results).filter(Boolean).length; console.log(`[LiveComponent] Patched ${successCount}/${Object.keys(fragments).length} fragments`, results); } /** * Handle server-sent events */ handleServerEvents(events) { events.forEach(event => { const { name, payload, target } = event; console.log(`[LiveComponent] Server event:`, { name, payload, target }); // Log event to DevTools this.logEventToDevTools(name, { payload, target }, 'server'); // Convert payload to object if it's an array (from EventPayload) const eventPayload = Array.isArray(payload) ? payload : (payload || {}); if (target) { // Targeted event - only for specific component this.dispatchToComponent(target, name, eventPayload); } else { // Broadcast event - to all listening components this.broadcast(name, eventPayload); } }); } /** * Dispatch event to specific component */ dispatchToComponent(targetComponentId, eventName, payload) { const handlers = this.eventHandlers.get(eventName) || []; handlers.forEach(handler => { if (handler.componentId === targetComponentId) { handler.callback(payload); } }); // Also dispatch as custom DOM event document.dispatchEvent(new CustomEvent(`livecomponent:${eventName}`, { detail: payload })); // Dispatch direct event for UI events if (this.isUIEvent(eventName)) { document.dispatchEvent(new CustomEvent(eventName, { detail: payload })); } } /** * Dispatch component event (used for batch operations) * @param {string} componentId - Component ID * @param {string} eventName - Event name * @param {Object} payload - Event payload */ dispatchComponentEvent(componentId, eventName, payload) { // Convert payload to object if needed const eventPayload = Array.isArray(payload) ? payload : (payload || {}); // Dispatch to component-specific handlers this.dispatchToComponent(componentId, eventName, eventPayload); // Also broadcast if no target specified (for UI events) if (this.isUIEvent(eventName)) { this.broadcast(eventName, eventPayload); } } /** * Broadcast event to all listeners */ broadcast(eventName, payload) { const handlers = this.eventHandlers.get(eventName) || []; handlers.forEach(handler => { handler.callback(payload); }); // Dispatch as custom DOM event with livecomponent: prefix for compatibility document.dispatchEvent(new CustomEvent(`livecomponent:${eventName}`, { detail: payload })); // Also dispatch direct event (without prefix) for UI events // This allows UIEventHandler to listen to toast:show, modal:show, etc. if (this.isUIEvent(eventName)) { document.dispatchEvent(new CustomEvent(eventName, { detail: payload })); } } /** * Check if event name is a UI event * @param {string} eventName - Event name * @returns {boolean} */ isUIEvent(eventName) { const uiEventPrefixes = ['toast:', 'modal:']; return uiEventPrefixes.some(prefix => eventName.startsWith(prefix)); } /** * Register event listener */ on(componentId, eventName, callback) { if (!this.eventHandlers.has(eventName)) { this.eventHandlers.set(eventName, []); } this.eventHandlers.get(eventName).push({ componentId, callback }); console.log(`[LiveComponent] Event listener registered: ${componentId} -> ${eventName}`); } /** * Start polling for component */ startPolling(componentId) { const config = this.components.get(componentId); if (!config || !config.pollInterval) return; // Clear existing timer if (config.pollTimer) { clearInterval(config.pollTimer); } // Start new polling interval config.pollTimer = setInterval(async () => { await this.executeAction(componentId, 'poll', {}); }, config.pollInterval); console.log(`[LiveComponent] Polling started: ${componentId} (${config.pollInterval}ms)`); } /** * Stop polling for component */ stopPolling(componentId) { const config = this.components.get(componentId); if (!config || !config.pollTimer) return; clearInterval(config.pollTimer); config.pollTimer = null; console.log(`[LiveComponent] Polling stopped: ${componentId}`); } // Note: getCSRFToken() removed - components now have their own tokens in data-csrf-token /** * Error handler */ handleError(componentId, error) { const config = this.components.get(componentId); if (!config) return; // Show error in component const errorHtml = `
LiveComponent Error: ${error.message}
`; config.element.insertAdjacentHTML('beforeend', errorHtml); // Auto-remove error after 5 seconds setTimeout(() => { config.element.querySelector('.livecomponent-error')?.remove(); }, 5000); } /** * Show conflict notification * * Displays a user-friendly notification when version conflicts occur during optimistic updates. * * @param {string} componentId - Component identifier * @param {Object} notification - Notification object from OptimisticStateManager */ showConflictNotification(componentId, notification) { const config = this.components.get(componentId); if (!config) return; // Create conflict notification HTML const notificationHtml = `
${notification.title}

${notification.message}

${notification.canRetry ? ` ` : ''}
`; // Insert notification at the top of component config.element.insertAdjacentHTML('afterbegin', notificationHtml); // Setup retry button if applicable if (notification.canRetry) { const retryButton = config.element.querySelector('.livecomponent-retry'); if (retryButton) { retryButton.addEventListener('click', async () => { // Remove notification config.element.querySelector('.livecomponent-conflict')?.remove(); // Retry the operation await optimisticStateManager.retryOperation( componentId, notification.operation, async (metadata) => { return await this.executeAction( componentId, metadata.action, metadata.params ); } ); }); } } // Auto-remove notification after 8 seconds setTimeout(() => { config.element.querySelector('.livecomponent-conflict')?.remove(); }, 8000); console.log(`[LiveComponent] Conflict notification shown for ${componentId}`, notification); } /** * Setup file upload handlers */ setupFileUploadHandlers(element) { const componentId = element.dataset.liveComponent; // Setup file input handlers element.querySelectorAll('input[type="file"][data-live-upload]').forEach(input => { input.addEventListener('change', async (e) => { const files = e.target.files; if (files.length > 0) { await this.uploadFile(componentId, files[0]); } }); }); // Setup drag & drop zones element.querySelectorAll('[data-live-dropzone]').forEach(dropzone => { this.setupDropzone(componentId, dropzone); }); } /** * Setup drag & drop zone */ setupDropzone(componentId, dropzone) { // Prevent default drag behaviors ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropzone.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }); }); // Highlight drop zone on drag over ['dragenter', 'dragover'].forEach(eventName => { dropzone.addEventListener(eventName, () => { dropzone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(eventName => { dropzone.addEventListener(eventName, () => { dropzone.classList.remove('drag-over'); }); }); // Handle file drop dropzone.addEventListener('drop', async (e) => { const files = e.dataTransfer.files; if (files.length > 0) { await this.uploadFile(componentId, files[0]); } }); } /** * Upload file to component */ async uploadFile(componentId, file, params = {}) { const config = this.components.get(componentId); if (!config) { console.error(`[LiveComponent] Unknown component: ${componentId}`); return; } try { // Get current state const stateJson = config.element.dataset.state || '{}'; const state = JSON.parse(stateJson); // Create FormData for file upload const formData = new FormData(); formData.append('file', file); formData.append('state', JSON.stringify(state)); formData.append('params', JSON.stringify(params)); // Get component-specific CSRF token const csrfToken = config.element.dataset.csrfToken; if (!csrfToken) { console.error(`[LiveComponent] Missing CSRF token for upload: ${componentId}`); throw new Error('CSRF token is required for file uploads'); } // Add CSRF token to form data formData.append('_csrf_token', csrfToken); // Show upload progress (optional) this.showUploadProgress(componentId, 0); // Send upload request const response = await fetch(`/live-component/${componentId}/upload`, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: formData }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Upload failed'); } const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Upload failed'); } // Update component HTML if (data.html) { config.element.innerHTML = data.html; this.setupActionHandlers(config.element); this.setupFileUploadHandlers(config.element); } // Update component state using StateSerializer if (data.state) { StateSerializer.setStateOnElement(config.element, data.state); } // Handle server events if (data.events && data.events.length > 0) { this.handleServerEvents(data.events); } // Hide upload progress this.hideUploadProgress(componentId); console.log(`[LiveComponent] File uploaded: ${componentId}`, data.file); // Dispatch upload success event document.dispatchEvent(new CustomEvent('livecomponent:upload:success', { detail: { componentId, file: data.file } })); } catch (error) { console.error(`[LiveComponent] Upload failed:`, error); this.hideUploadProgress(componentId); this.handleError(componentId, error); // Dispatch upload error event document.dispatchEvent(new CustomEvent('livecomponent:upload:error', { detail: { componentId, error: error.message } })); } } /** * Show upload progress indicator */ showUploadProgress(componentId, percent) { const config = this.components.get(componentId); if (!config) return; let progressEl = config.element.querySelector('.livecomponent-upload-progress'); if (!progressEl) { progressEl = document.createElement('div'); progressEl.className = 'livecomponent-upload-progress'; progressEl.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; height: 3px; background: #e0e0e0; z-index: 1000; `; const bar = document.createElement('div'); bar.className = 'progress-bar'; bar.style.cssText = ` height: 100%; background: #2196F3; transition: width 0.3s ease; width: ${percent}%; `; progressEl.appendChild(bar); config.element.style.position = 'relative'; config.element.appendChild(progressEl); } else { const bar = progressEl.querySelector('.progress-bar'); bar.style.width = `${percent}%`; } } /** * Hide upload progress indicator */ hideUploadProgress(componentId) { const config = this.components.get(componentId); if (!config) return; const progressEl = config.element.querySelector('.livecomponent-upload-progress'); if (progressEl) { progressEl.remove(); } } /** * Destroy component instance * Clean up timers, observers, event handlers, SSE connections, and accessibility features */ destroy(componentId) { const config = this.components.get(componentId); // Stop polling this.stopPolling(componentId); // Disconnect MutationObserver if (config && config.observer) { config.observer.disconnect(); } // Cleanup SSE connection this.cleanupSseConnection(componentId); // Cleanup accessibility features this.accessibilityManager.cleanup(componentId); // Cleanup error handler this.errorBoundary.clearErrorHandler(componentId); // Cleanup request deduplication this.requestDeduplicator.clearComponent(componentId); // Cleanup tooltips this.tooltipManager.cleanupComponent(config.element); // Hide any active loading states this.loadingStateManager.hideLoading(componentId, null); this.loadingStateManager.clearConfig(componentId); // Cleanup UI components this.uiHelper.cleanup(componentId); // Remove from registry this.components.delete(componentId); // Remove event handlers for this component this.eventHandlers.forEach((handlers, eventName) => { const filtered = handlers.filter(h => h.componentId !== componentId); this.eventHandlers.set(eventName, filtered); }); console.log(`[LiveComponent] Destroyed: ${componentId}`); } /** * Cleanup SSE connection when component is destroyed */ cleanupSseConnection(componentId) { const channel = this.componentChannels.get(componentId); if (!channel) { return; // No SSE connection for this component } // Remove component-channel mapping this.componentChannels.delete(componentId); // Check if any other components are using this channel let channelStillInUse = false; for (const [, componentChannel] of this.componentChannels) { if (componentChannel === channel) { channelStillInUse = true; break; } } // If no other components use this channel, disconnect the SSE client if (!channelStillInUse) { const sseClient = this.sseClients.get(channel); if (sseClient) { sseClient.disconnect(); this.sseClients.delete(channel); console.log(`[LiveComponent] Disconnected SSE client for channel: ${channel}`); } } } /** * Execute batch of operations * * Sends multiple component actions in a single HTTP request. * Reduces HTTP overhead by 60-80% for multi-component updates. * * @param {Array} operations - Array of operation objects * @param {Object} options - Batch options * @returns {Promise} Batch response with results * * Example: * await LiveComponent.executeBatch([ * { * componentId: 'counter:demo', * method: 'increment', * params: { amount: 5 }, * fragments: ['counter-display'], * operationId: 'op-1' * }, * { * componentId: 'user-stats:123', * method: 'refresh', * operationId: 'op-2' * } * ]); */ async executeBatch(operations, options = {}) { if (!Array.isArray(operations) || operations.length === 0) { throw new Error('Operations must be a non-empty array'); } // Build batch request const batchRequest = { operations: operations.map(op => ({ componentId: op.componentId, method: op.method, params: op.params || {}, fragments: op.fragments || null, operationId: op.operationId || null })) }; console.log(`[LiveComponent] Executing batch with ${operations.length} operations`, batchRequest); try { const response = await fetch('/live-component/batch', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(batchRequest) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Batch request failed'); } const batchResponse = await response.json(); console.log(`[LiveComponent] Batch completed: ${batchResponse.success_count} succeeded, ${batchResponse.failure_count} failed`); // Process results and update components if (options.autoApply !== false) { await this.applyBatchResults(batchResponse.results); } return batchResponse; } catch (error) { console.error('[LiveComponent] Batch execution failed:', error); throw error; } } /** * Apply batch results to components * * Updates component HTML/fragments and state based on batch response. * * @param {Array} results - Batch results array */ async applyBatchResults(results) { for (const result of results) { if (!result.success) { console.warn(`[LiveComponent] Batch operation failed:`, result); continue; } // Extract componentId from operationId or results // (We need to track which component each result belongs to) const componentId = result.componentId || this.extractComponentIdFromResult(result); if (!componentId) continue; const config = this.components.get(componentId); if (!config) continue; try { // Update fragments if present if (result.fragments) { this.updateFragments(config.element, result.fragments); } // Update full HTML if present else if (result.html) { config.element.innerHTML = result.html; this.setupActionHandlers(config.element); } // Update state using StateSerializer if (result.state) { StateSerializer.setStateOnElement(config.element, result.state); } // Dispatch events if (result.events && Array.isArray(result.events)) { result.events.forEach(event => { this.dispatchComponentEvent(componentId, event.name, event.payload || {}); }); } } catch (error) { console.error(`[LiveComponent] Failed to apply batch result for ${componentId}:`, error); } } } /** * Helper to extract component ID from batch result * (Since we don't store componentId in result, we need to track it) */ extractComponentIdFromResult(result) { // This is a placeholder - in production you'd track componentId per operation // For now, we rely on the caller to provide componentId in custom data return result.componentId || null; } /** * Queue batch operation * * Allows batching multiple operations with automatic flush. * Useful for collecting multiple updates that happen close together. * * @param {Object} operation - Operation to queue * @param {Object} options - Queue options (flushDelay) */ queueBatchOperation(operation, options = {}) { if (!this.batchQueue) { this.batchQueue = []; this.batchQueueTimer = null; } this.batchQueue.push(operation); // Clear existing timer if (this.batchQueueTimer) { clearTimeout(this.batchQueueTimer); } // Auto-flush after delay const flushDelay = options.flushDelay || 50; // 50ms default this.batchQueueTimer = setTimeout(() => { this.flushBatchQueue(); }, flushDelay); } /** * Flush batch queue * * Executes all queued operations in a single batch request. */ async flushBatchQueue() { if (!this.batchQueue || this.batchQueue.length === 0) { return; } const operations = [...this.batchQueue]; this.batchQueue = []; this.batchQueueTimer = null; try { await this.executeBatch(operations); } catch (error) { console.error('[LiveComponent] Batch queue flush failed:', error); } } } // Create global LiveComponent instance const LiveComponent = new LiveComponentManager(); // Make LiveComponent globally accessible window.LiveComponent = LiveComponent; // Auto-initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`).forEach(el => { LiveComponent.init(el); }); }); } // Framework module definition export const definition = { name: 'livecomponent', version: '1.0.0', dependencies: [], provides: ['LiveComponent'], priority: 0 }; // Framework module initialization (global setup) export async function init(config = {}, state = {}) { console.log('[LiveComponent] Module initializing...'); // Initialize Accessibility Manager (global live regions) accessibilityManager.initialize(); // Initialize Lazy Loading system first LiveComponent.initLazyLoading(); // Initialize Nested Components system LiveComponent.initNestedComponents(); // Initialize all regular LiveComponents in the page // This is needed when loaded as a core module (not via data-module attributes) const components = document.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`); console.log(`[LiveComponent] Found ${components.length} regular components to initialize`); components.forEach(el => { LiveComponent.init(el); }); // Lazy components are automatically initialized by LazyComponentLoader const lazyComponents = document.querySelectorAll(`[${LiveComponentLazyAttributes.LIVE_COMPONENT_LAZY}]`); console.log(`[LiveComponent] Found ${lazyComponents.length} lazy components (will load on demand)`); // Log nested component stats if (LiveComponent.nestedHandler) { const stats = LiveComponent.nestedHandler.getStats(); console.log(`[LiveComponent] Nested components: ${stats.child_components} children across ${stats.root_components} roots (max depth: ${stats.max_nesting_depth})`); } // Log accessibility stats const a11yStats = accessibilityManager.getStats(); console.log(`[LiveComponent] Accessibility: ${a11yStats.component_live_regions} component regions, ${a11yStats.tracked_focus_states} tracked focus states`); console.log('[LiveComponent] Module initialized'); return { manager: LiveComponent, state }; } // Framework DOM element initialization (called by framework for data-module elements) export function initElement(element, options = {}) { console.log('[LiveComponent] Initializing element with data-module:', element); // Find all LiveComponents within this element (or the element itself) const components = element.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`); if (components.length === 0 && element.hasAttribute('data-live-component')) { // Element itself is a LiveComponent LiveComponent.init(element); } else { // Initialize all child LiveComponents components.forEach(el => LiveComponent.init(el)); } return LiveComponent; } // Export LiveComponent class for direct use export { LiveComponent }; export { ComponentPlayground }; export { ComponentFileUploader } from './ComponentFileUploader.js'; export { FileUploadWidget } from './FileUploadWidget.js'; export { ChunkedUploader } from './ChunkedUploader.js'; // Export UI integration modules export { tooltipManager } from './TooltipManager.js'; export { actionLoadingManager } from './ActionLoadingManager.js'; export { LiveComponentUIHelper } from './LiveComponentUIHelper.js'; export { UIEventHandler } from './UIEventHandler.js'; export { LoadingStateManager } from './LoadingStateManager.js'; export default LiveComponent;