Saltar al contenido principal
Volver al blog

2 min de lectura

Tema sin flash, con CSP estricta y View Transitions

El script inline que evita el flash de tema equivocado choca de frente con una CSP estricta, y luego View Transitions amenaza con romperlo otra vez. Cómo conviven los tres sin bajar de A+.

  • astro
  • seguridad
  • csp
  • view-transitions
  • tema

Un toggle de tema light/dark tiene un enemigo silencioso: el FART, flash of wrong theme. Si decides el tema después de que React hidrate, el usuario ve un parpadeo del tema por defecto antes de que se aplique el suyo. La cura es vieja y conocida: un <script is:inline> en el <head> que lee localStorage y pone data-theme en el <html> antes del primer paint.

El problema es que ese mismo script choca con las otras dos cosas que me importaban: una Content-Security-Policy estricta y las View Transitions.

Conflicto uno: la CSP no quiere scripts inline

Una CSP digna de A+ en securityheaders.com no lleva 'unsafe-inline' en script-src. Pero mi anti-FART es un script inline, por definición tiene que correr antes que cualquier bundle. La tentación es abrir la política; la salida correcta es hashearlo.

CSP Level 3 permite autorizar un script inline concreto por su hash SHA-256. Calculo el hash del contenido exacto del script en build y lo agrego a script-src:

script-src 'self' 'sha256-…';

El navegador hashea cada script inline que encuentra y solo ejecuta los que coinciden. El anti-FART corre; cualquier inyección no autorizada se bloquea. La trampa: el hash depende del contenido byte a byte. Un espacio de más, un cambio de Astro en el bootstrap, y el navegador bloquea el script y la página se rompe. Por eso el hash se recalcula en cada build con un script propio, no a mano.

Conflicto dos: View Transitions remonta el <head>

Con <ClientRouter />, Astro hace navegaciones cross-document con View Transitions: precioso, gratis, cero JS de animación. Pero al intercambiar documentos, el data-theme que mi script había puesto puede perderse entre una página y otra, y vuelve el parpadeo —ahora en cada navegación, no solo en la carga inicial.

Dos piezas lo resuelven. La isla del toggle se marca con transition:persist para que no se remonte y conserve su estado. Y el tema se re-aplica escuchando el evento astro:before-swap, que dispara antes de que el nuevo documento se monte: escribo data-theme sobre event.newDocument para que llegue ya con el tema correcto y no haya flash, replicando lo que hace el anti-FART en la carga inicial.

Lo que me llevo

Las tres features son buenas por separado y enemigas si las juntas sin pensar. El patrón que las reconcilia es el mismo en los dos casos: aplicar el tema en el momento exacto antes del paint —en la carga, vía script hasheado; en cada navegación, vía astro:before-swap— y nunca confiar en que el estado sobreviva solo. El resultado es un toggle que no parpadea, una CSP que sigue en A+ y transiciones suaves entre páginas. Ninguna de las tres tuvo que ceder.