Files
roi-theme/Assets/Js/adsense-loader.js
FrankZamora 5971f2c971 feat(js): switch to eager loading to eliminate layout shift
Instead of lazy loading slots when they enter the viewport (which
causes layout shift when unfilled slots collapse), now all slots
are activated immediately on page load.

- Slots start collapsed (max-height:0, opacity:0) via CSS
- All slots are activated with 100ms stagger to avoid rate limiting
- Only slots confirmed as 'filled' expand and become visible
- Unfilled slots remain collapsed - zero layout shift

This completely eliminates the CLS issue where content would jump
when ad slots were hidden after entering the viewport.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 17:11:08 -06:00

667 lines
22 KiB
JavaScript

/**
* AdSense Lazy Loader con Intersection Observer
*
* Carga anuncios AdSense individualmente cuando entran al viewport,
* detecta si reciben contenido, y oculta slots vacios.
*
* @package ROI_Theme
* @since 1.5.0
* @version 2.0.0 - Refactorizado con Intersection Observer
*/
(function() {
'use strict';
// =========================================================================
// CONFIGURACION
// =========================================================================
/**
* Configuracion por defecto, sobrescrita por window.roiAdsenseConfig
*/
var DEFAULT_CONFIG = {
lazyEnabled: true,
rootMargin: '200px 0px',
fillTimeout: 5000,
debug: false
};
/**
* Obtiene configuracion desde wp_localize_script o usa defaults
*/
function getConfig() {
var wpConfig = window.roiAdsenseConfig || {};
return {
lazyEnabled: typeof wpConfig.lazyEnabled !== 'undefined' ? wpConfig.lazyEnabled : DEFAULT_CONFIG.lazyEnabled,
rootMargin: wpConfig.rootMargin || DEFAULT_CONFIG.rootMargin,
fillTimeout: typeof wpConfig.fillTimeout !== 'undefined' ? parseInt(wpConfig.fillTimeout, 10) : DEFAULT_CONFIG.fillTimeout,
debug: typeof wpConfig.debug !== 'undefined' ? wpConfig.debug : DEFAULT_CONFIG.debug
};
}
var CONFIG = getConfig();
// =========================================================================
// ESTADO GLOBAL
// =========================================================================
var libraryLoaded = false;
var libraryLoading = false;
var libraryLoadFailed = false;
var loadRetryCount = 0;
var MAX_LOAD_RETRIES = 1;
var RETRY_DELAY = 2000;
/** @type {IntersectionObserver|null} */
var slotObserver = null;
/** @type {Map<Element, MutationObserver>} */
var fillObservers = new Map();
/** @type {Map<Element, number>} */
var fillTimeouts = new Map();
/** @type {Set<Element>} */
var activatedSlots = new Set();
/** @type {Array<Function>} */
var pendingActivations = [];
// =========================================================================
// LOGGING
// =========================================================================
/**
* Log condicional basado en CONFIG.debug
* @param {string} message
* @param {string} [level='log'] - 'log', 'warn', 'error'
*/
function debugLog(message, level) {
if (!CONFIG.debug || typeof console === 'undefined') {
return;
}
level = level || 'log';
var prefix = '[AdSense Lazy] ';
if (level === 'error') {
console.error(prefix + message);
} else if (level === 'warn') {
console.warn(prefix + message);
} else {
console.log(prefix + message);
}
}
// =========================================================================
// DETECCION DE SOPORTE
// =========================================================================
/**
* Verifica si el navegador soporta Intersection Observer
*/
function hasIntersectionObserverSupport() {
return typeof window.IntersectionObserver !== 'undefined';
}
/**
* Verifica si el navegador soporta MutationObserver
*/
function hasMutationObserverSupport() {
return typeof window.MutationObserver !== 'undefined';
}
// =========================================================================
// CARGA DE BIBLIOTECA ADSENSE
// =========================================================================
/**
* Carga la biblioteca adsbygoogle.js
* @param {Function} onSuccess
* @param {Function} onError
*/
function loadAdSenseLibrary(onSuccess, onError) {
if (libraryLoaded) {
debugLog('Biblioteca ya cargada');
onSuccess();
return;
}
if (libraryLoading) {
debugLog('Biblioteca en proceso de carga, encolando callback');
pendingActivations.push(onSuccess);
return;
}
libraryLoading = true;
debugLog('Cargando biblioteca adsbygoogle.js...');
var scriptTags = document.querySelectorAll('script[data-adsense-script]');
if (scriptTags.length === 0) {
debugLog('No se encontro script[data-adsense-script]', 'warn');
libraryLoading = false;
onError();
return;
}
var oldScript = scriptTags[0];
var newScript = document.createElement('script');
newScript.src = oldScript.src;
newScript.async = true;
if (oldScript.getAttribute('crossorigin')) {
newScript.crossOrigin = oldScript.getAttribute('crossorigin');
}
newScript.onload = function() {
debugLog('Biblioteca cargada exitosamente');
libraryLoaded = true;
libraryLoading = false;
window.adsbygoogle = window.adsbygoogle || [];
// Ejecutar callbacks pendientes
onSuccess();
while (pendingActivations.length > 0) {
var callback = pendingActivations.shift();
callback();
}
};
newScript.onerror = function() {
debugLog('Error cargando biblioteca (intento ' + (loadRetryCount + 1) + ')', 'error');
libraryLoading = false;
if (loadRetryCount < MAX_LOAD_RETRIES) {
loadRetryCount++;
debugLog('Reintentando en ' + RETRY_DELAY + 'ms...');
setTimeout(function() {
loadAdSenseLibrary(onSuccess, onError);
}, RETRY_DELAY);
} else {
debugLog('Maximo de reintentos alcanzado', 'error');
libraryLoadFailed = true;
onError();
}
};
oldScript.parentNode.replaceChild(newScript, oldScript);
}
/**
* Marca todos los slots como error cuando la biblioteca falla
*/
function markAllSlotsAsError() {
var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]');
slots.forEach(function(slot) {
slot.classList.add('roi-ad-error');
cleanupSlot(slot);
});
debugLog('Todos los slots marcados como error', 'error');
}
// =========================================================================
// ACTIVACION DE SLOTS
// =========================================================================
/**
* Activa un slot individual ejecutando adsbygoogle.push()
* @param {Element} slot
*/
function activateSlot(slot) {
if (activatedSlots.has(slot)) {
debugLog('Slot ya activado, omitiendo');
return;
}
if (libraryLoadFailed) {
debugLog('Biblioteca fallida, marcando slot como error');
slot.classList.add('roi-ad-error');
return;
}
activatedSlots.add(slot);
var doActivation = function() {
var ins = slot.querySelector('ins.adsbygoogle');
if (!ins) {
debugLog('No se encontro <ins> en slot', 'warn');
slot.classList.add('roi-ad-empty');
return;
}
debugLog('Activando slot: ' + (ins.getAttribute('data-ad-slot') || 'unknown'));
// Ejecutar push
try {
window.adsbygoogle = window.adsbygoogle || [];
window.adsbygoogle.push({});
} catch (e) {
debugLog('Error en push: ' + e.message, 'error');
slot.classList.add('roi-ad-error');
return;
}
// Iniciar observacion de llenado
startFillDetection(slot, ins);
};
// Si la biblioteca ya cargo, activar inmediatamente
if (libraryLoaded) {
doActivation();
} else {
// Cargar biblioteca y luego activar
loadAdSenseLibrary(doActivation, function() {
markAllSlotsAsError();
});
}
}
// =========================================================================
// DETECCION DE LLENADO
// =========================================================================
/** @type {Map<Element, number>} */
var pollIntervals = new Map();
/** Intervalo de polling rapido en ms */
var POLL_INTERVAL = 50;
/** Maximo de intentos de polling (50ms * 60 = 3 segundos max) */
var MAX_POLL_ATTEMPTS = 60;
/**
* Inicia la deteccion de llenado para un slot
* @param {Element} slot
* @param {Element} ins
*/
function startFillDetection(slot, ins) {
// Verificar inmediatamente si ya tiene contenido
if (checkFillStatus(slot, ins)) {
return;
}
// Estrategia: Polling rapido (50ms) para detectar data-ad-status lo antes posible.
// AdSense establece data-ad-status muy rapido despues de inyectar el iframe,
// pero MutationObserver a veces no lo detecta inmediatamente.
var pollCount = 0;
var pollId = setInterval(function() {
pollCount++;
if (checkFillStatus(slot, ins)) {
// Estado detectado, limpiar polling
clearInterval(pollId);
pollIntervals.delete(slot);
return;
}
// Si alcanzamos el maximo de intentos, marcar como vacio
if (pollCount >= MAX_POLL_ATTEMPTS) {
debugLog('Polling timeout alcanzado (' + (pollCount * POLL_INTERVAL) + 'ms)');
clearInterval(pollId);
pollIntervals.delete(slot);
markSlotEmpty(slot);
}
}, POLL_INTERVAL);
pollIntervals.set(slot, pollId);
}
/**
* Verifica el estado de llenado de un slot
* @param {Element} slot
* @param {Element} ins
* @returns {boolean} true si el estado fue determinado (filled o empty)
*/
function checkFillStatus(slot, ins) {
// IMPORTANTE: Solo data-ad-status es confiable para determinar el estado final.
// AdSense inyecta iframe ANTES de establecer data-ad-status, por lo que
// la presencia de iframe NO indica que el anuncio fue llenado.
var status = ins.getAttribute('data-ad-status');
// Estado definitivo: filled
if (status === 'filled') {
debugLog('Slot llenado (data-ad-status=filled)');
markSlotFilled(slot);
return true;
}
// Estado definitivo: unfilled (sin anuncio disponible)
if (status === 'unfilled') {
debugLog('Slot vacio (data-ad-status=unfilled)');
markSlotEmpty(slot);
return true;
}
// Si no hay data-ad-status, AdSense aun no ha respondido.
// NO usar iframe como criterio porque AdSense inyecta iframe incluso para unfilled.
// El MutationObserver seguira observando hasta que data-ad-status aparezca o timeout.
debugLog('Esperando data-ad-status...');
return false;
}
/**
* Marca un slot como llenado
* @param {Element} slot
*/
function markSlotFilled(slot) {
slot.classList.remove('roi-ad-empty', 'roi-ad-error');
slot.classList.add('roi-ad-filled');
cleanupSlot(slot);
}
/**
* Marca un slot como vacio
* @param {Element} slot
*/
function markSlotEmpty(slot) {
slot.classList.remove('roi-ad-filled', 'roi-ad-error');
slot.classList.add('roi-ad-empty');
cleanupSlot(slot);
}
/**
* Limpia observadores, timeouts e intervalos de un slot
* @param {Element} slot
*/
function cleanupSlot(slot) {
// Limpiar polling interval
if (pollIntervals.has(slot)) {
clearInterval(pollIntervals.get(slot));
pollIntervals.delete(slot);
}
// Limpiar timeout (legacy)
if (fillTimeouts.has(slot)) {
clearTimeout(fillTimeouts.get(slot));
fillTimeouts.delete(slot);
}
// Limpiar MutationObserver (legacy)
if (fillObservers.has(slot)) {
fillObservers.get(slot).disconnect();
fillObservers.delete(slot);
}
// Dejar de observar con IntersectionObserver
if (slotObserver) {
slotObserver.unobserve(slot);
}
}
// =========================================================================
// INTERSECTION OBSERVER
// =========================================================================
/**
* Inicializa el Intersection Observer para slots
*/
function initIntersectionObserver() {
if (!hasIntersectionObserverSupport()) {
debugLog('Sin soporte Intersection Observer, usando modo legacy', 'warn');
return false;
}
var options = {
root: null,
rootMargin: CONFIG.rootMargin,
threshold: 0
};
slotObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var slot = entry.target;
debugLog('Slot entro al viewport');
activateSlot(slot);
}
});
}, options);
debugLog('Intersection Observer inicializado con rootMargin: ' + CONFIG.rootMargin);
return true;
}
/**
* Observa todos los slots lazy en la pagina
*/
function observeAllSlots() {
var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]');
debugLog('Encontrados ' + slots.length + ' slots para observar');
slots.forEach(function(slot) {
if (!activatedSlots.has(slot)) {
slotObserver.observe(slot);
}
});
}
/**
* Observa nuevos slots agregados dinamicamente
*/
function observeNewSlots() {
var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]');
var newCount = 0;
slots.forEach(function(slot) {
if (!activatedSlots.has(slot)) {
slotObserver.observe(slot);
newCount++;
}
});
if (newCount > 0) {
debugLog('Agregados ' + newCount + ' nuevos slots al observer');
}
}
// =========================================================================
// MODO LEGACY (FALLBACK)
// =========================================================================
/**
* Variables para modo legacy
*/
var legacyLoaded = false;
var legacyTimeout = null;
/**
* Carga todos los ads en modo legacy (sin Intersection Observer)
*/
function loadAllAdsLegacy() {
if (legacyLoaded) {
return;
}
legacyLoaded = true;
debugLog('Modo legacy: Cargando todos los ads');
if (legacyTimeout) {
clearTimeout(legacyTimeout);
}
removeLegacyEventListeners();
loadAdSenseLibrary(function() {
executeAllPushScripts();
}, function() {
debugLog('Error en modo legacy', 'error');
});
}
/**
* Ejecuta todos los scripts de push en modo legacy
*/
function executeAllPushScripts() {
var pushScripts = document.querySelectorAll('script[data-adsense-push]');
debugLog('Ejecutando ' + pushScripts.length + ' scripts de push');
window.adsbygoogle = window.adsbygoogle || [];
pushScripts.forEach(function(oldScript) {
var newScript = document.createElement('script');
newScript.innerHTML = oldScript.innerHTML;
newScript.type = 'text/javascript';
oldScript.parentNode.replaceChild(newScript, oldScript);
});
document.body.classList.add('adsense-loaded');
}
/**
* Event handler para modo legacy
*/
function handleLegacyInteraction() {
debugLog('Interaccion detectada (modo legacy)');
loadAllAdsLegacy();
}
/**
* Agrega listeners para modo legacy
*/
function addLegacyEventListeners() {
window.addEventListener('scroll', handleLegacyInteraction, { passive: true, once: true });
window.addEventListener('mousemove', handleLegacyInteraction, { passive: true, once: true });
window.addEventListener('touchstart', handleLegacyInteraction, { passive: true, once: true });
window.addEventListener('click', handleLegacyInteraction, { passive: true, once: true });
window.addEventListener('keydown', handleLegacyInteraction, { passive: true, once: true });
}
/**
* Remueve listeners de modo legacy
*/
function removeLegacyEventListeners() {
window.removeEventListener('scroll', handleLegacyInteraction, { passive: true });
window.removeEventListener('mousemove', handleLegacyInteraction, { passive: true });
window.removeEventListener('touchstart', handleLegacyInteraction, { passive: true });
window.removeEventListener('click', handleLegacyInteraction, { passive: true });
window.removeEventListener('keydown', handleLegacyInteraction, { passive: true });
}
/**
* Inicia modo legacy con listeners de interaccion
*/
function initLegacyMode() {
debugLog('Iniciando modo legacy');
addLegacyEventListeners();
legacyTimeout = setTimeout(function() {
debugLog('Timeout legacy alcanzado');
loadAllAdsLegacy();
}, CONFIG.fillTimeout);
}
// =========================================================================
// EVENTO DINAMICO
// =========================================================================
/**
* Configura listener para ads dinamicos
*/
function setupDynamicAdsListener() {
window.addEventListener('roi-adsense-activate', function() {
debugLog('Evento roi-adsense-activate recibido');
if (CONFIG.lazyEnabled && slotObserver) {
observeNewSlots();
} else if (!legacyLoaded) {
loadAllAdsLegacy();
} else {
// Ya cargado en legacy, ejecutar nuevos push
activateDynamicSlotsLegacy();
}
});
}
/**
* Activa slots dinamicos en modo legacy
*/
function activateDynamicSlotsLegacy() {
var pendingPushScripts = document.querySelectorAll('script[data-adsense-push][type="text/plain"]');
if (pendingPushScripts.length === 0) {
return;
}
debugLog('Activando ' + pendingPushScripts.length + ' slots dinamicos (legacy)');
window.adsbygoogle = window.adsbygoogle || [];
pendingPushScripts.forEach(function(oldScript) {
try {
var newScript = document.createElement('script');
newScript.type = 'text/javascript';
newScript.innerHTML = oldScript.innerHTML;
oldScript.parentNode.replaceChild(newScript, oldScript);
} catch (e) {
debugLog('Error activando slot dinamico: ' + e.message, 'error');
}
});
}
// =========================================================================
// EAGER LOADING (ACTIVACION INMEDIATA)
// =========================================================================
/**
* Activa todos los slots inmediatamente sin esperar viewport.
* Los slots inician colapsados (CSS) y solo se expanden si reciben anuncio.
* Esto evita layout shift porque el usuario nunca ve espacios vacios.
*/
function activateAllSlotsEagerly() {
var slots = document.querySelectorAll('.roi-ad-slot[data-ad-lazy="true"]');
debugLog('Activando ' + slots.length + ' slots de manera eager');
slots.forEach(function(slot, index) {
// Pequeño delay escalonado para no saturar AdSense
setTimeout(function() {
activateSlot(slot);
}, index * 100); // 100ms entre cada slot
});
}
// =========================================================================
// INICIALIZACION
// =========================================================================
/**
* Inicializa el sistema
*/
function init() {
// Siempre configurar listener para ads dinamicos
setupDynamicAdsListener();
debugLog('Listener dinamico configurado');
// Verificar si delay esta habilitado globalmente
if (!window.roiAdsenseDelayed) {
debugLog('Delay global no habilitado');
return;
}
debugLog('Inicializando AdSense Lazy Loader v2.1 (Eager Mode)');
debugLog('Config: lazyEnabled=' + CONFIG.lazyEnabled + ', fillTimeout=' + CONFIG.fillTimeout);
// Decidir modo de operacion
if (!CONFIG.lazyEnabled) {
debugLog('Lazy loading deshabilitado, usando modo legacy');
initLegacyMode();
return;
}
// NUEVA ESTRATEGIA: Eager loading
// En lugar de esperar a que los slots entren al viewport (lo cual causa
// layout shift cuando se ocultan slots vacios), activamos todos los slots
// inmediatamente. Los slots inician colapsados via CSS y solo se expanden
// cuando AdSense confirma que tienen anuncio (filled).
// Esto elimina completamente el layout shift.
debugLog('Usando modo eager: activar todos los slots inmediatamente');
// Esperar a que el DOM este listo
if (document.readyState === 'interactive' || document.readyState === 'complete') {
activateAllSlotsEagerly();
} else {
document.addEventListener('DOMContentLoaded', function() {
activateAllSlotsEagerly();
});
}
}
// Iniciar
init();
})();