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.