Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
1693 lines
59 KiB
JavaScript
1693 lines
59 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';
|
|
|
|
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<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
|
|
*/
|
|
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.<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');
|
|
|
|
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 = `
|
|
<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);
|
|
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('[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;
|