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

524 lines
16 KiB
JavaScript

/**
* 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:
* <div data-live-component-lazy="notification-center:user-123"
* data-lazy-threshold="0.1"
* data-lazy-priority="high"
* data-lazy-placeholder="Loading notifications...">
* </div>
*/
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() {
// Scan for regular lazy components
const lazyElements = document.querySelectorAll('[data-live-component-lazy]');
lazyElements.forEach(element => {
this.registerLazyComponent(element);
});
// Scan for Island components (both lazy and non-lazy)
const islandElements = document.querySelectorAll('[data-island-component]');
islandElements.forEach(element => {
if (element.dataset.liveComponentLazy) {
// Lazy Island - register for lazy loading
this.registerLazyComponent(element);
} else if (element.dataset.liveComponentIsland) {
// Non-lazy Island - load immediately
this.loadIslandComponentImmediately(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<IntersectionObserverEntry>} 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);
// Check if this is an Island component
const isIsland = config.element.dataset.islandComponent === 'true';
const endpoint = isIsland
? `/live-component/${config.componentId}/island`
: `/live-component/${config.componentId}/lazy-load`;
// Request component HTML from server
const response = await fetch(endpoint, {
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');
// Keep island-component attribute for isolated initialization
if (isIsland) {
config.element.setAttribute('data-island-component', 'true');
}
// Copy CSRF token to element
if (data.csrf_token) {
config.element.dataset.csrfToken = data.csrf_token;
}
// Copy state to element
if (data.state) {
// Use StateSerializer for type-safe state handling
const stateJson = JSON.stringify(data.state);
config.element.dataset.state = stateJson;
}
// Initialize as regular LiveComponent (or Island if isolated)
this.liveComponentManager.init(config.element, { isolated: isIsland });
// Mark as loaded
config.loaded = true;
config.loading = false;
// Stop observing
this.observer.unobserve(config.element);
console.log(`[LazyLoader] Loaded: ${config.componentId}${isIsland ? ' (Island)' : ''}`);
// Dispatch custom event
config.element.dispatchEvent(new CustomEvent('livecomponent:lazy:loaded', {
detail: { componentId: config.componentId, isIsland }
}));
} 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
}
}));
}
}
/**
* Load Island component immediately (non-lazy)
*
* @param {HTMLElement} element - Island component element
*/
async loadIslandComponentImmediately(element) {
const componentId = element.dataset.liveComponentIsland;
if (!componentId) {
console.warn('[LazyLoader] Island component missing componentId:', element);
return;
}
try {
console.log(`[LazyLoader] Loading Island immediately: ${componentId}`);
// Show loading indicator
this.showLoadingIndicator(element);
// Request component HTML from Island endpoint
const response = await fetch(`/live-component/${componentId}/island`, {
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 Island component');
}
// Replace placeholder with component HTML
element.innerHTML = data.html;
// Mark element as Island component
element.setAttribute('data-live-component', componentId);
element.setAttribute('data-island-component', 'true');
element.removeAttribute('data-live-component-island');
// Copy CSRF token to element
if (data.csrf_token) {
element.dataset.csrfToken = data.csrf_token;
}
// Copy state to element
if (data.state) {
const stateJson = JSON.stringify(data.state);
element.dataset.state = stateJson;
}
// Initialize as isolated Island component
this.liveComponentManager.init(element, { isolated: true });
console.log(`[LazyLoader] Loaded Island: ${componentId}`);
// Dispatch custom event
element.dispatchEvent(new CustomEvent('livecomponent:island:loaded', {
detail: { componentId }
}));
} catch (error) {
console.error(`[LazyLoader] Failed to load Island ${componentId}:`, error);
// Show error state
this.showError(element, error.message);
// Dispatch error event
element.dispatchEvent(new CustomEvent('livecomponent:island:error', {
detail: {
componentId,
error: error.message
}
}));
}
}
/**
* Show placeholder
*
* @param {HTMLElement} element - Container element
* @param {string} text - Placeholder text
*/
showPlaceholder(element, text) {
element.innerHTML = `
<div class="livecomponent-lazy-placeholder" style="
padding: 2rem;
text-align: center;
color: #666;
background: #f5f5f5;
border-radius: 8px;
border: 1px dashed #ddd;
">
<div style="font-size: 1.5rem; margin-bottom: 0.5rem;">⏳</div>
<div>${text}</div>
</div>
`;
}
/**
* Show loading indicator
*
* @param {HTMLElement} element - Container element
*/
showLoadingIndicator(element) {
element.innerHTML = `
<div class="livecomponent-lazy-loading" style="
padding: 2rem;
text-align: center;
color: #2196F3;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #e3f2fd;
">
<div class="spinner" style="
width: 40px;
height: 40px;
margin: 0 auto 1rem;
border: 4px solid #e3f2fd;
border-top: 4px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
"></div>
<div>Loading component...</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
`;
}
/**
* Show error state
*
* @param {HTMLElement} element - Container element
* @param {string} errorMessage - Error message
*/
showError(element, errorMessage) {
element.innerHTML = `
<div class="livecomponent-lazy-error" style="
padding: 2rem;
text-align: center;
color: #d32f2f;
background: #ffebee;
border-radius: 8px;
border: 1px solid #ef9a9a;
">
<div style="font-size: 1.5rem; margin-bottom: 0.5rem;">❌</div>
<div><strong>Failed to load component</strong></div>
<div style="margin-top: 0.5rem; font-size: 0.9rem; color: #c62828;">
${errorMessage}
</div>
</div>
`;
}
/**
* 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;