mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
perf: optimize animated backgrounds for mobile — reduce GPU load and memory pressure
- Add useAnimationLoop hook with FPS throttling (30fps mobile/60fps desktop), Page Visibility API pause, and Telegram WebApp activated/deactivated events - Add useAnimationPause hook for CSS/Framer Motion components - Validate animation config with type allowlist, numeric clamping, JSON size guard - Move ctx.setTransform from per-frame to init/resize across all canvas backgrounds - Move ctx.lineCap to init/resize in vortex, replace Float32Array.set with direct writes - Pre-compute shooting star delay instead of per-frame Math.random - Cap DPR to 1.5 on mobile, reduce particle counts, cap blur to 4px - Disable aurora CSS animation on mobile, remove blur on mobile spotlight - Remove mix-blend-hard-light on mobile gradient-animation - Add NaN/Infinity guard to clampNumber, remove dead CSS variables - Add conditional unmount for Framer Motion backgrounds when paused - Add animationPlayState pause for CSS-animated backgrounds
This commit is contained in:
@@ -7,19 +7,57 @@ import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
||||
|
||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
||||
const MAX_CONFIG_JSON_LENGTH = 10_000;
|
||||
|
||||
const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
]);
|
||||
|
||||
function validateConfig(parsed: unknown): AnimationConfig | null {
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.enabled !== 'boolean') return null;
|
||||
if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type)) return null;
|
||||
if (typeof obj.opacity !== 'number') return null;
|
||||
if (typeof obj.blur !== 'number') return null;
|
||||
if (obj.settings != null && (typeof obj.settings !== 'object' || Array.isArray(obj.settings)))
|
||||
return null;
|
||||
return {
|
||||
enabled: obj.enabled,
|
||||
type: obj.type as BackgroundType,
|
||||
opacity: Math.max(0, Math.min(1, obj.opacity)),
|
||||
blur: Math.max(0, Math.min(50, obj.blur)),
|
||||
settings: (obj.settings as Record<string, unknown>) ?? {},
|
||||
reducedOnMobile: typeof obj.reducedOnMobile === 'boolean' ? obj.reducedOnMobile : true,
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedConfig(): AnimationConfig | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
if (!cached || cached.length > MAX_CONFIG_JSON_LENGTH) return null;
|
||||
return validateConfig(JSON.parse(cached));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefetch the background JS chunk immediately based on localStorage cache.
|
||||
// This starts the download before React even renders, so by the time
|
||||
// Suspense needs the component, the chunk is already loaded.
|
||||
const cachedConfig = getCachedConfig();
|
||||
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
||||
prefetchBackground(cachedConfig.type);
|
||||
@@ -34,18 +72,27 @@ function setCachedConfig(config: AnimationConfig) {
|
||||
}
|
||||
|
||||
export function setCachedAnimationConfig(config: AnimationConfig) {
|
||||
setCachedConfig(config);
|
||||
const validated = validateConfig(config);
|
||||
if (validated) setCachedConfig(validated);
|
||||
}
|
||||
|
||||
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||
const reduced = { ...settings };
|
||||
// 75% reduction (divide by 4) instead of 50% — much less GPU work
|
||||
if (typeof reduced.particleCount === 'number')
|
||||
reduced.particleCount = Math.floor((reduced.particleCount as number) / 2);
|
||||
reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4));
|
||||
if (typeof reduced.particleDensity === 'number')
|
||||
reduced.particleDensity = Math.floor((reduced.particleDensity as number) / 2);
|
||||
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
|
||||
if (typeof reduced.number === 'number')
|
||||
reduced.number = Math.floor((reduced.number as number) / 2);
|
||||
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
|
||||
if ('interactive' in reduced) reduced.interactive = false;
|
||||
if (typeof reduced.lineCount === 'number')
|
||||
reduced.lineCount = Math.max(5, Math.floor(reduced.lineCount / 2));
|
||||
if (typeof reduced.rippleCount === 'number')
|
||||
reduced.rippleCount = Math.max(2, Math.floor(reduced.rippleCount / 2));
|
||||
if (typeof reduced.count === 'number') reduced.count = Math.max(3, Math.floor(reduced.count / 2));
|
||||
if (typeof reduced.rows === 'number') reduced.rows = Math.max(4, Math.floor(reduced.rows * 0.6));
|
||||
if (typeof reduced.cols === 'number') reduced.cols = Math.max(4, Math.floor(reduced.cols * 0.6));
|
||||
return reduced;
|
||||
}
|
||||
|
||||
@@ -58,7 +105,9 @@ export function BackgroundRenderer() {
|
||||
const { data: config } = useQuery({
|
||||
queryKey: ['animation-config'],
|
||||
queryFn: async () => {
|
||||
const result = await brandingApi.getAnimationConfig();
|
||||
const raw = await brandingApi.getAnimationConfig();
|
||||
// Validate and clamp API response same as localStorage
|
||||
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
|
||||
setCachedConfig(result);
|
||||
return result;
|
||||
},
|
||||
@@ -84,17 +133,16 @@ export function BackgroundRenderer() {
|
||||
? reduceMobileSettings(effectiveConfig.settings)
|
||||
: effectiveConfig.settings;
|
||||
|
||||
// Render via portal on document.body with z-index: -1.
|
||||
// This places the animated background BELOW the root stacking context,
|
||||
// preventing Chrome's implicit compositing from promoting every
|
||||
// overlapping element to its own GPU layer (the root cause of flickering).
|
||||
// On mobile, cap blur to 4px max — full blur is extremely GPU-heavy
|
||||
const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0"
|
||||
style={{
|
||||
zIndex: -2,
|
||||
opacity: effectiveConfig.opacity,
|
||||
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
||||
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
|
||||
contain: 'strict',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user