Fix Safari iOS reload loop with static device detection and caching
All checks were successful
Build And Push Image / docker (push) Successful in 3m7s
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:
282
utils/config-cache.ts
Normal file
282
utils/config-cache.ts
Normal 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();
|
||||
}
|
||||
128
utils/static-device-detection.ts
Normal file
128
utils/static-device-detection.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user