Fix mobile Safari reload loop by persisting config cache in window object
Build And Push Image / docker (push) Successful in 2m56s
Details
Build And Push Image / docker (push) Successful in 2m56s
Details
- Store config cache in window.__configCache instead of module-level variable to maintain persistence across Vue reactivity cycles - Fix cardClasses ref to store computed value instead of function - Add client plugin for config cache initialization - Add documentation for mobile Safari reload loop fix
This commit is contained in:
parent
0774e16fb2
commit
86977ca92a
|
|
@ -0,0 +1,190 @@
|
||||||
|
# Mobile Safari Reload Loop - Final Fix
|
||||||
|
|
||||||
|
## Problem Description
|
||||||
|
Users on Safari iPhone experienced endless reload loops on:
|
||||||
|
- Signup page (`/signup`)
|
||||||
|
- Email verification page (`/auth/verify`)
|
||||||
|
- Password setup page (`/auth/setup-password`)
|
||||||
|
|
||||||
|
The server logs showed repeated calls to:
|
||||||
|
- `/api/recaptcha-config`
|
||||||
|
- `/api/registration-config`
|
||||||
|
|
||||||
|
## Root Causes Identified
|
||||||
|
|
||||||
|
### 1. Incorrect Reactive Reference in Signup Page
|
||||||
|
**Issue**: `cardClasses` was defined as a ref containing a function instead of the function's result:
|
||||||
|
```typescript
|
||||||
|
// WRONG - causes reactivity issues
|
||||||
|
const cardClasses = ref(() => {
|
||||||
|
const classes = ['signup-card'];
|
||||||
|
// ...
|
||||||
|
return classes.join(' ');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**: Execute the function immediately and store the result:
|
||||||
|
```typescript
|
||||||
|
// CORRECT
|
||||||
|
const cardClasses = ref((() => {
|
||||||
|
const classes = ['signup-card'];
|
||||||
|
// ...
|
||||||
|
return classes.join(' ');
|
||||||
|
})()); // Note the immediate execution with ()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Config Cache Not Persisting Across Component Lifecycles
|
||||||
|
**Issue**: The global config cache was using module-level variables that could be reset during Vue's reactivity cycles, causing repeated API calls.
|
||||||
|
|
||||||
|
**Fix**: Use `window` object for true persistence:
|
||||||
|
```typescript
|
||||||
|
// Use window object for true persistence across component lifecycle
|
||||||
|
function getGlobalCache(): ConfigCache {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return defaultCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(window as any).__configCache) {
|
||||||
|
(window as any).__configCache = defaultCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (window as any).__configCache;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Missing Circuit Breaker Protection
|
||||||
|
**Issue**: No protection against rapid successive API calls that could trigger reload loops.
|
||||||
|
|
||||||
|
**Fix**: Implemented circuit breaker with threshold protection:
|
||||||
|
- Max 5 calls in 10-second window
|
||||||
|
- Automatic blocking when threshold reached
|
||||||
|
- Fallback to default values when blocked
|
||||||
|
|
||||||
|
## Complete Solution Implementation
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. **`pages/signup.vue`**
|
||||||
|
- Fixed `cardClasses` ref definition
|
||||||
|
- Ensured static device detection
|
||||||
|
- Added initialization flag to prevent multiple setups
|
||||||
|
|
||||||
|
2. **`utils/config-cache.ts`**
|
||||||
|
- Moved cache storage to `window` object
|
||||||
|
- Added `getGlobalCache()` function for persistent storage
|
||||||
|
- Improved circuit breaker implementation
|
||||||
|
- Added proper logging for debugging
|
||||||
|
|
||||||
|
3. **`plugins/04.config-cache-init.client.ts`** (NEW)
|
||||||
|
- Pre-initializes config cache structure
|
||||||
|
- Sets up global error handlers to catch reload loops
|
||||||
|
- Prevents multiple initializations
|
||||||
|
- Adds unhandled rejection handler
|
||||||
|
|
||||||
|
## How The Fix Works
|
||||||
|
|
||||||
|
### 1. Plugin Initialization (runs first)
|
||||||
|
- `04.config-cache-init.client.ts` runs before other plugins
|
||||||
|
- Initializes `window.__configCache` structure
|
||||||
|
- Sets up error handlers to catch potential reload loops
|
||||||
|
- Marks initialization complete with `window.__configCacheInitialized`
|
||||||
|
|
||||||
|
### 2. Config Loading (on-demand)
|
||||||
|
- When pages need config, they call `loadAllConfigs()`
|
||||||
|
- Cache is checked first via `getGlobalCache()`
|
||||||
|
- If cached, returns immediately (no API call)
|
||||||
|
- If not cached, makes API call with circuit breaker protection
|
||||||
|
- Results stored in `window.__configCache` for persistence
|
||||||
|
|
||||||
|
### 3. Circuit Breaker Protection
|
||||||
|
- Tracks API call history in time windows
|
||||||
|
- Blocks calls if threshold exceeded (5 calls in 10 seconds)
|
||||||
|
- Returns fallback values when blocked
|
||||||
|
- Prevents cascade failures and reload loops
|
||||||
|
|
||||||
|
## Testing Instructions
|
||||||
|
|
||||||
|
### Test on Safari iPhone:
|
||||||
|
1. Clear Safari cache and cookies
|
||||||
|
2. Navigate to `/signup` - should load without reload loop
|
||||||
|
3. Navigate to `/auth/verify?token=test` - should show error without loop
|
||||||
|
4. Navigate to `/auth/setup-password?email=test@test.com` - should load without loop
|
||||||
|
|
||||||
|
### Monitor Console Logs:
|
||||||
|
- Look for `[config-cache-init]` messages confirming initialization
|
||||||
|
- Check for `[config-cache] Returning cached` messages on subsequent loads
|
||||||
|
- Watch for `Circuit breaker activated` if threshold reached
|
||||||
|
|
||||||
|
### Server Logs:
|
||||||
|
- Should see initial calls to `/api/recaptcha-config` and `/api/registration-config`
|
||||||
|
- Should NOT see repeated calls in quick succession
|
||||||
|
- Maximum 2-3 calls per page load (initial + retry if needed)
|
||||||
|
|
||||||
|
## Prevention Measures
|
||||||
|
|
||||||
|
### 1. Static Detection Pattern
|
||||||
|
All device detection uses static, non-reactive patterns:
|
||||||
|
```typescript
|
||||||
|
const deviceInfo = getStaticDeviceInfo(); // Called once, never reactive
|
||||||
|
const containerClasses = ref(getDeviceCssClasses('page-name')); // Computed once
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configuration Caching
|
||||||
|
All configuration loading uses cached utility:
|
||||||
|
```typescript
|
||||||
|
const configs = await loadAllConfigs(); // Uses cache automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialization Flags
|
||||||
|
Prevent multiple initializations:
|
||||||
|
```typescript
|
||||||
|
let initialized = false;
|
||||||
|
onMounted(() => {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
// ... initialization code
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Key Metrics to Watch:
|
||||||
|
1. **API Call Frequency**: `/api/recaptcha-config` and `/api/registration-config` should be called max once per session
|
||||||
|
2. **Page Load Time**: Should be under 2 seconds on mobile
|
||||||
|
3. **Error Rate**: No "Maximum call stack" or recursion errors
|
||||||
|
4. **User Reports**: No complaints about infinite loading
|
||||||
|
|
||||||
|
### Debug Commands:
|
||||||
|
```javascript
|
||||||
|
// Check cache status in browser console
|
||||||
|
console.log(window.__configCache);
|
||||||
|
console.log(window.__configCacheInitialized);
|
||||||
|
|
||||||
|
// Force clear cache (for testing)
|
||||||
|
window.__configCache = null;
|
||||||
|
window.__configCacheInitialized = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues persist, rollback changes:
|
||||||
|
1. Remove `plugins/04.config-cache-init.client.ts`
|
||||||
|
2. Revert `utils/config-cache.ts` to previous version
|
||||||
|
3. Revert `pages/signup.vue` changes
|
||||||
|
|
||||||
|
## Long-term Improvements
|
||||||
|
|
||||||
|
1. **Server-side caching**: Cache config in Redis/memory on server
|
||||||
|
2. **SSR config injection**: Inject config during SSR to avoid client calls
|
||||||
|
3. **PWA service worker**: Cache config in service worker
|
||||||
|
4. **Config versioning**: Add version check to invalidate stale cache
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The mobile Safari reload loop has been resolved through:
|
||||||
|
1. Fixing reactive reference bugs
|
||||||
|
2. Implementing proper persistent caching
|
||||||
|
3. Adding circuit breaker protection
|
||||||
|
4. Setting up global error handlers
|
||||||
|
|
||||||
|
The solution is backward compatible and doesn't affect desktop users or other browsers. The fix specifically targets the root causes while maintaining the existing functionality.
|
||||||
|
|
@ -234,13 +234,13 @@ useHead({
|
||||||
|
|
||||||
// Static CSS classes - computed once, never reactive
|
// Static CSS classes - computed once, never reactive
|
||||||
const containerClasses = ref(getDeviceCssClasses('signup-container'));
|
const containerClasses = ref(getDeviceCssClasses('signup-container'));
|
||||||
const cardClasses = ref(() => {
|
const cardClasses = ref((() => {
|
||||||
const classes = ['signup-card'];
|
const classes = ['signup-card'];
|
||||||
if (deviceInfo.isMobileSafari) {
|
if (deviceInfo.isMobileSafari) {
|
||||||
classes.push('performance-optimized', 'no-backdrop-filter');
|
classes.push('performance-optimized', 'no-backdrop-filter');
|
||||||
}
|
}
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
});
|
})()); // Execute immediately and store the result, not the function
|
||||||
|
|
||||||
// Form data - individual refs to prevent Vue reactivity corruption
|
// Form data - individual refs to prevent Vue reactivity corruption
|
||||||
const firstName = ref('');
|
const firstName = ref('');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* Config Cache Initialization Plugin
|
||||||
|
* Ensures config cache is properly initialized and prevents reload loops
|
||||||
|
* Specifically designed to fix Safari iOS issues
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default defineNuxtPlugin({
|
||||||
|
name: 'config-cache-init',
|
||||||
|
enforce: 'pre', // Run before other plugins
|
||||||
|
async setup() {
|
||||||
|
console.log('[config-cache-init] Initializing config cache plugin');
|
||||||
|
|
||||||
|
// Only run on client side
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a flag to prevent multiple initializations
|
||||||
|
if ((window as any).__configCacheInitialized) {
|
||||||
|
console.log('[config-cache-init] Config cache already initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as initialized
|
||||||
|
(window as any).__configCacheInitialized = true;
|
||||||
|
|
||||||
|
// Initialize the config cache structure if not already present
|
||||||
|
if (!(window as any).__configCache) {
|
||||||
|
(window as any).__configCache = {
|
||||||
|
recaptcha: null,
|
||||||
|
registration: null,
|
||||||
|
recaptchaLoading: false,
|
||||||
|
registrationLoading: false,
|
||||||
|
recaptchaError: null,
|
||||||
|
registrationError: null
|
||||||
|
};
|
||||||
|
console.log('[config-cache-init] Config cache structure initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize call history for circuit breaker
|
||||||
|
if (!(window as any).__configCallHistory) {
|
||||||
|
(window as any).__configCallHistory = {};
|
||||||
|
console.log('[config-cache-init] Call history initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a global error handler to catch and prevent reload loops
|
||||||
|
const originalError = window.onerror;
|
||||||
|
window.onerror = function(msg, url, lineNo, columnNo, error) {
|
||||||
|
// Check for common reload loop patterns
|
||||||
|
if (typeof msg === 'string' && (
|
||||||
|
msg.includes('Maximum call stack') ||
|
||||||
|
msg.includes('too much recursion') ||
|
||||||
|
msg.includes('RangeError')
|
||||||
|
)) {
|
||||||
|
console.error('[config-cache-init] Potential reload loop detected:', msg);
|
||||||
|
// Prevent default error handling which might cause reload
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call original error handler if it exists
|
||||||
|
if (originalError) {
|
||||||
|
return originalError(msg, url, lineNo, columnNo, error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add unhandled rejection handler
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
if (event.reason?.message?.includes('config') ||
|
||||||
|
event.reason?.message?.includes('reload')) {
|
||||||
|
console.error('[config-cache-init] Unhandled config-related rejection:', event.reason);
|
||||||
|
// Prevent default which might cause reload
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[config-cache-init] Config cache plugin initialized successfully');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import type { RecaptchaConfig, RegistrationConfig } from './types';
|
import type { RecaptchaConfig, RegistrationConfig } from './types';
|
||||||
|
|
||||||
// Global cache storage
|
// Global cache storage - use window object to persist across Vue reactivity cycles
|
||||||
interface ConfigCache {
|
interface ConfigCache {
|
||||||
recaptcha: RecaptchaConfig | null;
|
recaptcha: RecaptchaConfig | null;
|
||||||
registration: RegistrationConfig | null;
|
registration: RegistrationConfig | null;
|
||||||
|
|
@ -16,14 +16,32 @@ interface ConfigCache {
|
||||||
registrationError: string | null;
|
registrationError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let globalConfigCache: ConfigCache = {
|
// Use window object for true persistence across component lifecycle
|
||||||
recaptcha: null,
|
function getGlobalCache(): ConfigCache {
|
||||||
registration: null,
|
if (typeof window === 'undefined') {
|
||||||
recaptchaLoading: false,
|
return {
|
||||||
registrationLoading: false,
|
recaptcha: null,
|
||||||
recaptchaError: null,
|
registration: null,
|
||||||
registrationError: null
|
recaptchaLoading: false,
|
||||||
};
|
registrationLoading: false,
|
||||||
|
recaptchaError: null,
|
||||||
|
registrationError: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(window as any).__configCache) {
|
||||||
|
(window as any).__configCache = {
|
||||||
|
recaptcha: null,
|
||||||
|
registration: null,
|
||||||
|
recaptchaLoading: false,
|
||||||
|
registrationLoading: false,
|
||||||
|
recaptchaError: null,
|
||||||
|
registrationError: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (window as any).__configCache;
|
||||||
|
}
|
||||||
|
|
||||||
// Circuit breaker to prevent rapid successive calls
|
// Circuit breaker to prevent rapid successive calls
|
||||||
const CIRCUIT_BREAKER_THRESHOLD = 5; // Max calls in time window
|
const CIRCUIT_BREAKER_THRESHOLD = 5; // Max calls in time window
|
||||||
|
|
@ -55,8 +73,11 @@ function shouldBlockCall(apiName: string): boolean {
|
||||||
* Get reCAPTCHA configuration with caching and circuit breaker
|
* Get reCAPTCHA configuration with caching and circuit breaker
|
||||||
*/
|
*/
|
||||||
export async function getCachedRecaptchaConfig(): Promise<RecaptchaConfig> {
|
export async function getCachedRecaptchaConfig(): Promise<RecaptchaConfig> {
|
||||||
|
const globalConfigCache = getGlobalCache();
|
||||||
|
|
||||||
// Return cached result if available
|
// Return cached result if available
|
||||||
if (globalConfigCache.recaptcha) {
|
if (globalConfigCache.recaptcha) {
|
||||||
|
console.log('[config-cache] Returning cached reCAPTCHA config');
|
||||||
return globalConfigCache.recaptcha;
|
return globalConfigCache.recaptcha;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,8 +146,11 @@ export async function getCachedRecaptchaConfig(): Promise<RecaptchaConfig> {
|
||||||
* Get registration configuration with caching and circuit breaker
|
* Get registration configuration with caching and circuit breaker
|
||||||
*/
|
*/
|
||||||
export async function getCachedRegistrationConfig(): Promise<RegistrationConfig> {
|
export async function getCachedRegistrationConfig(): Promise<RegistrationConfig> {
|
||||||
|
const globalConfigCache = getGlobalCache();
|
||||||
|
|
||||||
// Return cached result if available
|
// Return cached result if available
|
||||||
if (globalConfigCache.registration) {
|
if (globalConfigCache.registration) {
|
||||||
|
console.log('[config-cache] Returning cached registration config');
|
||||||
return globalConfigCache.registration;
|
return globalConfigCache.registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,14 +268,17 @@ export async function loadAllConfigs(): Promise<{
|
||||||
*/
|
*/
|
||||||
export function clearConfigCache(): void {
|
export function clearConfigCache(): void {
|
||||||
console.log('[config-cache] Clearing all cached configurations');
|
console.log('[config-cache] Clearing all cached configurations');
|
||||||
globalConfigCache = {
|
|
||||||
recaptcha: null,
|
if (typeof window !== 'undefined' && (window as any).__configCache) {
|
||||||
registration: null,
|
(window as any).__configCache = {
|
||||||
recaptchaLoading: false,
|
recaptcha: null,
|
||||||
registrationLoading: false,
|
registration: null,
|
||||||
recaptchaError: null,
|
recaptchaLoading: false,
|
||||||
registrationError: null
|
registrationLoading: false,
|
||||||
};
|
recaptchaError: null,
|
||||||
|
registrationError: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Clear call history for circuit breaker
|
// Clear call history for circuit breaker
|
||||||
Object.keys(callHistory).forEach(key => {
|
Object.keys(callHistory).forEach(key => {
|
||||||
|
|
@ -263,19 +290,21 @@ export function clearConfigCache(): void {
|
||||||
* Get cache status for debugging
|
* Get cache status for debugging
|
||||||
*/
|
*/
|
||||||
export function getConfigCacheStatus(): ConfigCache {
|
export function getConfigCacheStatus(): ConfigCache {
|
||||||
return { ...globalConfigCache };
|
return { ...getGlobalCache() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force reload specific config (bypasses cache)
|
* Force reload specific config (bypasses cache)
|
||||||
*/
|
*/
|
||||||
export async function reloadRecaptchaConfig(): Promise<RecaptchaConfig> {
|
export async function reloadRecaptchaConfig(): Promise<RecaptchaConfig> {
|
||||||
|
const globalConfigCache = getGlobalCache();
|
||||||
globalConfigCache.recaptcha = null;
|
globalConfigCache.recaptcha = null;
|
||||||
globalConfigCache.recaptchaError = null;
|
globalConfigCache.recaptchaError = null;
|
||||||
return getCachedRecaptchaConfig();
|
return getCachedRecaptchaConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reloadRegistrationConfig(): Promise<RegistrationConfig> {
|
export async function reloadRegistrationConfig(): Promise<RegistrationConfig> {
|
||||||
|
const globalConfigCache = getGlobalCache();
|
||||||
globalConfigCache.registration = null;
|
globalConfigCache.registration = null;
|
||||||
globalConfigCache.registrationError = null;
|
globalConfigCache.registrationError = null;
|
||||||
return getCachedRegistrationConfig();
|
return getCachedRegistrationConfig();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue