Files
roi-theme/openspec/changes/refactor-adsense-lazy-loading/design.md
FrankZamora 179a83e9cd feat(js): implement intersection observer lazy loading for adsense
- 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>
2025-12-10 15:48:20 -06:00

275 lines
9.2 KiB
Markdown

# Design: AdSense Lazy Loading con Intersection Observer
## Context
### Problema Actual
El `adsense-loader.js` actual implementa un modelo "todo o nada":
1. Usuario interactua (scroll/click) O timeout 5s
2. Se carga `adsbygoogle.js` (biblioteca principal)
3. Se ejecutan TODOS los `push({})` simultaneamente
4. 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":
1. La biblioteca `adsbygoogle.js` se carga UNA vez (primer ad visible)
2. Cada slot individual se activa al entrar en viewport
3. Slots permanecen ocultos hasta que tengan contenido
4. 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 grupo `forms`)
- 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):
```php
// 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 CLS
- `height: 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`:**
1. El elemento `<ins class="adsbygoogle">` contiene al menos un hijo
2. **Y** ese hijo es un `<iframe>` O un `<div>` con contenido
3. **Y** el `<ins>` tiene `data-ad-status="filled"` (atributo que Google agrega)
**Criterios para marcar como `roi-ad-empty`:**
1. Timeout de `lazy_fill_timeout` ms ha pasado sin cumplir criterios de fill
2. **O** el `<ins>` tiene `data-ad-status="unfilled"`
**Implementacion con MutationObserver:**
```javascript
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_rootmargin` en schema JSON
- Leido por `AdsenseAssetEnqueuer` desde 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()`:
```php
// 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:**
1. `onerror` callback en script de biblioteca
2. Reintentar 1 vez despues de 2 segundos
3. Si falla segundo intento, marcar todos los slots como `roi-ad-error`
4. Log en consola si debug habilitado
```javascript
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).
```javascript
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
1. Agregar campos `lazy_loading_enabled`, `lazy_rootmargin`, `lazy_fill_timeout` al grupo `behavior` de `adsense-placement.json`
2. Ejecutar `wp roi-theme sync-component adsense-placement`
3. Verificar campos en BD
### Fase 2: Renderer (BD → HTML + CSS)
1. Actualizar `AdsensePlacementRenderer.php` para CSS dinamico
2. Actualizar `AdsenseAssetEnqueuer.php` para pasar config a JS
3. Actualizar `AdsensePlacementFieldMapper.php` con nuevos campos
### Fase 3: FormBuilder (UI Admin)
1. Actualizar `AdsensePlacementFormBuilder.php` con UI para nuevos campos
2. Agregar nota sobre necesidad de vaciar cache
### Fase 4: JavaScript (Infrastructure)
1. Refactorizar `adsense-loader.js` con Intersection Observer
2. Implementar MutationObserver para fill detection
3. Implementar fallback para navegadores sin soporte
4. Mantener compatibilidad con `lazy_loading_enabled: false`
### Fase 5: Validacion y Testing
1. Ejecutar validador de arquitectura
2. Probar en desarrollo con DevTools (Network throttling)
3. Verificar que ads cargan al scroll
4. Verificar que slots vacios NO se muestran
5. Medir Core Web Vitals con Lighthouse
### Post-Implementacion: Deploy y Monitoreo
1. Commit con mensaje descriptivo
2. Deploy a produccion
3. Vaciar cache (Redis, W3TC)
4. Verificar fill rate en AdSense dashboard (24-48h)
### Rollback
Si hay problemas:
1. En admin, cambiar `lazy_loading_enabled` a false
2. El sistema vuelve a modo legacy automaticamente
3. No requiere deploy de codigo
## Open Questions - RESUELTOS
1. **Cual es el rootMargin optimo?**
- **Resuelto:** 200px por defecto, configurable via admin
2. **Timeout por slot para "dar por vacio"?**
- **Resuelto:** 5000ms por defecto, configurable via admin
3. **Como detectar fill de forma confiable?**
- **Resuelto:** Usar `data-ad-status` de Google + fallback a children check
4. **Donde va la configuracion?**
- **Resuelto:** Schema JSON → BD → wp_localize_script (NO window globals)