Files
michaelschiemer/resources/js/modules/livecomponent/index.js
2025-11-24 21:28:25 +01:00

2145 lines
82 KiB
JavaScript

/**
* 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<string>|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<string>} 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.<string, string>} 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 = `
<div class="livecomponent-error" style="padding: 1rem; background: #fee; color: #c00; border: 1px solid #faa; border-radius: 4px;">
<strong>LiveComponent Error:</strong> ${error.message}
</div>
`;
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 = `
<div class="livecomponent-conflict" style="
padding: 1rem;
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
border-radius: 4px;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
">
<div>
<strong>${notification.title}</strong>
<p style="margin: 0.5rem 0 0 0;">${notification.message}</p>
</div>
${notification.canRetry ? `
<button
class="livecomponent-retry"
style="
padding: 0.5rem 1rem;
background: #ffc107;
color: #000;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: 1rem;
"
data-operation-id="${notification.operation.id}"
data-action="${notification.operation.metadata.action}"
>
Retry
</button>
` : ''}
</div>
`;
// 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<Object>} operations - Array of operation objects
* @param {Object} options - Batch options
* @returns {Promise<Object>} 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<Object>} 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;