/** * 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'; 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); // Loading State Manager this.loadingStateManager = new LoadingStateManager( this.actionLoadingManager, optimisticStateManager ); } /** * 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 */ init(element) { const componentId = element.dataset.liveComponent; if (!componentId) return; const config = { id: componentId, element, pollInterval: parseInt(element.dataset.pollInterval) || null, pollTimer: null, observer: null }; 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 this.setupSseConnection(element, componentId); // Setup accessibility features this.setupAccessibility(componentId, element); // Initialize tooltips for component this.tooltipManager.initComponent(element); console.log(`[LiveComponent] Initialized: ${componentId}`); } /** * Setup accessibility features for component */ setupAccessibility(componentId, element) { // Create component-specific ARIA live region const politeness = element.dataset.livePolite === '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) { // Check if component has SSE channel const sseChannel = element.dataset.sseChannel; 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('[data-live-action]').forEach(actionEl => { // Handle form submissions if (actionEl.tagName === 'FORM') { actionEl.addEventListener('submit', async (e) => { e.preventDefault(); const componentId = element.dataset.liveComponent; const action = actionEl.dataset.liveAction; // 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); }); } // 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; actionEl.addEventListener('input', async (e) => { const componentId = element.dataset.liveComponent; const action = actionEl.dataset.liveAction; 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); } else { await this.executeAction(componentId, action, params, fragments); } }); // For radio buttons and checkboxes, also listen to 'change' event if (actionEl.type === 'radio' || actionEl.type === 'checkbox') { actionEl.addEventListener('change', async (e) => { const componentId = element.dataset.liveComponent; const action = actionEl.dataset.liveAction; 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); }); } } else { // Handle button clicks actionEl.addEventListener('click', async (e) => { e.preventDefault(); const componentId = element.dataset.liveComponent; const action = actionEl.dataset.liveAction; 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); }); } }); } /** * Debounced action execution */ debouncedAction(componentId, action, params, delay, fragments = 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); 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 */ async executeAction(componentId, method, params = {}, fragments = 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(); // Show loading state (uses LoadingStateManager for configurable indicators) const loadingConfig = this.loadingStateManager.getConfig(componentId); this.loadingStateManager.showLoading(componentId, config.element, { fragments, type: loadingConfig.type, optimistic: true // Check optimistic UI first }); // Create request promise const requestPromise = (async () => { try { // Get current state from element using StateSerializer const stateWrapper = StateSerializer.getStateFromElement(config.element) || { 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 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; // Get component-specific CSRF token from element const csrfToken = config.element.dataset.csrfToken; if (!csrfToken) { console.error(`[LiveComponent] Missing CSRF token for component: ${componentId}`); throw new Error('CSRF token is required for component actions'); } // Build request body const requestBody = { method, params, state, _csrf_token: csrfToken }; // Add fragments parameter if specified if (fragments && Array.isArray(fragments) && fragments.length > 0) { requestBody.fragments = fragments; } // Send AJAX request with CSRF token in body // Component ID is in the URL, not in the body const response = await fetch(`/live-component/${componentId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify(requestBody) }); 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 currentVersion = stateWrapper.version || 1; 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); } } // Handle fragment-based response (partial rendering) if (data.fragments) { this.updateFragments(config.element, data.fragments); } // Handle full HTML response (fallback or no fragments requested) else if (data.html) { config.element.innerHTML = data.html; this.setupActionHandlers(config.element); } // Re-initialize tooltips after DOM update this.tooltipManager.initComponent(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); } 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); 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); // 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); } /** * 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'); if (target) { // Targeted event - only for specific component this.dispatchToComponent(target, name, payload); } else { // Broadcast event - to all listening components this.broadcast(name, payload); } }); } /** * 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); } }); } /** * Broadcast event to all listeners */ broadcast(eventName, payload) { const handlers = this.eventHandlers.get(eventName) || []; handlers.forEach(handler => { handler.callback(payload); }); // Also dispatch as custom DOM event document.dispatchEvent(new CustomEvent(`livecomponent:${eventName}`, { detail: payload })); } /** * 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); 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('[data-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('[data-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('[data-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('[data-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 { LoadingStateManager } from './LoadingStateManager.js'; export default LiveComponent;