Três PRs, uma causa raiz: o body HTML varia com o estado de login, UA e flash — propriedades das quais a cache key do CDN não sabe nada — e o conteúdo vaza entre usuários. Deixar o Claude percorrer a cadeia de PRs faz emergir três padrões reutilizáveis: lazy personalize frames, ocultar no cliente via CSS e guardião na fronteira do cache.
Os usuários entravam no meu site e na parte inferior de toda página pública pairava uma linha "Content missing". Os primeiros relatos vieram todos do iOS — meu primeiro instinto: algum comportamento estranho do cliente Hotwire Native. Só ao mergulhar nos logs do Cloudflare é que percebi: isso não tem nada a ver com o cliente. O cache do CDN está envenenado, e os três frontes (Web / iOS / Android) podem cair nele — o iOS só foi o primeiro a trazê-lo à tona.
Reflexo um: bug do Hotwire Native. Reflexo dois: algum comportamento esquisito de edge da Cloudflare. Nenhum dos dois. Até chamar essa categoria de bug de "problema de CDN" é incorreto — a CDN está fazendo exatamente o que você mandou.
Quando coloquei o Claude para ler a cadeia de PRs em sequência, três PRs consecutivos tinham todos a mesma causa raiz: o body HTML varia conforme o estado de login, o UA e o flash — propriedades do request das quais a cache key da CDN não tem ideia. Três sintomas distintos, uma única causa.
/, /topics, /square, /coins, /searches/app passam pelo cache do CDN via public_expires_in. No layout havia:
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge renderiza um turbo-frame:
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
A intenção é clara: usuários autenticados de Hotwire Native veem um pontinho de notificação não lida na tab bar inferior. Web não precisa (tem um caminho de renderização próprio), e quem não está autenticado também não.
O problema: o branch mobile_hotwire? && user_signed_in? faz a mesma URL produzir dois HTMLs diferentes. Mas a cache key da CDN só olha coisas como URL e cabeçalho Accept — ela não tem ideia se você está autenticado.
Linha do tempo:
/. O CDN vai à origem e cacheia o HTML que contém o tab-badge frame./. O CDN bate no mesmo cache e entrega esse HTML para ele.src, pede /notifications?badge_only=true.NotificationsController tem before_action :authenticate_user!, vê que não há sessão e dá 302 para /users/sign_in.tab_badge e pinta "Content missing".Visitantes não autenticados dos três frontes (Web / iOS / Android) podem cair — basta que algum cache slot tenha sido preenchido antes por um usuário autenticado. Os relatos se concentraram no iOS porque o iOS tem a maior taxa de login — então os caches são contaminados com estado autenticado com mais frequência, e visitantes iOS não autenticados têm a maior probabilidade de tropeçar em uma entrada envenenada. Web e Android não estavam "tranquilos": tinham probabilidade de gatilho menor e menos relatos — um bug que em essência é um desastre de site inteiro foi disfarçado pelos dados iniciais como "exclusivo do iOS".
A correção segue em duas etapas, ambas para impedir que o cache continue se rachando por estado de login:
Passo 1: o && user_signed_in? precisa sair do layout. Caso contrário, você joga adivinha-cache-key para sempre:
<%# Renderizar para todas as requisições mobile_hotwire?, com login ou
sem — o branch user_signed_in? quebra o HTML da mesma URL em duas
variantes; quem ganhar o slot CDN será o que vai sair para o
próximo visitante. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
Passo 2: o endpoint do frame /notifications?badge_only=true tem que devolver, para requisições não autenticadas, um frame 200 estruturalmente idêntico, nunca um 302. E o endpoint em si precisa ser private, no-store, para o CDN não compartilhar o contador de não lidas de um usuário com outro.
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 de index normal
end
end
Note que authenticate_user! só é dispensado para index — e somente para sub-requisições de frame; requisições não-frame ainda passam pelo redirect.
No mesmo PR havia outro bug com formato idêntico: usuários do cliente Hotwire Native não autenticados também viam o FAB de compose (botão flutuante) — mesma intoxicação, um cache contaminado pelo estado de login vazando para visitantes não autenticados. A versão original colocava user_signed_in? direto no layout para decidir se renderizava o div do stimulus controller. Correção: mover o FAB para um lazy frame, renderizado pelo endpoint /personalize/compose_fab, em private, no-store.
<%# O layout sempre cospe um frame vazio %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Template /personalize/compose_fab %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
O layout sempre devolve um frame vazio; o endpoint dentro do frame decide, pelo estado de login, se enfia o div do controller. No HTML da página principal não há nenhum markup que ramifique por usuário — o CDN pode cachear como quiser, nada quebra.
Dias depois do #117 ter saído, outro relato: um usuário não autenticado entra na home e no topo aparece um banner flash "Login feito com sucesso" — sem entender nada.
Mesma doença, sintoma novo.
No layout:
<%= render "shared/_notice" %>
Essa linha renderiza flash[:notice] / flash[:alert]. A primeira requisição depois de qualquer redirect carrega flash, por exemplo:
redirect_to root_path depois de login bem-sucedidoredirect_to root_path depois de logoutredirect_back(fallback_location: ...) do PunditSe a URL onde o redirect cai é também um caminho public_expires_in, esse flash é assado dentro do HTML em cache. O próximo visitante anônimo dessa URL recebe o mesmo HTML — e vê o "Login feito com sucesso" de outra pessoa.
A tentação é refatorar toda view cacheável para mover a renderização do flash para um frame. Mas o caminho mais sólido é cortar na fronteira do cache: se a resposta carrega flash, proibido cachear publicamente.
def public_expires_in(duration)
return unless Rails.env.live?
# flash é renderizado direto no layout; cachear esta resposta
# vazaria o aviso do usuário anterior para o próximo visitante anônimo
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Vantagens desse golpe:
public_expires_in se beneficia automaticamente.Já aproveitando, descobri que coins#show escrevia por conta própria expires_in 5.minutes, public: true desviando do helper. Padronizei para public_expires_in, senão o mesmo bug de vazamento de flash brotaria dali.
Depois do deploy é obrigatório rodar uma vez cloudflare:purge_personalized_pages — entradas de cache já envenenadas não expiram sozinhas; ficam até esgotar o TTL da URL correspondente (/topics é 1 semana).
/topicsAlgumas horas depois, terceiro sintoma: usuários autenticados em /topics não viam o botão "+ Novo Tópico". Recarregar não ajudava. Mas o mesmo usuário, vindo de outra página para /topics?r=1 (parâmetro aleatório para furar cache), via o botão de volta.
/topics está em public_expires_in 1.week — o cache mais agressivo. A 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 %>
Dois guardiões: um guardião de UA (web vs native) e um guardião de auth (autenticado ou não). O CDN não conhece nenhum dos dois — quem chega primeiro à origem decide a forma do cache. O primeiro visitante web anônimo cacheia "sem botão native bridge, com botão web"; o primeiro visitante native autenticado cacheia "com botão bridge, sem botão web" — o destino do próximo depende de em qual cache slot ele cair.
Mesma abordagem do #117:
O botão web — sem branch, sempre renderizado:
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only o esconde via CSS sob UA native. O HTML cacheado pelo CDN é sempre idêntico, o botão sempre presente, e o UA decide se exibe ou não — CSS é decisão do lado cliente, o cache não participa.
O botão native bridge — movido para um lazy personalize frame:
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# Template /personalize/topic_new_button %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
O HTML da página principal só tem o container <turbo-frame> e o botão web — nenhum dos dois ramifica por usuário. Todo markup "renderizar ou não dependendo do usuário" migrou para o endpoint personalize, que é private, no-store e nunca entra no CDN.
Cada sintoma, na hora, parecia um bug diferente. "Content missing" parecia problema de Hotwire Native; flash vazando parecia problema de configuração de cookies/session; botão sumido parecia erro de lógica no view template.
Colocando os três PRs lado a lado, a causa raiz é uma só: o conteúdo do HTML body depende de propriedades da requisição que estão fora da cache key. O trabalho do CDN é cachear HTML pela cache key — ele não tem como saber que aquele HTML só está correto para usuários autenticados, ou só para Hotwire Native, ou só para requisições com flash.
Três padrões reutilizáveis para extirpar essa categoria:
Empurrar a variabilidade para fora do body cacheado — todo markup que ramifica por usuário vira um lazy turbo-frame, renderizado por um endpoint personalize private, no-store. O HTML da página principal é invariante; o CDN cacheia como quiser, sempre acerta.
Renderizar versão universal e esconder no client — por ex., o botão web fica sempre no HTML, e o CSS o esconde com .web-only sob UA native. O branch vive em CSS/JS, não no HTML cache-keyed.
Guardião na fronteira do cache — para respostas em que já vazou estado (como flash), rebaixar Cache-Control para private, no-store na fronteira. Tráfego normal fica intocado.
O fio comum dos três padrões: o HTML cacheado e o markup ramificado por usuário nunca podem se sobrepor.
Depois do deploy do fix sobra uma coisa: as entradas envenenadas no CDN não expiram sozinhas, e os TTLs podem chegar a uma semana. Escreva uma rake task de uso único cloudflare:purge_personalized_pages que purga ativamente todos os caminhos suspeitos — caso contrário, o bug vai continuar pipocando até a expiração natural.
O PR #122 foi descoberto no mesmo período. Mesma forma, tipo de bug diferente — vale chamar à parte porque compartilha o mesmo mecanismo invisível com os bugs de cache poisoning.
A página /following usa um lazy turbo-frame para carregar a próxima página (padrão de infinite scroll). O src do frame é calculado por um helper chamado set_load_more_path — que decide a URL da próxima página com base no controller/action atual.
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(...)
# ... muitos branches ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← fallback
end
end
A action posts#following não está na lista de branches, então cai no último fallback posts_path — ou seja, /posts, o explore feed.
Efeito: a página 1 de /following está certa (o próprio controller action seta @posts = following_posts), mas da página 2 em diante o lazy frame puxa silenciosamente /posts?page=2, carregando conteúdo do explore feed — posts de gente que você não segue se infiltram no feed.
A correção é curta:
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
Não é a mesma categoria dos três bugs de cache poisoning — o branch errado em set_load_more_path não tem nada a ver com CDN. Mas compartilham o mesmo mecanismo invisível: conteúdo carregado por um lazy turbo-frame é conteúdo que você não revisa ativamente.
O frame do #117 dava 302 silencioso no erro; em #119/#121 o conteúdo no cache estava errado e você não via; em #122 o caminho do frame estava errado e olhando só a página 1 você não pegava. Uma vez que você coloca "conteúdo dinâmico" dentro de um lazy frame, precisa revisar ativamente o estado depois que o frame carrega — o que o Claude pegou para mim na fase de PR review é exatamente essa checklist "o que acontece depois que o frame carrega".
Se sua app Rails tem um CDN na frente (Cloudflare, Fastly, qualquer um), há grande chance de você estar pisando em pelo menos uma destas combinações:
public_expires_in / expires_in ..., public: true, existe algum branch tipo if user_signed_in??flash é renderizado direto no layout? Uma rota public_expires_in é cacheada quando há flash?mobile_hotwire? aparecendo em rotas cacheáveis?src de turbo-frame, o controller dele usa before_action :authenticate_user!? Requisições não autenticadas vão dar 302?src de um lazy turbo-frame é calculado por um helper? Esse helper tem branch fallback? O fallback é a URL errada?Peça ao Claude para passar essa checklist de cinco pontos — se algum bater, esse é o material do próximo PR. Esses bugs não vêm à tona sozinhos, porque lazy frames falham em silêncio, CDNs acertam em silêncio e o CSS esconde em silêncio. Três mecanismos silenciosos empilhados — e um bug pode se esconder em produção por meses.