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

9.2 KiB

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):

// 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:

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():

// 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
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

  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)