fix: eliminate forced reflows in TOC ScrollSpy + revert Bootstrap defer

- 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 <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-11-29 10:52:25 -06:00
parent d5a2fd2702
commit 6004420620
4 changed files with 95 additions and 73 deletions

View File

@@ -17,16 +17,12 @@ if (!defined('ABSPATH')) {
* Estos CSS se cargan con media="print" y onload="this.media='all'" * Estos CSS se cargan con media="print" y onload="this.media='all'"
* para evitar bloquear el renderizado inicial. * para evitar bloquear el renderizado inicial.
* *
* IMPORTANTE: Bootstrap se difiere porque su CSS crítico se inyecta * NOTA: Bootstrap NO está diferido porque causa CLS alto (0.954).
* inline via CriticalBootstrapService (~8KB subset en <head>). * Bootstrap debe cargar bloqueante para evitar layout shifts.
* El Bootstrap completo (31KB) carga después sin bloquear.
* *
* @since 1.0.21 * @since 1.0.21
* @since 1.0.22 Added roi-bootstrap to deferred list
*/ */
define('ROI_DEFERRED_CSS', [ define('ROI_DEFERRED_CSS', [
// Framework CSS (critical subset inline, full deferred)
'roi-bootstrap',
// Componentes específicos (below the fold) // Componentes específicos (below the fold)
'roi-badges', 'roi-badges',
'roi-pagination', 'roi-pagination',
@@ -99,22 +95,18 @@ add_action('wp_enqueue_scripts', 'roi_enqueue_fonts', 1);
/** /**
* Enqueue Bootstrap 5 styles and scripts * Enqueue Bootstrap 5 styles and scripts
* *
* OPTIMIZACIÓN PageSpeed: Bootstrap CSS se carga diferido. * NOTA: Bootstrap debe cargar BLOQUEANTE (media='all').
* El CSS crítico (container, navbar, flexbox) se inyecta inline * Diferirlo causa CLS alto (0.954) por layout shifts.
* via CriticalBootstrapService antes de que Bootstrap cargue. * CriticalBootstrapService queda disponible para futuras optimizaciones.
*
* @see Shared/Infrastructure/Services/CriticalBootstrapService.php
* @see Assets/css/critical-bootstrap.css
*/ */
function roi_enqueue_bootstrap() { function roi_enqueue_bootstrap() {
// Bootstrap CSS - DIFERIDO: critical subset inline, full deferred // Bootstrap CSS - BLOQUEANTE para evitar CLS
// media='print' + onload cambia a 'all' cuando carga (ver ROI_DEFERRED_CSS)
wp_enqueue_style( wp_enqueue_style(
'roi-bootstrap', 'roi-bootstrap',
get_template_directory_uri() . '/Assets/Vendor/Bootstrap/Css/bootstrap.min.css', get_template_directory_uri() . '/Assets/Vendor/Bootstrap/Css/bootstrap.min.css',
array('roi-fonts'), array('roi-fonts'),
'5.3.2', '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) // Bootstrap Icons CSS - SUBSET OPTIMIZADO (Fase 4.1 PageSpeed)

View File

@@ -428,12 +428,16 @@ final class TableOfContentsRenderer implements RendererInterface
return ''; 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 = <<<JS $script = <<<JS
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var tocLinks = document.querySelectorAll('.toc-link'); var tocLinks = document.querySelectorAll('.toc-link');
var offsetTop = {$scrollOffset}; var offsetTop = {$scrollOffset};
// Smooth scroll on click - usa getBoundingClientRect una sola vez por click
tocLinks.forEach(function(link) { tocLinks.forEach(function(link) {
link.addEventListener('click', function(e) { link.addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
@@ -452,36 +456,93 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// ScrollSpy // ScrollSpy con Intersection Observer (sin forced reflows)
var sections = []; var sections = [];
var sectionMap = {};
tocLinks.forEach(function(link) { tocLinks.forEach(function(link) {
var id = link.getAttribute('href').substring(1); var id = link.getAttribute('href').substring(1);
var section = document.getElementById(id); var section = document.getElementById(id);
if (section) { if (section) {
sections.push({ id: id, element: section }); sections.push(section);
sectionMap[id] = link;
} }
}); });
function updateActiveLink() { if (sections.length === 0) return;
var scrollPosition = window.pageYOffset + offsetTop + 50;
var currentSection = '';
sections.forEach(function(section) { // Track de secciones visibles para determinar la activa
if (section.element.offsetTop <= scrollPosition) { var visibleSections = new Set();
currentSection = section.id; 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) { if (topMostSection && topMostSection !== currentActive) {
link.classList.remove('active'); // Remover active de todos
if (link.getAttribute('href') === '#' + currentSection) { tocLinks.forEach(function(link) {
link.classList.add('active'); link.classList.remove('active');
});
// Activar el correspondiente
if (sectionMap[topMostSection]) {
sectionMap[topMostSection].classList.add('active');
currentActive = topMostSection;
} }
}); }
} }
window.addEventListener('scroll', updateActiveLink); // Intersection Observer - el navegador maneja la detección eficientemente
updateActiveLink(); 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;
}
}
});
}); });
</script> </script>
JS; JS;

View File

@@ -4,28 +4,22 @@ declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Wordpress; namespace ROITheme\Shared\Infrastructure\Wordpress;
use ROITheme\Shared\Infrastructure\Services\CriticalCSSService; 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: * RESPONSABILIDAD:
* - Registrar hook wp_head (priority 0) para Critical Bootstrap
* - Registrar hook wp_head (priority 1) para Critical Component CSS * - Registrar hook wp_head (priority 1) para Critical Component CSS
* - Delegar renderizado a servicios especializados * - Delegar renderizado a CriticalCSSService
* *
* FLUJO: * FLUJO:
* 1. wp_head (priority 0) → CriticalBootstrapService::render() * 1. wp_head (priority 1) → CriticalCSSService::render()
* - Lee Assets/css/critical-bootstrap.css (subset ~8KB)
* - Output: <style id="roi-critical-bootstrap">...</style>
*
* 2. wp_head (priority 1) → CriticalCSSService::render()
* - Consulta BD por componentes is_critical=true * - Consulta BD por componentes is_critical=true
* - Genera CSS usando Renderers * - Genera CSS usando Renderers
* - Output: <style id="roi-critical-css">...</style> * - Output: <style id="roi-critical-css">...</style>
* *
* 3. Bootstrap completo se carga diferido (media="print" + onload) * NOTA: CriticalBootstrapService está DESHABILITADO porque diferir
* - No bloquea renderizado inicial * Bootstrap causa CLS alto (0.954). Bootstrap carga bloqueante.
* *
* PATRÓN: * PATRÓN:
* - SRP: Solo registra hooks, delega lógica a servicios * - SRP: Solo registra hooks, delega lógica a servicios
@@ -37,8 +31,7 @@ use ROITheme\Shared\Infrastructure\Services\CriticalBootstrapService;
final class CriticalCSSHooksRegistrar final class CriticalCSSHooksRegistrar
{ {
public function __construct( public function __construct(
private readonly CriticalCSSService $criticalCSSService, private readonly CriticalCSSService $criticalCSSService
private readonly CriticalBootstrapService $criticalBootstrapService
) {} ) {}
/** /**
@@ -46,25 +39,10 @@ final class CriticalCSSHooksRegistrar
*/ */
public function register(): void public function register(): void
{ {
// Priority 0 = Critical Bootstrap (primero, antes de componentes) // Priority 1 = Critical Component CSS (hero, navbar, top-notification-bar)
add_action('wp_head', [$this, 'renderCriticalBootstrap'], 0);
// Priority 1 = Critical Component CSS (después de Bootstrap)
add_action('wp_head', [$this, 'renderCriticalCSS'], 1); add_action('wp_head', [$this, 'renderCriticalCSS'], 1);
} }
/**
* Callback para wp_head - Critical Bootstrap
*
* Inyecta subset de Bootstrap (~8KB) inline:
* - Container, flexbox, navbar, dropdown
* - Output: <style id="roi-critical-bootstrap">...</style>
*/
public function renderCriticalBootstrap(): void
{
$this->criticalBootstrapService->render();
}
/** /**
* Callback para wp_head - Critical Component CSS * Callback para wp_head - Critical Component CSS
* *

View File

@@ -283,28 +283,19 @@ function roi_render_component(string $componentName): string {
* Registra hooks para inyectar CSS crítico en <head> * Registra hooks para inyectar CSS crítico en <head>
* *
* FLUJO: * FLUJO:
* 1. wp_head (priority 0) → CriticalBootstrapService::render() * 1. wp_head (priority 1) → CriticalCSSService::render()
* - Lee Assets/css/critical-bootstrap.css (subset ~8KB)
* - Output: <style id="roi-critical-bootstrap">...</style>
*
* 2. wp_head (priority 1) → CriticalCSSService::render()
* - Consulta BD por componentes con is_critical=true * - Consulta BD por componentes con is_critical=true
* - Genera CSS usando los métodos públicos generateCSS() de los Renderers * - Genera CSS usando los métodos públicos generateCSS() de los Renderers
* - Output: <style id="roi-critical-css">...</style> * - Output: <style id="roi-critical-css">...</style>
* *
* 3. Bootstrap completo se carga diferido (media="print" + onload) * NOTA: CriticalBootstrapService está DESHABILITADO porque diferir
* - Ver Inc/enqueue-scripts.php * 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() { add_action('after_setup_theme', function() {
$criticalCSSService = roi_get_critical_css_service(); $criticalCSSService = roi_get_critical_css_service();
$criticalBootstrapService = \ROITheme\Shared\Infrastructure\Services\CriticalBootstrapService::getInstance(); $hooksRegistrar = new \ROITheme\Shared\Infrastructure\Wordpress\CriticalCSSHooksRegistrar($criticalCSSService);
$hooksRegistrar = new \ROITheme\Shared\Infrastructure\Wordpress\CriticalCSSHooksRegistrar(
$criticalCSSService,
$criticalBootstrapService
);
$hooksRegistrar->register(); $hooksRegistrar->register();
}); });