fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled

This commit is contained in:
2025-11-24 21:28:25 +01:00
parent 4eb7134853
commit 77abc65cd7
1327 changed files with 91915 additions and 9909 deletions

View File

@@ -23,6 +23,16 @@ 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() {
@@ -64,11 +74,21 @@ class LiveComponentManager {
// 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();
}
/**
@@ -132,17 +152,24 @@ class LiveComponentManager {
/**
* 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) {
const componentId = element.dataset.liveComponent;
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.pollInterval) || null,
pollInterval: parseInt(element.dataset[toDatasetKey(LiveComponentCoreAttributes.POLL_INTERVAL)]) || null,
pollTimer: null,
observer: null
observer: null,
isolated: isIsland
};
this.components.set(componentId, config);
@@ -162,7 +189,8 @@ class LiveComponentManager {
this.setupLifecycleObserver(element, componentId);
// Setup SSE connection if component has a channel
this.setupSseConnection(element, componentId);
// Islands have isolated SSE channels (no parent events)
this.setupSseConnection(element, componentId, { isolated: isIsland });
// Setup accessibility features
this.setupAccessibility(componentId, element);
@@ -170,7 +198,12 @@ class LiveComponentManager {
// Initialize tooltips for component
this.tooltipManager.initComponent(element);
console.log(`[LiveComponent] Initialized: ${componentId}`);
// Initialize progressive enhancement if not already done
if (!this.progressiveEnhancement.initialized) {
this.progressiveEnhancement.init();
}
console.log(`[LiveComponent] Initialized: ${componentId}${isIsland ? ' (Island)' : ''}`);
}
/**
@@ -178,7 +211,7 @@ class LiveComponentManager {
*/
setupAccessibility(componentId, element) {
// Create component-specific ARIA live region
const politeness = element.dataset.livePolite === 'assertive' ? 'assertive' : 'polite';
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}`);
@@ -217,9 +250,9 @@ class LiveComponentManager {
* Setup SSE connection for component
* Automatically connects to SSE if component has data-sse-channel attribute
*/
setupSseConnection(element, componentId) {
setupSseConnection(element, componentId, options = {}) {
// Check if component has SSE channel
const sseChannel = element.dataset.sseChannel;
const sseChannel = element.dataset[toDatasetKey(LiveComponentCoreAttributes.SSE_CHANNEL)];
if (!sseChannel) {
return; // No SSE channel, skip
}
@@ -433,14 +466,112 @@ class LiveComponentManager {
* Setup action click handlers
*/
setupActionHandlers(element) {
element.querySelectorAll('[data-live-action]').forEach(actionEl => {
// Handle form submissions
if (actionEl.tagName === 'FORM') {
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 componentId = element.dataset.liveComponent;
const action = actionEl.dataset.liveAction;
const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)];
// Collect all form values
const formValues = {};
@@ -452,44 +583,77 @@ class LiveComponentManager {
// Extract fragments for partial rendering
const fragments = this.extractFragments(actionEl);
await this.executeAction(componentId, action, formValues, fragments);
await this.executeAction(componentId, action, formValues, fragments, actionEl);
});
}
// Handle input elements with debouncing
else if (actionEl.tagName === 'INPUT' || actionEl.tagName === 'TEXTAREA' || actionEl.tagName === 'SELECT') {
}
// 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);
// 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 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;
}
// 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;
}
// Extract fragments for partial rendering
const fragments = this.extractFragments(actionEl);
console.log('[LiveComponent] SELECT change:', {
action,
name,
camelCaseName,
value: actionEl.value,
params
});
if (debounceMs > 0) {
this.debouncedAction(componentId, action, params, debounceMs, fragments);
} else {
await this.executeAction(componentId, action, params, fragments);
}
});
// 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 componentId = element.dataset.liveComponent;
const action = actionEl.dataset.liveAction;
const action = actionEl.dataset[toDatasetKey(LiveComponentCoreAttributes.LIVE_ACTION)];
const params = this.extractParams(actionEl);
if (actionEl.type === 'checkbox') {
@@ -498,31 +662,31 @@ class LiveComponentManager {
params[actionEl.name || 'value'] = actionEl.value;
}
await this.executeAction(componentId, action, params);
await this.executeAction(componentId, action, params, null, actionEl);
});
}
} else {
// Handle button clicks
actionEl.addEventListener('click', async (e) => {
e.preventDefault();
} else {
// Handle button clicks (default behavior)
actionEl.addEventListener('click', async (e) => {
e.preventDefault();
const componentId = element.dataset.liveComponent;
const action = actionEl.dataset.liveAction;
let params = this.extractParams(actionEl);
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);
}
// 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);
// Extract fragments for partial rendering
const fragments = this.extractFragments(actionEl);
await this.executeAction(componentId, action, params, fragments);
});
await this.executeAction(componentId, action, params, fragments, actionEl);
});
}
}
});
}
@@ -530,7 +694,7 @@ class LiveComponentManager {
/**
* Debounced action execution
*/
debouncedAction(componentId, action, params, delay, fragments = null) {
debouncedAction(componentId, action, params, delay, fragments = null, actionElement = null) {
const timerKey = `${componentId}_${action}`;
// Clear existing timer
@@ -540,7 +704,7 @@ class LiveComponentManager {
// Set new timer
const timer = setTimeout(async () => {
await this.executeAction(componentId, action, params, fragments);
await this.executeAction(componentId, action, params, fragments, actionElement);
this.debounceTimers.delete(timerKey);
}, delay);
@@ -636,8 +800,9 @@ class LiveComponentManager {
* @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) {
async executeAction(componentId, method, params = {}, fragments = null, actionElement = null) {
const config = this.components.get(componentId);
if (!config) {
console.error(`[LiveComponent] Unknown component: ${componentId}`);
@@ -661,19 +826,39 @@ class LiveComponentManager {
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, config.element, {
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(config.element) || {
const stateWrapper = StateSerializer.getStateFromElement(componentElement) || {
id: componentId,
component: '',
data: {},
@@ -683,6 +868,45 @@ class LiveComponentManager {
// 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
@@ -706,36 +930,123 @@ class LiveComponentManager {
);
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 element
const csrfToken = config.element.dataset.csrfToken;
// 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
const requestBody = {
method,
params,
state,
_csrf_token: csrfToken
};
// 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: JSON.stringify(requestBody)
body: finalRequestBody
});
if (!response.ok) {
@@ -758,7 +1069,6 @@ class LiveComponentManager {
// Check for version conflict
const serverState = data.state || {};
const currentVersion = stateWrapper.version || 1;
const serverVersion = serverState.version || 1;
if (serverVersion !== currentVersion + 1) {
@@ -789,18 +1099,54 @@ class LiveComponentManager {
}
}
// 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) {
config.element.innerHTML = data.html;
this.setupActionHandlers(config.element);
// 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
this.tooltipManager.initComponent(config.element);
// 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) {
@@ -812,6 +1158,16 @@ class LiveComponentManager {
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
@@ -822,7 +1178,7 @@ class LiveComponentManager {
this.requestDeduplicator.cacheResult(componentId, method, params, data);
// Hide loading state
this.loadingStateManager.hideLoading(componentId);
this.loadingStateManager.hideLoading(componentId, actionElement);
return data;
@@ -844,7 +1200,7 @@ class LiveComponentManager {
}
// Hide loading state on error
this.loadingStateManager.hideLoading(componentId);
this.loadingStateManager.hideLoading(componentId, null);
// Handle error via ErrorBoundary
await this.errorBoundary.handleError(componentId, method, error, {
@@ -860,6 +1216,49 @@ class LiveComponentManager {
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
*
@@ -922,12 +1321,15 @@ class LiveComponentManager {
// 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, payload);
this.dispatchToComponent(target, name, eventPayload);
} else {
// Broadcast event - to all listening components
this.broadcast(name, payload);
this.broadcast(name, eventPayload);
}
});
}
@@ -943,6 +1345,37 @@ class LiveComponentManager {
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);
}
}
/**
@@ -955,10 +1388,28 @@ class LiveComponentManager {
handler.callback(payload);
});
// Also dispatch as custom DOM event
// 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));
}
/**
@@ -1352,7 +1803,7 @@ class LiveComponentManager {
this.tooltipManager.cleanupComponent(config.element);
// Hide any active loading states
this.loadingStateManager.hideLoading(componentId);
this.loadingStateManager.hideLoading(componentId, null);
this.loadingStateManager.clearConfig(componentId);
// Cleanup UI components
@@ -1599,7 +2050,7 @@ window.LiveComponent = LiveComponent;
// Auto-initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-live-component]').forEach(el => {
document.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`).forEach(el => {
LiveComponent.init(el);
});
});
@@ -1629,7 +2080,7 @@ export async function init(config = {}, state = {}) {
// 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]');
const components = document.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`);
console.log(`[LiveComponent] Found ${components.length} regular components to initialize`);
components.forEach(el => {
@@ -1637,7 +2088,7 @@ export async function init(config = {}, state = {}) {
});
// Lazy components are automatically initialized by LazyComponentLoader
const lazyComponents = document.querySelectorAll('[data-live-component-lazy]');
const lazyComponents = document.querySelectorAll(`[${LiveComponentLazyAttributes.LIVE_COMPONENT_LAZY}]`);
console.log(`[LiveComponent] Found ${lazyComponents.length} lazy components (will load on demand)`);
// Log nested component stats
@@ -1663,7 +2114,7 @@ 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]');
const components = element.querySelectorAll(`[${LiveComponentCoreAttributes.LIVE_COMPONENT}]`);
if (components.length === 0 && element.hasAttribute('data-live-component')) {
// Element itself is a LiveComponent
@@ -1687,6 +2138,7 @@ export { ChunkedUploader } from './ChunkedUploader.js';
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;