Saltar al contenido
Development

El error que solo existe bajo una configuración que todavía nadie usa

Por Victor Da Luz
astroprivacyjavascriptdev-logastro-tools

Ninguno de los dos sitios que corren mi paquete de consentimiento de analítica usa las transiciones de vista de Astro. Ni una sola página. Así que cuando un ítem del backlog decía “esto se rompe con ClientRouter”, mi primera reacción fue: ¿importa?

Decidí que sí importa, y arreglarlo me enseñó algo sobre la diferencia entre un error y un error que todavía no se puede ver.

La configuración que nunca se probó

ConsentGate y ConsentPrompt se inicializan con una pequeña etiqueta de script en línea: importar una función, llamarla una vez. Eso funciona porque Astro corre un script de módulo en línea exactamente una vez por cada carga completa de página. En cada carga de página en producción, la función de arranque corre, encuentra los elementos del DOM, y conecta los listeners. Simple, y correcto en producción durante meses.

<ClientRouter /> cambia lo que significa “una vez por carga de página.” Al activarlo, Astro empieza a hacer navegaciones suaves: al hacer clic en un enlace se reemplaza el contenido de la página sin una recarga completa. Los scripts de módulo en línea no vuelven a correr en una navegación suave, ya corrieron, en lo que respecta al sistema de módulos. Entonces los botones de aceptar y rechazar del prompt de consentimiento, vinculados al elemento del DOM que existiera en la primera página, quedan ahí conectados a nada en cuanto una navegación suave reemplaza el contenido por una copia nueva.

Dos errores, no uno

Supuse que era un solo problema de “los listeners no se vuelven a vincular.” Son dos, y el segundo es el interesante.

El error obvio: los propios botones del prompt están vinculados a una instancia específica de elemento. Desaparece después de un reemplazo.

El sutil: hay un enlace en el footer, “Analytics preferences,” que reabre el prompt cuando se solicita. Su handler se escribió una sola vez en el momento del arranque y capturó una referencia al elemento del prompt de esa página en un closure:

document.addEventListener(OPEN_PROMPT_EVENT, () => {
  prompt.hidden = false; // `prompt` is whatever it was when this ran
});

document en sí sobrevive a una navegación suave, es el mismo objeto document todo el tiempo. Entonces este listener nunca se quita, nunca necesita volver a vincularse, y sigue disparándose para siempre. Solo que dispara contra una variable prompt que dejó de apuntar a algo visible en el momento en que ocurrió la primera navegación. El listener no está roto. Sus datos están obsoletos.

Esa distinción cambió la solución. El primer instinto fue “volver a correr toda la función de arranque en cada navegación,” lo cual volvería a registrar este mismo handler una vez por navegación, para siempre, cada uno capturando lo que fuera el prompt en ese momento. Inofensivo en el sentido de que solo el handler más reciente encontraría un elemento real que tocar, pero es una pila sin límite de closures muertos que se acumulan durante toda la sesión. Una fuga disfrazada de solución.

Lo que en realidad lo arregló

Dos reglas distintas para dos tipos distintos de listener. Todo lo vinculado a un elemento específico del DOM (los propios botones del prompt) necesita volver a vincularse en cada navegación, porque el elemento genuinamente es nuevo cada vez. Todo lo vinculado a document necesita vincularse una sola vez, para siempre, y su handler debería buscar lo que sea que toque en el momento en que se dispara, en lugar de capturar una referencia en el momento del registro:

document.addEventListener(OPEN_PROMPT_EVENT, () => {
  const current = document.getElementById('oia-prompt'); // fresh, every time
  if (current) current.hidden = false;
});

Volver a correr la función de arranque en el evento astro:page-load de Astro, que se dispara en la primera carga y en cada navegación suave después de esa, resuelve el lado de la revinculación de elementos. Una bandera de una línea a nivel de módulo evita que los listeners a nivel de document se registren dos veces.

Verificar un error que no existe en producción

Esta es la parte que no se pudo abreviar. Ningún sitio real usa <ClientRouter />, así que ninguna página en vivo mostraba en realidad este problema. Las pruebas unitarias tampoco habrían ayudado, ya que esto es cableado de eventos del DOM y el paquete es deliberadamente libre de dependencias, sin jsdom.

Así que agregué temporalmente <ClientRouter /> al layout de un sitio, sin hacer commit, únicamente para fabricar la condición de falla el tiempo suficiente para comprobar la solución. Entré al listado del blog, hice clic en una publicación (una navegación suave real, no una carga nueva), y verifiqué que el prompt apareciera, que el botón de aceptar funcionara, y (la ruta específica que rompía el error del closure) que el enlace de reabrir del footer apuntara al prompt de la página actual en lugar de a un fantasma de la primera. Después revertí el cambio de layout por completo.

Es una sensación rara, activar una función en el propio sitio solo para crear un error a propósito y así poder observar la corrección en vivo. Pero no había otra manera de verlo ocurrir, y una corrección que no se puede ver fallar primero no está realmente verificada.

Qué haría diferente

Desconfiaría más de “ningún sitio usa X” como motivo para bajarle prioridad a algo. Es cierto, y también es exactamente la condición bajo la cual un error se queda en una base de código durante meses sin que nadie lo note, porque nada ejercita la ruta de código que está mal. Prioridad baja no es lo mismo que riesgo bajo.

Lecturas relacionadas