Fix Safari iOS reload loop with static device detection and caching
All checks were successful
Build And Push Image / docker (push) Successful in 3m7s

- Replace reactive device detection with static utilities to prevent
  infinite reload loops on mobile Safari
- Add static-device-detection.ts for one-time device info computation
- Add config-cache.ts for improved configuration loading performance
- Apply mobile Safari viewport and CSS optimizations across auth pages
- Remove reactive dependencies that caused rendering issues on iOS
This commit is contained in:
2025-08-10 15:18:34 +02:00
parent 4e53e7ea10
commit 30136117ce
6 changed files with 797 additions and 204 deletions

282
utils/config-cache.ts Normal file
View File

@@ -0,0 +1,282 @@
/**
* Global Configuration Cache Utility
* Prevents repeated API calls and manages singleton pattern for configs
* Designed to stop Safari iOS reload loops caused by repeated config fetches
*/
import type { RecaptchaConfig, RegistrationConfig } from './types';
// Global cache storage
interface ConfigCache {
recaptcha: RecaptchaConfig | null;
registration: RegistrationConfig | null;
recaptchaLoading: boolean;
registrationLoading: boolean;
recaptchaError: string | null;
registrationError: string | null;
}
let globalConfigCache: ConfigCache = {
recaptcha: null,
registration: null,
recaptchaLoading: false,
registrationLoading: false,
recaptchaError: null,
registrationError: null
};
// Circuit breaker to prevent rapid successive calls
const CIRCUIT_BREAKER_THRESHOLD = 5; // Max calls in time window
const CIRCUIT_BREAKER_WINDOW = 10000; // 10 seconds
const callHistory: { [key: string]: number[] } = {};
/**
* Check if API calls should be blocked due to circuit breaker
*/
function shouldBlockCall(apiName: string): boolean {
const now = Date.now();
const history = callHistory[apiName] || [];
// Clean old calls outside the time window
const recentCalls = history.filter(time => now - time < CIRCUIT_BREAKER_WINDOW);
callHistory[apiName] = recentCalls;
if (recentCalls.length >= CIRCUIT_BREAKER_THRESHOLD) {
console.warn(`[config-cache] Circuit breaker activated for ${apiName} - too many calls`);
return true;
}
// Record this call
recentCalls.push(now);
return false;
}
/**
* Get reCAPTCHA configuration with caching and circuit breaker
*/
export async function getCachedRecaptchaConfig(): Promise<RecaptchaConfig> {
// Return cached result if available
if (globalConfigCache.recaptcha) {
return globalConfigCache.recaptcha;
}
// Check if already loading
if (globalConfigCache.recaptchaLoading) {
console.log('[config-cache] reCAPTCHA config already loading, waiting...');
// Wait for loading to complete
return new Promise((resolve, reject) => {
const checkInterval = setInterval(() => {
if (!globalConfigCache.recaptchaLoading) {
clearInterval(checkInterval);
if (globalConfigCache.recaptcha) {
resolve(globalConfigCache.recaptcha);
} else if (globalConfigCache.recaptchaError) {
reject(new Error(globalConfigCache.recaptchaError));
} else {
reject(new Error('Unknown error loading reCAPTCHA config'));
}
}
}, 100);
// Timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval);
reject(new Error('Timeout waiting for reCAPTCHA config'));
}, 10000);
});
}
// Check circuit breaker
if (shouldBlockCall('recaptcha-config')) {
const fallbackConfig: RecaptchaConfig = { siteKey: '', secretKey: '' };
globalConfigCache.recaptcha = fallbackConfig;
return fallbackConfig;
}
try {
console.log('[config-cache] Loading reCAPTCHA config...');
globalConfigCache.recaptchaLoading = true;
globalConfigCache.recaptchaError = null;
const response = await $fetch('/api/recaptcha-config') as any;
if (response?.success && response?.data) {
globalConfigCache.recaptcha = response.data;
console.log('[config-cache] reCAPTCHA config loaded successfully');
return response.data;
} else {
throw new Error('Invalid reCAPTCHA config response');
}
} catch (error: any) {
const errorMessage = error.message || 'Failed to load reCAPTCHA config';
console.warn('[config-cache] reCAPTCHA config load failed:', errorMessage);
globalConfigCache.recaptchaError = errorMessage;
// Return fallback config instead of throwing
const fallbackConfig: RecaptchaConfig = { siteKey: '', secretKey: '' };
globalConfigCache.recaptcha = fallbackConfig;
return fallbackConfig;
} finally {
globalConfigCache.recaptchaLoading = false;
}
}
/**
* Get registration configuration with caching and circuit breaker
*/
export async function getCachedRegistrationConfig(): Promise<RegistrationConfig> {
// Return cached result if available
if (globalConfigCache.registration) {
return globalConfigCache.registration;
}
// Check if already loading
if (globalConfigCache.registrationLoading) {
console.log('[config-cache] Registration config already loading, waiting...');
// Wait for loading to complete
return new Promise((resolve, reject) => {
const checkInterval = setInterval(() => {
if (!globalConfigCache.registrationLoading) {
clearInterval(checkInterval);
if (globalConfigCache.registration) {
resolve(globalConfigCache.registration);
} else if (globalConfigCache.registrationError) {
reject(new Error(globalConfigCache.registrationError));
} else {
reject(new Error('Unknown error loading registration config'));
}
}
}, 100);
// Timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval);
reject(new Error('Timeout waiting for registration config'));
}, 10000);
});
}
// Check circuit breaker
if (shouldBlockCall('registration-config')) {
const fallbackConfig: RegistrationConfig = {
membershipFee: 150,
iban: 'MC58 1756 9000 0104 0050 1001 860',
accountHolder: 'ASSOCIATION MONACO USA'
};
globalConfigCache.registration = fallbackConfig;
return fallbackConfig;
}
try {
console.log('[config-cache] Loading registration config...');
globalConfigCache.registrationLoading = true;
globalConfigCache.registrationError = null;
const response = await $fetch('/api/registration-config') as any;
if (response?.success && response?.data) {
globalConfigCache.registration = response.data;
console.log('[config-cache] Registration config loaded successfully');
return response.data;
} else {
throw new Error('Invalid registration config response');
}
} catch (error: any) {
const errorMessage = error.message || 'Failed to load registration config';
console.warn('[config-cache] Registration config load failed:', errorMessage);
globalConfigCache.registrationError = errorMessage;
// Return fallback config instead of throwing
const fallbackConfig: RegistrationConfig = {
membershipFee: 150,
iban: 'MC58 1756 9000 0104 0050 1001 860',
accountHolder: 'ASSOCIATION MONACO USA'
};
globalConfigCache.registration = fallbackConfig;
return fallbackConfig;
} finally {
globalConfigCache.registrationLoading = false;
}
}
/**
* Load both configs with optimal batching
* Useful for components that need both configs
*/
export async function loadAllConfigs(): Promise<{
recaptcha: RecaptchaConfig;
registration: RegistrationConfig;
}> {
try {
const [recaptcha, registration] = await Promise.allSettled([
getCachedRecaptchaConfig(),
getCachedRegistrationConfig()
]);
return {
recaptcha: recaptcha.status === 'fulfilled'
? recaptcha.value
: { siteKey: '', secretKey: '' },
registration: registration.status === 'fulfilled'
? registration.value
: {
membershipFee: 150,
iban: 'MC58 1756 9000 0104 0050 1001 860',
accountHolder: 'ASSOCIATION MONACO USA'
}
};
} catch (error) {
console.error('[config-cache] Error loading configs:', error);
// Return fallback configs
return {
recaptcha: { siteKey: '', secretKey: '' },
registration: {
membershipFee: 150,
iban: 'MC58 1756 9000 0104 0050 1001 860',
accountHolder: 'ASSOCIATION MONACO USA'
}
};
}
}
/**
* Clear all cached configurations (useful for testing or cache refresh)
*/
export function clearConfigCache(): void {
console.log('[config-cache] Clearing all cached configurations');
globalConfigCache = {
recaptcha: null,
registration: null,
recaptchaLoading: false,
registrationLoading: false,
recaptchaError: null,
registrationError: null
};
// Clear call history for circuit breaker
Object.keys(callHistory).forEach(key => {
delete callHistory[key];
});
}
/**
* Get cache status for debugging
*/
export function getConfigCacheStatus(): ConfigCache {
return { ...globalConfigCache };
}
/**
* Force reload specific config (bypasses cache)
*/
export async function reloadRecaptchaConfig(): Promise<RecaptchaConfig> {
globalConfigCache.recaptcha = null;
globalConfigCache.recaptchaError = null;
return getCachedRecaptchaConfig();
}
export async function reloadRegistrationConfig(): Promise<RegistrationConfig> {
globalConfigCache.registration = null;
globalConfigCache.registrationError = null;
return getCachedRegistrationConfig();
}

View File

@@ -0,0 +1,128 @@
/**
* Static Device Detection Utility
* Provides non-reactive device detection for Safari iOS reload loop prevention
* Uses direct navigator.userAgent analysis without creating Vue reactive dependencies
*/
export interface DeviceInfo {
isMobile: boolean;
isIos: boolean;
isSafari: boolean;
isMobileSafari: boolean;
isAndroid: boolean;
userAgent: string;
}
let cachedDeviceInfo: DeviceInfo | null = null;
/**
* Get static device information without creating reactive dependencies
* Results are cached to prevent multiple userAgent parsing
*/
export function getStaticDeviceInfo(): DeviceInfo {
// Return cached result if available
if (cachedDeviceInfo) {
return cachedDeviceInfo;
}
// Only run on client-side
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
cachedDeviceInfo = {
isMobile: false,
isIos: false,
isSafari: false,
isMobileSafari: false,
isAndroid: false,
userAgent: ''
};
return cachedDeviceInfo;
}
const userAgent = navigator.userAgent;
// Device detection logic
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
const isIos = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
const isAndroid = /Android/i.test(userAgent);
const isSafari = /^((?!chrome|android).)*safari/i.test(userAgent);
const isMobileSafari = isIos && isSafari;
// Cache the result
cachedDeviceInfo = {
isMobile,
isIos,
isSafari,
isMobileSafari,
isAndroid,
userAgent
};
return cachedDeviceInfo;
}
/**
* Get CSS classes for device-specific styling
* Returns a space-separated string of CSS classes
*/
export function getDeviceCssClasses(baseClass: string = ''): string {
const device = getStaticDeviceInfo();
const classes = [baseClass].filter(Boolean);
if (device.isMobile) classes.push('is-mobile');
if (device.isIos) classes.push('is-ios');
if (device.isSafari) classes.push('is-safari');
if (device.isMobileSafari) classes.push('is-mobile-safari');
if (device.isAndroid) classes.push('is-android');
return classes.join(' ');
}
/**
* Check if current device is mobile Safari specifically
* This is the primary problematic browser for reload loops
*/
export function isMobileSafari(): boolean {
return getStaticDeviceInfo().isMobileSafari;
}
/**
* Apply mobile Safari specific optimizations to DOM element
* Should be called once per component to prevent reactive updates
*/
export function applyMobileSafariOptimizations(element?: HTMLElement): void {
if (!isMobileSafari()) return;
const targetElement = element || document.documentElement;
// Apply performance optimization classes
targetElement.classList.add('is-mobile-safari', 'performance-optimized');
// Set viewport height CSS variable for mobile Safari
const vh = window.innerHeight * 0.01;
targetElement.style.setProperty('--vh', `${vh}px`);
// Disable problematic CSS features for performance
targetElement.style.setProperty('--backdrop-filter', 'none');
targetElement.style.setProperty('--will-change', 'auto');
}
/**
* Get viewport meta content optimized for mobile Safari
*/
export function getMobileSafariViewportMeta(): string {
const device = getStaticDeviceInfo();
if (device.isMobileSafari) {
// Prevent zoom on input focus for iOS Safari
return 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no';
}
return 'width=device-width, initial-scale=1.0';
}
/**
* Clear cached device info (useful for testing)
*/
export function clearDeviceInfoCache(): void {
cachedDeviceInfo = null;
}