From 600442062039dc975c1faab2b8917f022ab7f157 Mon Sep 17 00:00:00 2001 From: FrankZamora Date: Sat, 29 Nov 2025 10:52:25 -0600 Subject: [PATCH] fix: eliminate forced reflows in TOC ScrollSpy + revert Bootstrap defer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace scroll event listener with Intersection Observer in TableOfContentsRenderer - Eliminates ~100ms forced reflows from offsetTop reads during scroll - Revert Bootstrap CSS to blocking (media='all') - deferring caused CLS 0.954 - Keep CriticalBootstrapService available for future optimization - Simplify CriticalCSSHooksRegistrar to only use CriticalCSSService 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Inc/enqueue-scripts.php | 22 ++--- .../Ui/TableOfContentsRenderer.php | 91 ++++++++++++++++--- .../Wordpress/CriticalCSSHooksRegistrar.php | 36 ++------ functions-addon.php | 19 +--- 4 files changed, 95 insertions(+), 73 deletions(-) diff --git a/Inc/enqueue-scripts.php b/Inc/enqueue-scripts.php index 5a77be2f..9c812ba2 100644 --- a/Inc/enqueue-scripts.php +++ b/Inc/enqueue-scripts.php @@ -17,16 +17,12 @@ if (!defined('ABSPATH')) { * Estos CSS se cargan con media="print" y onload="this.media='all'" * para evitar bloquear el renderizado inicial. * - * IMPORTANTE: Bootstrap se difiere porque su CSS crítico se inyecta - * inline via CriticalBootstrapService (~8KB subset en ). - * El Bootstrap completo (31KB) carga después sin bloquear. + * NOTA: Bootstrap NO está diferido porque causa CLS alto (0.954). + * Bootstrap debe cargar bloqueante para evitar layout shifts. * * @since 1.0.21 - * @since 1.0.22 Added roi-bootstrap to deferred list */ define('ROI_DEFERRED_CSS', [ - // Framework CSS (critical subset inline, full deferred) - 'roi-bootstrap', // Componentes específicos (below the fold) 'roi-badges', 'roi-pagination', @@ -99,22 +95,18 @@ add_action('wp_enqueue_scripts', 'roi_enqueue_fonts', 1); /** * Enqueue Bootstrap 5 styles and scripts * - * OPTIMIZACIÓN PageSpeed: Bootstrap CSS se carga diferido. - * El CSS crítico (container, navbar, flexbox) se inyecta inline - * via CriticalBootstrapService antes de que Bootstrap cargue. - * - * @see Shared/Infrastructure/Services/CriticalBootstrapService.php - * @see Assets/css/critical-bootstrap.css + * NOTA: Bootstrap debe cargar BLOQUEANTE (media='all'). + * Diferirlo causa CLS alto (0.954) por layout shifts. + * CriticalBootstrapService queda disponible para futuras optimizaciones. */ function roi_enqueue_bootstrap() { - // Bootstrap CSS - DIFERIDO: critical subset inline, full deferred - // media='print' + onload cambia a 'all' cuando carga (ver ROI_DEFERRED_CSS) + // Bootstrap CSS - BLOQUEANTE para evitar CLS wp_enqueue_style( 'roi-bootstrap', get_template_directory_uri() . '/Assets/Vendor/Bootstrap/Css/bootstrap.min.css', array('roi-fonts'), '5.3.2', - 'print' // Diferido - CSS crítico inline via CriticalBootstrapService + 'all' // Bloqueante - diferirlo causa CLS alto ); // Bootstrap Icons CSS - SUBSET OPTIMIZADO (Fase 4.1 PageSpeed) diff --git a/Public/TableOfContents/Infrastructure/Ui/TableOfContentsRenderer.php b/Public/TableOfContents/Infrastructure/Ui/TableOfContentsRenderer.php index dcd45efc..83ecb097 100644 --- a/Public/TableOfContents/Infrastructure/Ui/TableOfContentsRenderer.php +++ b/Public/TableOfContents/Infrastructure/Ui/TableOfContentsRenderer.php @@ -428,12 +428,16 @@ final class TableOfContentsRenderer implements RendererInterface return ''; } + // Intersection Observer elimina forced reflows del ScrollSpy + // En lugar de leer offsetTop en cada scroll event (60+/seg), + // el navegador notifica solo cuando secciones entran/salen del viewport $script = << document.addEventListener('DOMContentLoaded', function() { var tocLinks = document.querySelectorAll('.toc-link'); var offsetTop = {$scrollOffset}; + // Smooth scroll on click - usa getBoundingClientRect una sola vez por click tocLinks.forEach(function(link) { link.addEventListener('click', function(e) { e.preventDefault(); @@ -452,36 +456,93 @@ document.addEventListener('DOMContentLoaded', function() { }); }); - // ScrollSpy + // ScrollSpy con Intersection Observer (sin forced reflows) var sections = []; + var sectionMap = {}; + tocLinks.forEach(function(link) { var id = link.getAttribute('href').substring(1); var section = document.getElementById(id); if (section) { - sections.push({ id: id, element: section }); + sections.push(section); + sectionMap[id] = link; } }); - function updateActiveLink() { - var scrollPosition = window.pageYOffset + offsetTop + 50; - var currentSection = ''; + if (sections.length === 0) return; - sections.forEach(function(section) { - if (section.element.offsetTop <= scrollPosition) { - currentSection = section.id; + // Track de secciones visibles para determinar la activa + var visibleSections = new Set(); + var currentActive = null; + + function updateActiveFromVisible() { + if (visibleSections.size === 0) return; + + // Encontrar la sección visible más arriba en el documento + var topMostSection = null; + var topMostPosition = Infinity; + + visibleSections.forEach(function(id) { + var section = document.getElementById(id); + if (section) { + var rect = section.getBoundingClientRect(); + if (rect.top < topMostPosition) { + topMostPosition = rect.top; + topMostSection = id; + } } }); - tocLinks.forEach(function(link) { - link.classList.remove('active'); - if (link.getAttribute('href') === '#' + currentSection) { - link.classList.add('active'); + if (topMostSection && topMostSection !== currentActive) { + // Remover active de todos + tocLinks.forEach(function(link) { + link.classList.remove('active'); + }); + + // Activar el correspondiente + if (sectionMap[topMostSection]) { + sectionMap[topMostSection].classList.add('active'); + currentActive = topMostSection; } - }); + } } - window.addEventListener('scroll', updateActiveLink); - updateActiveLink(); + // Intersection Observer - el navegador maneja la detección eficientemente + var observerOptions = { + root: null, + rootMargin: '-' + offsetTop + 'px 0px -50% 0px', + threshold: 0 + }; + + var observer = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + var id = entry.target.id; + if (entry.isIntersecting) { + visibleSections.add(id); + } else { + visibleSections.delete(id); + } + }); + + // Usar requestAnimationFrame para batch DOM updates + requestAnimationFrame(updateActiveFromVisible); + }, observerOptions); + + // Observar todas las secciones + sections.forEach(function(section) { + observer.observe(section); + }); + + // Activar la primera sección visible al cargar + requestAnimationFrame(function() { + if (visibleSections.size === 0 && sections.length > 0) { + var firstId = sections[0].id; + if (sectionMap[firstId]) { + sectionMap[firstId].classList.add('active'); + currentActive = firstId; + } + } + }); }); JS; diff --git a/Shared/Infrastructure/Wordpress/CriticalCSSHooksRegistrar.php b/Shared/Infrastructure/Wordpress/CriticalCSSHooksRegistrar.php index a57674fa..1943db71 100644 --- a/Shared/Infrastructure/Wordpress/CriticalCSSHooksRegistrar.php +++ b/Shared/Infrastructure/Wordpress/CriticalCSSHooksRegistrar.php @@ -4,28 +4,22 @@ declare(strict_types=1); namespace ROITheme\Shared\Infrastructure\Wordpress; use ROITheme\Shared\Infrastructure\Services\CriticalCSSService; -use ROITheme\Shared\Infrastructure\Services\CriticalBootstrapService; /** - * Registra hooks wp_head para inyectar CSS crítico + * Registra hook wp_head para inyectar CSS crítico de componentes * * RESPONSABILIDAD: - * - Registrar hook wp_head (priority 0) para Critical Bootstrap * - Registrar hook wp_head (priority 1) para Critical Component CSS - * - Delegar renderizado a servicios especializados + * - Delegar renderizado a CriticalCSSService * * FLUJO: - * 1. wp_head (priority 0) → CriticalBootstrapService::render() - * - Lee Assets/css/critical-bootstrap.css (subset ~8KB) - * - Output: - * - * 2. wp_head (priority 1) → CriticalCSSService::render() + * 1. wp_head (priority 1) → CriticalCSSService::render() * - Consulta BD por componentes is_critical=true * - Genera CSS usando Renderers * - Output: * - * 3. Bootstrap completo se carga diferido (media="print" + onload) - * - No bloquea renderizado inicial + * NOTA: CriticalBootstrapService está DESHABILITADO porque diferir + * Bootstrap causa CLS alto (0.954). Bootstrap carga bloqueante. * * PATRÓN: * - SRP: Solo registra hooks, delega lógica a servicios @@ -37,8 +31,7 @@ use ROITheme\Shared\Infrastructure\Services\CriticalBootstrapService; final class CriticalCSSHooksRegistrar { public function __construct( - private readonly CriticalCSSService $criticalCSSService, - private readonly CriticalBootstrapService $criticalBootstrapService + private readonly CriticalCSSService $criticalCSSService ) {} /** @@ -46,25 +39,10 @@ final class CriticalCSSHooksRegistrar */ public function register(): void { - // Priority 0 = Critical Bootstrap (primero, antes de componentes) - add_action('wp_head', [$this, 'renderCriticalBootstrap'], 0); - - // Priority 1 = Critical Component CSS (después de Bootstrap) + // Priority 1 = Critical Component CSS (hero, navbar, top-notification-bar) add_action('wp_head', [$this, 'renderCriticalCSS'], 1); } - /** - * Callback para wp_head - Critical Bootstrap - * - * Inyecta subset de Bootstrap (~8KB) inline: - * - Container, flexbox, navbar, dropdown - * - Output: - */ - public function renderCriticalBootstrap(): void - { - $this->criticalBootstrapService->render(); - } - /** * Callback para wp_head - Critical Component CSS * diff --git a/functions-addon.php b/functions-addon.php index ef2358ef..5d622d87 100644 --- a/functions-addon.php +++ b/functions-addon.php @@ -283,28 +283,19 @@ function roi_render_component(string $componentName): string { * Registra hooks para inyectar CSS crítico en * * FLUJO: - * 1. wp_head (priority 0) → CriticalBootstrapService::render() - * - Lee Assets/css/critical-bootstrap.css (subset ~8KB) - * - Output: - * - * 2. wp_head (priority 1) → CriticalCSSService::render() + * 1. wp_head (priority 1) → CriticalCSSService::render() * - Consulta BD por componentes con is_critical=true * - Genera CSS usando los métodos públicos generateCSS() de los Renderers * - Output: * - * 3. Bootstrap completo se carga diferido (media="print" + onload) - * - Ver Inc/enqueue-scripts.php + * NOTA: CriticalBootstrapService está DESHABILITADO porque diferir + * Bootstrap causa CLS alto (0.954). Bootstrap carga bloqueante. * - * 4. Cuando los Renderers ejecutan, detectan is_critical y omiten CSS inline + * Cuando los Renderers ejecutan, detectan is_critical y omiten CSS inline. */ add_action('after_setup_theme', function() { $criticalCSSService = roi_get_critical_css_service(); - $criticalBootstrapService = \ROITheme\Shared\Infrastructure\Services\CriticalBootstrapService::getInstance(); - - $hooksRegistrar = new \ROITheme\Shared\Infrastructure\Wordpress\CriticalCSSHooksRegistrar( - $criticalCSSService, - $criticalBootstrapService - ); + $hooksRegistrar = new \ROITheme\Shared\Infrastructure\Wordpress\CriticalCSSHooksRegistrar($criticalCSSService); $hooksRegistrar->register(); });