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

@@ -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 = <<<JS
<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;
}
}
});
});
</script>
JS;