diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css index 80f57928..d3760acd 100644 --- a/static/css/components/modal/_base.css +++ b/static/css/components/modal/_base.css @@ -13,6 +13,16 @@ overflow: auto; /* Change from hidden to auto to allow scrolling */ } +/* Software-rendering fallback (set by applyModalBackdropBlurPolicy): a + full-viewport backdrop-filter forces per-frame CPU rasterization of + everything behind the modal and freezes the browser (issue #1092) */ +html.no-modal-backdrop-blur .modal, +html.no-modal-backdrop-blur .delete-modal, +html.no-modal-backdrop-blur .batch-preview-select-all { + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + /* Prevent body scroll when modal is open */ body.modal-open { position: fixed; diff --git a/static/css/components/modal/download-modal.css b/static/css/components/modal/download-modal.css index 49f606c0..72677979 100644 --- a/static/css/components/modal/download-modal.css +++ b/static/css/components/modal/download-modal.css @@ -921,8 +921,8 @@ position: sticky; top: 0; z-index: 1; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(var(--modal-backdrop-blur, 6px)); + -webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px)); } .batch-preview-select-all input[type="checkbox"] { diff --git a/static/js/core.js b/static/js/core.js index f7a901f8..2088c4e5 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -12,6 +12,7 @@ import { helpManager } from './managers/HelpManager.js'; import { doctorManager } from './managers/DoctorManager.js'; import { bannerService } from './managers/BannerService.js'; import { initTheme, initBackToTop } from './utils/uiHelpers.js'; +import { applyModalBackdropBlurPolicy } from './utils/renderingCapability.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { i18n } from './i18n/index.js'; import { onboardingManager } from './managers/OnboardingManager.js'; @@ -33,7 +34,11 @@ export class AppCore { if (this.initialized) return; console.log('AppCore: Initializing...'); - + + // Disable full-viewport backdrop blur under software rendering before + // anything can open a modal (issue #1092) + applyModalBackdropBlurPolicy(); + // Initialize i18n first window.i18n = i18n; // Wait for i18n to be ready diff --git a/static/js/utils/renderingCapability.js b/static/js/utils/renderingCapability.js new file mode 100644 index 00000000..53cb8b4d --- /dev/null +++ b/static/js/utils/renderingCapability.js @@ -0,0 +1,66 @@ +/** + * Software-rendering detection for degrading expensive visual effects. + * + * With hardware acceleration disabled (or a GPU blocklisted), Chrome rasterizes + * in software. A full-viewport `backdrop-filter: blur()` then forces a per-frame + * CPU blur over everything painted behind the modal, freezing the entire + * browser (issue #1092). When software rendering is detected we add the + * `no-modal-backdrop-blur` class to , and CSS drops the backdrop blur. + */ + +const SOFTWARE_RENDERER_PATTERN = /swiftshader|llvmpipe|softpipe|software|basic render/i; + +/** + * Check a WebGL renderer string against known software rasterizers. + * @param {string} renderer - UNMASKED_RENDERER_WEBGL string + * @returns {boolean} + */ +export function isSoftwareRendererString(renderer) { + return SOFTWARE_RENDERER_PATTERN.test(renderer || ''); +} + +/** + * Read the unmasked WebGL renderer string, or null when unavailable/masked. + * @returns {string|null} + */ +function getWebGLRendererString() { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + if (!gl) return null; + + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); + const renderer = debugInfo + ? String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || '') + : ''; + + const loseContext = gl.getExtension('WEBGL_lose_context'); + if (loseContext) loseContext.loseContext(); + + return renderer || null; +} + +/** + * Heuristic: is the browser rasterizing in software? + * - No WebGL at all: no evidence of GPU acceleration, assume software. + * - Masked renderer string or detection failure: cannot tell, keep effects on. + * @returns {boolean} + */ +export function isSoftwareRendering() { + try { + const renderer = getWebGLRendererString(); + if (renderer === null) { + return true; + } + return isSoftwareRendererString(renderer); + } catch (error) { + return true; + } +} + +/** + * Toggle the blur-disabling class on . Runs once at app startup. + * @param {boolean} [isSoftware] - Override for tests; defaults to detection. + */ +export function applyModalBackdropBlurPolicy(isSoftware = isSoftwareRendering()) { + document.documentElement.classList.toggle('no-modal-backdrop-blur', isSoftware); +} diff --git a/tests/frontend/utils/renderingCapability.test.js b/tests/frontend/utils/renderingCapability.test.js new file mode 100644 index 00000000..d0a56f57 --- /dev/null +++ b/tests/frontend/utils/renderingCapability.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + isSoftwareRendererString, + applyModalBackdropBlurPolicy, +} from '../../../static/js/utils/renderingCapability.js'; + +describe('isSoftwareRendererString', () => { + it('detects SwiftShader (Chrome with hardware acceleration disabled)', () => { + expect(isSoftwareRendererString( + 'WebKit WebGL SwiftShader' + )).toBe(true); + expect(isSoftwareRendererString( + 'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)' + )).toBe(true); + }); + + it('detects Mesa software rasterizers (Linux)', () => { + expect(isSoftwareRendererString('llvmpipe (LLVM 17.0.6, 256 bits)')).toBe(true); + expect(isSoftwareRendererString('softpipe')).toBe(true); + }); + + it('detects generic software renderer strings', () => { + expect(isSoftwareRendererString('Software Renderer')).toBe(true); + expect(isSoftwareRendererString('Microsoft Basic Render Driver')).toBe(true); + }); + + it('accepts hardware GPU strings', () => { + expect(isSoftwareRendererString( + 'ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11 vs_5_0 ps_5_0, D3D11)' + )).toBe(false); + expect(isSoftwareRendererString( + 'ANGLE (AMD, AMD Radeon RX 7900 XTX (0x0000744C) Direct3D11 vs_5_0 ps_5_0, D3D11)' + )).toBe(false); + expect(isSoftwareRendererString('Apple M4 Pro')).toBe(false); + expect(isSoftwareRendererString('Mesa Intel(R) UHD Graphics 620 (KBL GT2)')).toBe(false); + }); + + it('handles empty input', () => { + expect(isSoftwareRendererString('')).toBe(false); + expect(isSoftwareRendererString(null)).toBe(false); + }); +}); + +describe('applyModalBackdropBlurPolicy', () => { + beforeEach(() => { + document.documentElement.classList.remove('no-modal-backdrop-blur'); + }); + + it('adds the disabling class under software rendering', () => { + applyModalBackdropBlurPolicy(true); + expect(document.documentElement.classList.contains('no-modal-backdrop-blur')).toBe(true); + }); + + it('removes the disabling class under hardware rendering', () => { + document.documentElement.classList.add('no-modal-backdrop-blur'); + applyModalBackdropBlurPolicy(false); + expect(document.documentElement.classList.contains('no-modal-backdrop-blur')).toBe(false); + }); +});