assets_js_controllers_mobile-controller-base.js

import { Controller } from "@hotwired/stimulus";

/**
 * MobileControllerBase - Shared base class for all mobile Stimulus controllers
 * 
 * Provides centralized online/offline state management, connection event handling,
 * and utility methods for network requests with retry logic.
 * 
 * Features:
 * - Single source of truth for online/offline state (static isOnline property)
 * - Automatic connection listener registration/cleanup
 * - fetchWithRetry() for network requests with exponential backoff
 * - Connection state change notifications via onConnectionStateChanged()
 * - Proper event handler cleanup on disconnect
 * 
 * Usage:
 * class MyMobileController extends MobileControllerBase {
 *     onConnectionStateChanged(isOnline) {
 *         // React to connection changes
 *     }
 * }
 */
class MobileControllerBase extends Controller {
    // Static properties - shared across all instances
    static isOnline = navigator.onLine;
    static connectionListeners = new Set();
    static initialized = false;

    /**
     * Initialize static connection listeners once
     * Sets up window online/offline event handlers
     */
    static initializeConnectionListeners() {
        if (MobileControllerBase.initialized) return;
        
        window.addEventListener('online', () => {
            MobileControllerBase.setOnlineState(true, true);
        });
        
        window.addEventListener('offline', () => {
            MobileControllerBase.setOnlineState(false, true);
        });
        
        MobileControllerBase.initialized = true;
    }

    /**
     * Set global online state and optionally notify listeners.
     * @param {boolean} isOnline
     * @param {boolean} notify
     */
    static setOnlineState(isOnline, notify = true) {
        const normalized = Boolean(isOnline);
        const changed = MobileControllerBase.isOnline !== normalized;
        MobileControllerBase.isOnline = normalized;

        if (notify && changed) {
            MobileControllerBase.notifyListeners(normalized);
        }
    }
    
    /**
     * Sync static isOnline with current navigator.onLine state
     * Called on each controller connect to handle page loads while offline
     */
    static syncOnlineState() {
        const currentState = navigator.onLine;
        MobileControllerBase.setOnlineState(currentState, false);
    }

    /**
     * Notify all registered controllers of connection state change
     * @param {boolean} isOnline - Current connection state
     */
    static notifyListeners(isOnline) {
        MobileControllerBase.connectionListeners.forEach(controller => {
            if (typeof controller.onConnectionStateChanged === 'function') {
                try {
                    controller.onConnectionStateChanged(isOnline);
                } catch (error) {
                    console.error('Error in onConnectionStateChanged:', error);
                }
            }
        });
    }

    /**
     * Initialize instance
     * Sets up bound handler tracking map
     */
    initialize() {
        // Ensure static listeners are set up
        MobileControllerBase.initializeConnectionListeners();
        
        // Sync online state with current navigator.onLine (handles page loads while offline)
        MobileControllerBase.syncOnlineState();
        
        // Map for tracking bound event handlers for cleanup
        this._boundHandlers = new Map();
    }

    /**
     * Connect controller to DOM
     * Registers controller for connection state notifications
     */
    connect() {
        // Sync online state again on connect (handles Turbo navigation while offline)
        MobileControllerBase.syncOnlineState();
        
        // Register this controller for connection notifications
        MobileControllerBase.connectionListeners.add(this);
        
        // Call subclass connect if defined
        if (typeof this.onConnect === 'function') {
            this.onConnect();
        }
    }

    /**
     * Disconnect controller from DOM
     * Unregisters controller and cleans up event handlers
     */
    disconnect() {
        // Unregister from connection notifications
        MobileControllerBase.connectionListeners.delete(this);
        
        // Clear bound handlers map
        this._boundHandlers.clear();
        
        // Call subclass disconnect if defined
        if (typeof this.onDisconnect === 'function') {
            this.onDisconnect();
        }
    }

    /**
     * Override in subclass to handle connection state changes
     * @param {boolean} isOnline - Current connection state
     */
    onConnectionStateChanged(isOnline) {
        // Override in subclass
    }

    /**
     * Get current online status
     * @returns {boolean} Current connection state
     */
    get online() {
        return MobileControllerBase.isOnline;
    }

    /**
     * Bind and track an event handler for later cleanup
     * @param {string} name - Identifier for the handler
     * @param {Function} handler - Function to bind
     * @returns {Function} Bound handler
     */
    bindHandler(name, handler) {
        const bound = handler.bind(this);
        this._boundHandlers.set(name, bound);
        return bound;
    }

    /**
     * Get a previously bound handler
     * @param {string} name - Handler identifier
     * @returns {Function|undefined} Bound handler or undefined
     */
    getHandler(name) {
        return this._boundHandlers.get(name);
    }

    /**
     * Fetch with retry logic and exponential backoff
     * 
     * @param {string} url - URL to fetch
     * @param {Object} options - Fetch options
     * @param {number} retries - Number of retry attempts (default: 3)
     * @param {number} timeout - Request timeout in ms (default: 10000)
     * @returns {Promise<Response>} Fetch response
     */
    async fetchWithRetry(url, options = {}, retries = 3, timeout = 10000) {
        let lastError;
        
        for (let attempt = 0; attempt <= retries; attempt++) {
            try {
                // Create abort controller for timeout
                const controller = new AbortController();
                const timeoutId = setTimeout(() => controller.abort(), timeout);
                
                const response = await fetch(url, {
                    ...options,
                    signal: controller.signal,
                    headers: {
                        'X-Requested-With': 'XMLHttpRequest',
                        'Accept': 'application/json',
                        ...options.headers
                    }
                });
                
                clearTimeout(timeoutId);
                
                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
                }
                
                return response;
            } catch (error) {
                lastError = error;
                
                // Don't retry on abort or if offline
                if (error.name === 'AbortError' || !MobileControllerBase.isOnline) {
                    throw error;
                }
                
                // Exponential backoff: 1s, 2s, 4s...
                if (attempt < retries) {
                    const delay = Math.pow(2, attempt) * 1000;
                    await new Promise(resolve => setTimeout(resolve, delay));
                }
            }
        }
        
        throw lastError;
    }

    /**
     * Dispatch a custom event with connection status
     * @param {string} eventName - Event name to dispatch
     * @param {Object} detail - Additional event detail
     */
    dispatchConnectionEvent(eventName, detail = {}) {
        const event = new CustomEvent(eventName, {
            bubbles: true,
            detail: {
                isOnline: MobileControllerBase.isOnline,
                ...detail
            }
        });
        this.element.dispatchEvent(event);
    }
}

// Register controller globally
if (!window.Controllers) {
    window.Controllers = {};
}
window.Controllers["mobile-controller-base"] = MobileControllerBase;

export default MobileControllerBase;