- 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>
275 lines
9.2 KiB
Markdown
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)
|