- Add per-slot lazy loading with Intersection Observer API - Implement fill detection via MutationObserver and data-ad-status - Add configurable rootMargin and fillTimeout from database - Generate dynamic CSS based on lazy_loading_enabled setting - Add legacy mode fallback for browsers without IO support - Include backup of previous implementation (adsense-loader.legacy.js) - Add OpenSpec documentation with test plan (72 tests verified) Schema changes: - Add lazy_loading_enabled (boolean, default: true) - Add lazy_rootmargin (select: 0-500px, default: 200) - Add lazy_fill_timeout (select: 3000-10000ms, default: 5000) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
9.2 KiB
Design: AdSense Lazy Loading con Intersection Observer
Context
Problema Actual
El adsense-loader.js actual implementa un modelo "todo o nada":
- Usuario interactua (scroll/click) O timeout 5s
- Se carga
adsbygoogle.js(biblioteca principal) - Se ejecutan TODOS los
push({})simultaneamente - Google intenta llenar TODOS los slots de una vez
Consecuencias:
- Fill rate bajo: Google tiene limite de ads por pagina/sesion
- Slots vacios visibles: No hay inventario para todos
- Impresiones desperdiciadas: Ads below-the-fold nunca vistos
- Impacto en Core Web Vitals: Carga masiva de recursos
Solucion Propuesta
Cambiar a modelo "por demanda con visibilidad":
- La biblioteca
adsbygoogle.jsse carga UNA vez (primer ad visible) - Cada slot individual se activa al entrar en viewport
- Slots permanecen ocultos hasta que tengan contenido
- No hay timeout global, cada ad tiene su propio trigger
Goals / Non-Goals
Goals
- Mejorar fill rate cargando ads secuencialmente
- Eliminar espacios en blanco de slots vacios
- Reducir tiempo de carga inicial (menos JS ejecutado)
- Mejorar Core Web Vitals (menor TBT, mejor LCP)
- Cumplir politicas de Google AdSense
Non-Goals
- Reciclar o eliminar ads ya cargados (viola politicas)
- Implementar "infinite scroll" de ads
- Cache de contenido de ads
- Prefetch de ads futuros
Decisions
Decision 1: Extension del Modulo Existente AdsensePlacement
Razon: Mantener Clean Architecture del proyecto. No crear modulo nuevo.
Ubicacion de archivos:
- Schema:
Schemas/adsense-placement.json(nuevos campos en grupoforms) - Renderer:
Public/AdsensePlacement/Infrastructure/Ui/AdsensePlacementRenderer.php - FormBuilder:
Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php - FieldMapper:
Admin/AdsensePlacement/Infrastructure/FieldMapping/AdsensePlacementFieldMapper.php - Asset Enqueuer:
Public/AdsensePlacement/Infrastructure/Services/AdsenseAssetEnqueuer.php - JavaScript:
Assets/Js/adsense-loader.js
Alternativas descartadas:
- Crear modulo nuevo
AdsenseLazyLoading: Viola principio de cohesion, duplica logica
Decision 2: Usar Intersection Observer API
Razon: API nativa del navegador, alto rendimiento, soporte >95% global.
Alternativas consideradas:
- Scroll listener + getBoundingClientRect(): Mayor consumo de CPU
- requestAnimationFrame loop: Complejo, mismo resultado
- Third-party library (lozad.js): Dependencia innecesaria
Decision 3: Ocultar slots por defecto con CSS Dinamico
Razon: Evitar layout shift (CLS) cuando un slot no recibe ad.
Implementacion via CSSGeneratorService (NO CSS estatico):
// En AdsensePlacementRenderer.php
$this->cssGenerator->generate([
'.roi-ad-slot' => [
'display' => $lazyEnabled ? 'none' : 'block',
],
'.roi-ad-slot.roi-ad-filled' => [
'display' => 'block',
],
]);
Alternativas descartadas:
- CSS estatico en archivo: Viola arquitectura del tema
visibility: hidden: Ocupa espacio, causa CLSheight: 0; overflow: hidden: Hack, problemas con responsive- Remover del DOM: Viola politicas de AdSense
Decision 4: Criterios Concretos de Fill Detection
Razon: Evitar ambiguedad sobre cuando un ad "tiene contenido".
Criterios para marcar como roi-ad-filled:
- El elemento
<ins class="adsbygoogle">contiene al menos un hijo - Y ese hijo es un
<iframe>O un<div>con contenido - Y el
<ins>tienedata-ad-status="filled"(atributo que Google agrega)
Criterios para marcar como roi-ad-empty:
- Timeout de
lazy_fill_timeoutms ha pasado sin cumplir criterios de fill - O el
<ins>tienedata-ad-status="unfilled"
Implementacion con MutationObserver:
function checkAdFill(insElement) {
const status = insElement.getAttribute('data-ad-status');
if (status === 'filled') return 'filled';
if (status === 'unfilled') return 'empty';
// Fallback: verificar contenido si no hay atributo
if (insElement.children.length > 0) {
const hasIframe = insElement.querySelector('iframe');
const hasContent = insElement.querySelector('div[id]');
if (hasIframe || hasContent) return 'filled';
}
return 'pending';
}
Decision 5: rootMargin Configurable via Schema
Razon: Cargar ads antes de que sean visibles para UX fluida.
Valor por defecto: 200px 0px (200px arriba/abajo, 0 laterales)
Configuracion via BD (no window object):
- Campo
lazy_rootmarginen schema JSON - Leido por
AdsenseAssetEnqueuerdesde BD - Pasado a JS via
wp_localize_script()
Decision 6: Configuracion Unica via Schema JSON
Razon: Seguir flujo de 5 fases del proyecto, evitar flags conflictivos.
Campos nuevos en grupo behavior de adsense-placement.json:
| Campo | Tipo | Default | Options | Descripcion |
|---|---|---|---|---|
lazy_loading_enabled |
boolean | true | - | Habilitar lazy loading |
lazy_rootmargin |
select | "200" | 0, 100, 200, 300, 400, 500 | Pixeles de pre-carga |
lazy_fill_timeout |
select | "5000" | 3000, 5000, 7000, 10000 | Timeout en ms |
Nota: Se usa select en lugar de number porque el schema solo soporta: boolean, text, textarea, url, select, color. Los valores se parsean a entero en PHP. Ver schema-changes.md para definicion completa con labels.
NO usar window.roiAdsenseConfig - La configuracion viene de BD via wp_localize_script():
// En AdsenseAssetEnqueuer.php
wp_localize_script('adsense-loader', 'roiAdsenseConfig', [
'lazyEnabled' => (bool) $settings['lazy_loading_enabled'],
'rootMargin' => (int) $settings['lazy_rootmargin'] . 'px 0px',
'fillTimeout' => (int) $settings['lazy_fill_timeout'],
'debug' => WP_DEBUG,
]);
Decision 7: Manejo de Errores de Red
Razon: La biblioteca adsbygoogle.js puede fallar por red o bloqueo.
Estrategia:
onerrorcallback en script de biblioteca- Reintentar 1 vez despues de 2 segundos
- Si falla segundo intento, marcar todos los slots como
roi-ad-error - Log en consola si debug habilitado
newScript.onerror = function() {
if (retryCount < 1) {
retryCount++;
setTimeout(() => loadLibrary(), 2000);
} else {
markAllSlotsAsError();
debugLog('AdSense library failed to load after retry');
}
};
Risks / Trade-offs
Risk 1: Ads below-the-fold nunca cargan
Mitigacion: rootMargin: '200px' pre-carga. Usuario que scrollea vera ads.
Trade-off aceptado: Si usuario no scrollea, no ve ads below-fold. Esto es BUENO para el anunciante (no paga por impresion no vista).
Risk 2: Adblockers detectan Intersection Observer
Mitigacion: Nula. Si adblocker activo, ads no cargan de todas formas.
Risk 3: Navegadores antiguos sin soporte
Mitigacion: Fallback a carga tradicional (todos al inicio).
if (!('IntersectionObserver' in window)) {
// Fallback: usar modo legacy existente
loadAllAdsLegacy();
}
Risk 4: Slots sin ad permanecen ocultos siempre
Mitigacion: Timeout por slot configurable. Clase roi-ad-empty permite styling si necesario.
Risk 5: Race condition en carga de biblioteca
Mitigacion: Ya resuelto en implementacion actual con callback onload. Documentado para mantener.
Migration Plan
Fase 1: Schema JSON
- Agregar campos
lazy_loading_enabled,lazy_rootmargin,lazy_fill_timeoutal grupobehaviordeadsense-placement.json - Ejecutar
wp roi-theme sync-component adsense-placement - Verificar campos en BD
Fase 2: Renderer (BD → HTML + CSS)
- Actualizar
AdsensePlacementRenderer.phppara CSS dinamico - Actualizar
AdsenseAssetEnqueuer.phppara pasar config a JS - Actualizar
AdsensePlacementFieldMapper.phpcon nuevos campos
Fase 3: FormBuilder (UI Admin)
- Actualizar
AdsensePlacementFormBuilder.phpcon UI para nuevos campos - Agregar nota sobre necesidad de vaciar cache
Fase 4: JavaScript (Infrastructure)
- Refactorizar
adsense-loader.jscon Intersection Observer - Implementar MutationObserver para fill detection
- Implementar fallback para navegadores sin soporte
- Mantener compatibilidad con
lazy_loading_enabled: false
Fase 5: Validacion y Testing
- Ejecutar validador de arquitectura
- Probar en desarrollo con DevTools (Network throttling)
- Verificar que ads cargan al scroll
- Verificar que slots vacios NO se muestran
- Medir Core Web Vitals con Lighthouse
Post-Implementacion: Deploy y Monitoreo
- Commit con mensaje descriptivo
- Deploy a produccion
- Vaciar cache (Redis, W3TC)
- Verificar fill rate en AdSense dashboard (24-48h)
Rollback
Si hay problemas:
- En admin, cambiar
lazy_loading_enableda false - El sistema vuelve a modo legacy automaticamente
- No requiere deploy de codigo
Open Questions - RESUELTOS
-
Cual es el rootMargin optimo?
- Resuelto: 200px por defecto, configurable via admin
-
Timeout por slot para "dar por vacio"?
- Resuelto: 5000ms por defecto, configurable via admin
-
Como detectar fill de forma confiable?
- Resuelto: Usar
data-ad-statusde Google + fallback a children check
- Resuelto: Usar
-
Donde va la configuracion?
- Resuelto: Schema JSON → BD → wp_localize_script (NO window globals)