/** * Lazy Component Loader * * Loads LiveComponents only when they enter the viewport using Intersection Observer API. * Provides performance optimization for pages with many components. * * Features: * - Viewport-based loading with configurable thresholds * - Placeholder management during loading * - Progressive loading with priorities * - Memory-efficient intersection tracking * - Automatic cleanup and disconnection * * Usage: *
*
*/ export class LazyComponentLoader { constructor(liveComponentManager) { this.liveComponentManager = liveComponentManager; this.observer = null; this.lazyComponents = new Map(); // element → config this.loadingQueue = []; // Priority-based loading queue this.isProcessingQueue = false; this.defaultOptions = { rootMargin: '50px', // Load 50px before entering viewport threshold: 0.1 // Trigger when 10% visible }; } /** * Initialize lazy loading system */ init() { // Create Intersection Observer this.observer = new IntersectionObserver( (entries) => this.handleIntersection(entries), this.defaultOptions ); // Scan for lazy components this.scanLazyComponents(); console.log(`[LazyLoader] Initialized with ${this.lazyComponents.size} lazy components`); } /** * Scan DOM for lazy components */ scanLazyComponents() { const lazyElements = document.querySelectorAll('[data-live-component-lazy]'); lazyElements.forEach(element => { this.registerLazyComponent(element); }); } /** * Register a lazy component for loading * * @param {HTMLElement} element - Component container */ registerLazyComponent(element) { const componentId = element.dataset.liveComponentLazy; if (!componentId) { console.warn('[LazyLoader] Lazy component missing componentId:', element); return; } // Extract configuration const config = { element, componentId, threshold: parseFloat(element.dataset.lazyThreshold) || this.defaultOptions.threshold, priority: element.dataset.lazyPriority || 'normal', // high, normal, low placeholder: element.dataset.lazyPlaceholder || null, loaded: false, loading: false }; // Show placeholder if (config.placeholder) { this.showPlaceholder(element, config.placeholder); } // Store config this.lazyComponents.set(element, config); // Start observing this.observer.observe(element); console.log(`[LazyLoader] Registered lazy component: ${componentId}`); } /** * Handle intersection observer callback * * @param {Array} entries - Intersection entries */ handleIntersection(entries) { entries.forEach(entry => { if (entry.isIntersecting) { const config = this.lazyComponents.get(entry.target); if (config && !config.loaded && !config.loading) { this.queueComponentLoad(config); } } }); } /** * Queue component for loading with priority * * @param {Object} config - Component config */ queueComponentLoad(config) { const priorityWeight = this.getPriorityWeight(config.priority); this.loadingQueue.push({ config, priority: priorityWeight, timestamp: Date.now() }); // Sort by priority (high to low) and then by timestamp (early to late) this.loadingQueue.sort((a, b) => { if (b.priority !== a.priority) { return b.priority - a.priority; } return a.timestamp - b.timestamp; }); console.log(`[LazyLoader] Queued: ${config.componentId} (priority: ${config.priority})`); // Process queue this.processLoadingQueue(); } /** * Get numeric weight for priority * * @param {string} priority - Priority level (high, normal, low) * @returns {number} Priority weight */ getPriorityWeight(priority) { const weights = { 'high': 3, 'normal': 2, 'low': 1 }; return weights[priority] || 2; } /** * Process loading queue * * Loads components sequentially to avoid overloading server. */ async processLoadingQueue() { if (this.isProcessingQueue || this.loadingQueue.length === 0) { return; } this.isProcessingQueue = true; while (this.loadingQueue.length > 0) { const { config } = this.loadingQueue.shift(); if (!config.loaded && !config.loading) { await this.loadComponent(config); } } this.isProcessingQueue = false; } /** * Load component from server * * @param {Object} config - Component config */ async loadComponent(config) { config.loading = true; try { console.log(`[LazyLoader] Loading: ${config.componentId}`); // Show loading indicator this.showLoadingIndicator(config.element); // Request component HTML from server const response = await fetch(`/live-component/${config.componentId}/lazy-load`, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to load component'); } // Replace placeholder with component HTML config.element.innerHTML = data.html; // Mark element as regular LiveComponent (no longer lazy) config.element.setAttribute('data-live-component', config.componentId); config.element.removeAttribute('data-live-component-lazy'); // Copy CSRF token to element if (data.csrf_token) { config.element.dataset.csrfToken = data.csrf_token; } // Copy state to element if (data.state) { config.element.dataset.state = JSON.stringify(data.state); } // Initialize as regular LiveComponent this.liveComponentManager.init(config.element); // Mark as loaded config.loaded = true; config.loading = false; // Stop observing this.observer.unobserve(config.element); console.log(`[LazyLoader] Loaded: ${config.componentId}`); // Dispatch custom event config.element.dispatchEvent(new CustomEvent('livecomponent:lazy:loaded', { detail: { componentId: config.componentId } })); } catch (error) { console.error(`[LazyLoader] Failed to load ${config.componentId}:`, error); config.loading = false; // Show error state this.showError(config.element, error.message); // Dispatch error event config.element.dispatchEvent(new CustomEvent('livecomponent:lazy:error', { detail: { componentId: config.componentId, error: error.message } })); } } /** * Show placeholder * * @param {HTMLElement} element - Container element * @param {string} text - Placeholder text */ showPlaceholder(element, text) { element.innerHTML = `
${text}
`; } /** * Show loading indicator * * @param {HTMLElement} element - Container element */ showLoadingIndicator(element) { element.innerHTML = `
Loading component...
`; } /** * Show error state * * @param {HTMLElement} element - Container element * @param {string} errorMessage - Error message */ showError(element, errorMessage) { element.innerHTML = `
Failed to load component
${errorMessage}
`; } /** * Unregister lazy component and stop observing * * @param {HTMLElement} element - Component element */ unregister(element) { const config = this.lazyComponents.get(element); if (!config) return; // Stop observing this.observer.unobserve(element); // Remove from registry this.lazyComponents.delete(element); console.log(`[LazyLoader] Unregistered: ${config.componentId}`); } /** * Destroy lazy loader * * Clean up all observers and references. */ destroy() { // Disconnect observer if (this.observer) { this.observer.disconnect(); this.observer = null; } // Clear queue this.loadingQueue = []; // Clear registry this.lazyComponents.clear(); console.log('[LazyLoader] Destroyed'); } /** * Get lazy loading statistics * * @returns {Object} Statistics */ getStats() { let loaded = 0; let loading = 0; let pending = 0; this.lazyComponents.forEach(config => { if (config.loaded) loaded++; else if (config.loading) loading++; else pending++; }); return { total: this.lazyComponents.size, loaded, loading, pending, queued: this.loadingQueue.length }; } } // Export for use in LiveComponent module export default LazyComponentLoader;