Tres PRs, una causa raíz: el cuerpo HTML varía según el estado de login, UA y flash — propiedades que la cache key del CDN ignora — y el contenido se filtra entre usuarios. Dejar que Claude recorra la cadena de PRs hace emerger tres patrones reutilizables: lazy personalize frames, ocultamiento en el cliente con CSS y guardia en la frontera de caché.
Los usuarios entraban a mi sitio y en cada página pública flotaba en la parte inferior una línea "Content missing". Los primeros reportes venían todos de iOS — mi instinto inicial: algún comportamiento extraño del cliente Hotwire Native. Solo al excavar en los logs de Cloudflare caí en la cuenta: esto no tiene nada que ver con el cliente. La caché del CDN está envenenada y los tres frentes (Web / iOS / Android) pueden caer en ella; iOS solo fue el primero en sacarla a la luz.
Reflejo uno: bug de Hotwire Native. Reflejo dos: algún comportamiento raro del edge de Cloudflare. Ninguno de los dos. Incluso llamar a esta clase de bugs "un problema de CDN" es incorrecto — el CDN está haciendo exactamente lo que le indicaste.
Cuando le pedí a Claude que recorriera la cadena de PRs en orden, tres PRs consecutivos tenían la misma causa raíz: el cuerpo HTML varía según el estado de login, el UA y los flash — propiedades del request que la cache key del CDN ignora por completo. Tres síntomas distintos, una sola causa raíz.
/, /topics, /square, /coins, /searches/app se cachean en CDN vía public_expires_in. El layout tenía esto:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge renderiza un turbo-frame:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
La intención es clara: los usuarios autenticados de Hotwire Native ven un punto rojo de notificaciones no leídas en la barra de pestañas inferior. Web no lo necesita (tiene su propio camino de renderizado), y los no autenticados tampoco.
El problema: la rama mobile_hotwire? && user_signed_in? hace que la misma URL produzca dos HTML distintos. Pero la cache key del CDN solo mira cosas como URL y header Accept — no tiene idea si estás autenticado.
Línea de tiempo:
/. El CDN va al origen y cachea el HTML que contiene el tab-badge frame./. El CDN acierta el mismo cache y le entrega ese HTML.src, pide /notifications?badge_only=true.NotificationsController tiene before_action :authenticate_user!, ve que no hay sesión y hace 302 a /users/sign_in.tab_badge y pinta "Content missing".Los visitantes no autenticados de los tres frentes (Web / iOS / Android) pueden caer — basta con que un usuario autenticado haya rellenado primero ese cache slot. Los reportes se concentraron en iOS porque la tasa de login en iOS es la más alta y, por tanto, la caché se contamina con estado autenticado con más frecuencia — los visitantes no autenticados de iOS tenían la mayor probabilidad de toparse con una entrada envenenada. Web y Android no estaban "a salvo": tenían menor probabilidad de disparo y menos reportes — un bug que en esencia es una catástrofe de todo el sitio quedó disfrazado por los datos tempranos como "exclusivo de iOS".
La corrección va en dos pasos, ambos orientados a impedir que la caché siga partiéndose por estado de login:
Paso 1: hay que quitar el && user_signed_in? del layout. Si no, juegas a adivinar la cache key para siempre:
<%# Renderizar para todo request mobile_hotwire?, con o sin login —
la rama user_signed_in? parte el HTML de la misma URL en dos
variantes, y la que gane el slot del CDN será la que se sirva
al siguiente visitante. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
Paso 2: el endpoint del frame /notifications?badge_only=true debe devolver, para requests no autenticados, un frame 200 estructuralmente idéntico, nunca un 302. Y el endpoint mismo tiene que ser private, no-store para que el CDN no comparta el contador de no leídos de un usuario con otro.
class NotificationsController < ApplicationController
before_action :authenticate_user!, except: [:index]
def index
if params[:badge_only] || params[:count_only]
response.headers["Cache-Control"] = "private, no-store"
redirect_to notifications_path and return unless turbo_frame_request?
@unread_count = user_signed_in? ? current_user.notifications.unread.count : 0
if params[:badge_only]
render(partial: "notifications/tab_badge")
else
render(partial: "notifications/bell")
end
return
end
authenticate_user!
# ... lógica normal de index
end
end
Nota que authenticate_user! solo se omite para index — y solo para sub-requests de frame; los requests no-frame siguen yendo al redirect.
En el mismo PR había otro bug con la misma forma: los usuarios no autenticados de Hotwire Native también veían el FAB de compose (botón flotante) — el mismo envenenamiento, una caché contaminada por estado de login filtrándose a visitantes no autenticados. La versión original metía user_signed_in? directo en el layout para decidir si renderizar el div del stimulus controller. Fix: mover el FAB a un lazy frame, renderizado por el endpoint /personalize/compose_fab, que es private, no-store.
<%# El layout siempre saca un frame vacío %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Plantilla de /personalize/compose_fab %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
El layout siempre emite un frame vacío; el endpoint dentro del frame decide, según el estado de login, si insertar el div del controller. El HTML de la página principal no contiene ninguna marcación que ramifique por usuario — el CDN puede cachear como quiera, nada se rompe.
Días después de #117, otro reporte: un usuario no autenticado entró a la home y arriba apareció un banner flash "Sesión iniciada con éxito", sin entender nada.
La misma enfermedad, un nuevo síntoma.
El layout tenía:
<%= render "shared/_notice" %>
Esa línea renderiza flash[:notice] / flash[:alert]. El primer request después de cualquier redirect lleva flash, por ejemplo:
redirect_to root_path tras login exitosoredirect_to root_path tras logoutredirect_back(fallback_location: ...) de PunditSi la URL en la que aterriza el redirect resulta ser un path de public_expires_in, ese flash queda horneado dentro del HTML cacheado. El siguiente visitante anónimo en esa URL recibe el mismo HTML — y ve la "Sesión iniciada con éxito" de otra persona.
La tentación es refactorizar cada view cacheable y mover el rendering de flash a un frame. Pero la solución más sólida es cortar en la frontera de caché: si la respuesta lleva flash, no se permite cachear públicamente.
def public_expires_in(duration)
return unless Rails.env.live?
# flash se renderiza directo en el layout; cachear esta respuesta
# filtraría el aviso del usuario anterior al siguiente visitante anónimo
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Ventajas de esta jugada:
public_expires_in se beneficia automáticamente.Aprovechando, descubrí que coins#show escribía por su cuenta expires_in 5.minutes, public: true saltándose el helper. Lo unifiqué con public_expires_in, si no, el mismo bug de fuga de flash habría brotado desde ahí.
Tras el deploy hay que correr una vez cloudflare:purge_personalized_pages — las entradas de caché ya envenenadas no expiran solas; aguantarán hasta agotar el TTL del URL correspondiente (/topics es 1 semana).
/topicsUnas horas después, tercer síntoma: usuarios autenticados en /topics no veían el botón "+ Nuevo Tema". Recargar no servía. Pero el mismo usuario, llegando desde otra página a /topics?r=1 (parámetro aleatorio para evadir caché), recuperaba el botón.
/topics está en public_expires_in 1.week — la caché más agresiva. El view original:
<% unless mobile_hotwire? %>
<a href="<%= new_topic_path %>" class="btn-new-topic">+ Create</a>
<% end %>
<% if mobile_hotwire? %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
Dos guardias: una guardia de UA (web vs native) y una guardia de auth (autenticado o no). El CDN no conoce ninguna — el primer usuario que pegue al origen decide cómo queda la caché. El primer visitante anónimo web cachea "sin botón native bridge, con botón web"; el primer visitante autenticado native cachea "con botón bridge, sin botón web" — el destino del siguiente depende de en qué cache slot caiga.
Mismo enfoque que en #117:
El botón web — sin ramas, siempre se renderiza:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only lo oculta vía CSS bajo UA native. El HTML cacheado en CDN siempre es idéntico, el botón siempre presente, el UA decide si se muestra — CSS es decisión del lado cliente, la caché no participa.
El botón native bridge — movido a un lazy personalize frame:
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# Plantilla /personalize/topic_new_button %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
El HTML de la página principal solo contiene el contenedor <turbo-frame> y el botón web — ninguno de los dos ramifica por usuario. Toda marcación de "según el usuario, renderizar o no" se trasladó al endpoint personalize, que es private, no-store y nunca entra al CDN.
Cada síntoma, en su momento, parecía un bug distinto. "Content missing" parecía un problema de Hotwire Native; el flash filtrándose parecía un asunto de configuración de cookies/sesión; el botón ausente parecía un error de lógica en el view template.
Si pones los tres PRs uno al lado del otro, la causa raíz es una sola: el contenido del HTML body depende de propiedades del request que están fuera de la cache key. El trabajo del CDN es cachear HTML por cache key — no puede saber que ese HTML solo es correcto para usuarios autenticados, o solo para Hotwire Native, o solo para requests con flash.
Tres patrones reutilizables para erradicar esta clase:
Sacar la variabilidad fuera del body cacheado — toda marcación que ramifica por usuario se convierte en un lazy turbo-frame, renderizado por un endpoint personalize private, no-store. El HTML de la página principal queda invariante; el CDN cachea como quiera y siempre acierta.
Renderizar versión universal y ocultar del lado cliente — por ejemplo, el botón web siempre está en el HTML y CSS lo oculta con .web-only bajo native. La rama vive en CSS/JS, no en el HTML cacheado.
Guardia en la frontera de caché — para respuestas a las que ya se les coló estado (como flash), degradar Cache-Control a private, no-store en la frontera. El tráfico normal queda intacto.
Lo que tienen en común los tres patrones: el HTML cacheado y la marcación que ramifica por usuario nunca deben superponerse.
Tras desplegar el fix queda una cosa más: las entradas envenenadas en CDN no expiran solas y los TTL pueden llegar a una semana. Escribe un rake task de un solo uso cloudflare:purge_personalized_pages que purge activamente todas las rutas sospechosas — si no, el bug seguirá saliendo hasta que la caché expire por sí sola.
PR #122 se descubrió en el mismo periodo. Misma forma, distinto tipo de bug — vale la pena mencionarlo aparte porque comparte el mismo mecanismo invisible con los de cache poisoning.
La página /following usa un lazy turbo-frame para cargar la siguiente página (el patrón estándar de infinite scroll). El src del frame lo calcula un helper llamado set_load_more_path — decide la URL de la siguiente página según el controller/action actual.
def set_load_more_path(page:, anchor_id: nil)
if controller_name == "coins" && action_name == "show"
path = coin_symbol_path(...)
elsif controller_name == "posts" && action_name == "hot"
path = hot_path(...)
# ... muchas ramas ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← fallback
end
end
La acción posts#following no está en la lista de ramas, así que cae al fallback final posts_path — es decir, /posts, el explore feed.
Efecto: la página 1 de /following está bien (la propia controller action setea @posts = following_posts), pero a partir de la página 2 el lazy frame tira a escondidas de /posts?page=2 y carga contenido del explore feed — posts de gente a la que no sigues se cuelan en el feed.
El fix es corto:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
No es la misma categoría que los tres bugs de cache poisoning — la rama mal escrita en set_load_more_path no tiene nada que ver con el CDN. Pero comparte el mismo mecanismo invisible: el contenido que carga un lazy turbo-frame es contenido que no vas a revisar activamente.
En #117 el frame hacía 302 silenciosamente al fallar; en #119/#121 el contenido cacheado estaba mal y no se notaba; en #122 la ruta del frame estaba mal y mirando solo la página 1 no se veía. Una vez que metes "contenido dinámico" en un lazy frame, tienes que revisar activamente el estado posterior a la carga del frame — lo que Claude me cazó en la fase de PR review fue exactamente esa lista de "qué pasa después de que el frame se carga".
Si tu app Rails tiene un CDN delante (Cloudflare, Fastly, el que sea), lo más probable es que estés cayendo al menos en una de estas combinaciones:
public_expires_in / expires_in ..., public: true, ¿hay alguna rama tipo if user_signed_in??flash se renderiza directo en el layout? ¿Una ruta public_expires_in se cachea cuando hay flash?mobile_hotwire? apareciendo en rutas cacheables?src de turbo-frame, ¿su controller usa before_action :authenticate_user!? ¿Los requests no autenticados harán 302?src de algún lazy turbo-frame se calcula con un helper? ¿Ese helper tiene rama de fallback? ¿El fallback es la URL equivocada?Pídele a Claude que recorra esta checklist de cinco puntos — si alguno hace match, ahí está tu siguiente PR. Estos bugs no salen a la superficie por sí solos, porque los lazy frames fallan en silencio, los CDN aciertan en silencio y CSS oculta en silencio. Tres mecanismos silenciosos apilados — y un bug puede esconderse en producción durante meses.