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
418 lines
12 KiB
JavaScript
418 lines
12 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() {
|
|
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<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);
|
|
|
|
// 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) {
|
|
// Use StateSerializer for type-safe state handling
|
|
const stateJson = JSON.stringify(data.state);
|
|
config.element.dataset.state = stateJson;
|
|
}
|
|
|
|
// 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 = `
|
|
<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;
|