← Back to list

Por que seu app OutSystems tem CLS alto — e o que os Placeholders Canônicos têm a ver com isso

Decodificando o padrão escondido na documentação oficial que separa apps com Web Vitals saudáveis das que penalizam o ranking SEO.

Diógenes Dauster · 2026-06-17 18:04 · 56 claps · 6.8 min read
#low-code #outsystems #outsystems-development
Open on Medium ↗

Por que seu app OutSystems tem CLS alto — e o que os Placeholders Canônicos têm a ver com isso

Decodificando o padrão escondido na documentação oficial que separa apps com Web Vitals saudáveis das que penalizam o ranking SEO.

Você abre o Lighthouse de uma screen OutSystems Reactive. LCP está aceitável, INP em verde, mas o CLS está em 0.87. Vermelho. O Search Console começa a reportar a página como “needs improvement”. O ranking SEO cai.

O que aconteceu?

A resposta está num detalhe que poucos desenvolvedores low-code prestam atenção: a escolha do Layout block e como ele declara seus placeholders. A documentação oficial da OutSystems descreve esse padrão há anos, mas o impacto direto em Core Web Vitals raramente é discutido.

Neste artigo, vou conectar três pontos: (1) o que themes.md define como placeholders reservados; (2) por que esses nomes não são apenas convenção, mas sim a diferença entre CLS 0.02 e CLS 0.87 em produção; e (3) como Skeleton com altura reservada completa o que o Layout canônico começa.

O que é CLS e por que ele bate em você

Cumulative Layout Shift mede quanto o layout da página “pula” durante o carregamento. Cada vez que um elemento aparece e empurra outro pra baixo, conta pontos. Google soma tudo.

Os thresholds são:

  • Good: CLS ≤ 0.1
  • Needs improvement: 0.1 < CLS ≤ 0.25
  • Poor: CLS > 0.25

Desde 2021, CLS é um dos três Core Web Vitals usados como fator de ranking no Google Search. Em apps corporativas internas isso pode não importar. Em qualquer screen pública — landing, login, signup, blog, e-commerce — importa muito.

A causa raiz do CLS em apps Reactive Web é quase sempre a mesma: Blocks que renderizam tarde empurram o conteúdo que já estava no DOM. O React monta, o Block hidrata, e o que estava abaixo precisa “descer” pra dar espaço. Cada um desses movimentos é um shift.

A pergunta que poucos fazem

Quando auditei algumas apps OutSystems em produção esta semana — três delas, todas Reactive Web, com perfis bem diferentes — uma pergunta começou a se desenhar:

Por que duas apps usando o mesmo framework, no mesmo runtime, com complexidade visual parecida, têm CLS tão diferentes?

Os números:

A App A tem 65 blocks na Home, 14 módulos OS carregados, 38 arquivos CSS, 156 scripts. É a app mais complexa das três em volume de assets. E mesmo assim, CLS quase zero.

A App C tem 95 blocks, 13 módulos, 11 CSS, 90 scripts. Menos complexa. E CLS catastrófico.

A diferença não é de volume. É arquitetural. É sobre como o Layout block declara seus placeholders.

O que a documentação oficial diz (e poucos leem com atenção)

No documento themes.md, a OutSystems lista textualmente:

This is the list of reserved names for the Layout placeholders in the web Themes.

E enumera 7 nomes para Web Apps:

  • Title
  • MainContent
  • Breadcrumbs
  • Actions
  • Header
  • Menu
  • Footer

Para Mobile, são 6 nomes diferentes (HeaderLeft, Title, HeaderRight, HeaderContent, Content, Bottom).

A doc explica que esses nomes são “reservados” porque o platform usa eles para gerar a página e fazer drag-and-drop de Screen Templates funcionar. Mas o documento não menciona explicitamente o impacto em performance ou CLS. Isso é um efeito colateral arquitetural — e é o efeito mais valioso desses nomes.

Por que esses nomes mudam tudo

Quando o Layout block usa esses nomes específicos, o CSS do tema (qualquer um derivado do OutSystemsUI ou seguindo a convenção) aplica regras automáticas:

// Trecho de _themegrid-container.scss do repo OutSystems/outsystems-ui
.layout {
  .main-content.ThemeGrid_Container {
    padding: var(--space-xl);
  }
  .footer.ThemeGrid_Container {
    padding: var(--space-base) var(--space-xl);
  }
}

E mais importante: esses placeholders são renderizados como containers no DOM com altura/largura pré-calculadas pelo CSS do tema. O espaço fica reservado antes do conteúdo dos Blocks chegar.

Isso é o equivalente low-code de fazer min-height reservada manualmente — mas aplicado pelo framework, automaticamente, em escala.

Comparação anatômica dos três cenários

Quando inspecionei o DOM de cada uma das três apps, encontrei:

Cenário 1 · Layout com 7 placeholders reservados (App A)

LayoutFullChrome
├─ Header        (reservado)
├─ Menu          (reservado)
├─ Title         (reservado)
├─ Breadcrumbs   (reservado)
├─ Actions       (reservado)
├─ MainContent   (reservado)
├─ Footer        (reservado)
└─ Content       (reservado)

Resultado: cada zona do layout ocupa exatamente o espaço previsto antes do React montar qualquer Block. Quando os Blocks de UI carregam dentro do MainContent, eles entram num container que já tem altura — não há shift visível.

CLS: 0.02. Score perfeito.

Cenário 2 · Layout com 3 placeholders reservados + lazy chrome (App B)

LayoutsCustom
├─ Header       (reservado, com lazy load)
├─ MainContent  (reservado)
└─ Footer       (reservado, com lazy load)

Essa app segue a convenção, mas adiciona um pattern que vou abordar em outro artigo: um Block que faz lazy load de Header e Footer assíncronos. Isso é ótimo pra LCP — mas paradoxalmente piora um pouco o CLS no field (0.40 vs 0.04 lab), porque o chrome entra tarde e ainda assim empurra coisas. É um trade-off consciente da arquitetura.

Cenário 3 · Layout com placeholder de nome custom (App C)

LayoutPublicShell
├─ Header   (reservado)
├─ Title    (reservado)
├─ Content  (nome CUSTOM, NÃO reservado)  ← aqui mora o problema
└─ Footer   (reservado)

Essa app usa o nome Content no lugar de MainContent. Isso é uma decisão arquitetural deliberada — fazer o screen escapar dos estilos automáticos do .main-content.ThemeGrid_Container. Funciona, mas tem custo: o CSS do tema não aplica padding/altura reservada.

Pior: dentro desse Content custom, há um hero, um carousel e múltiplos cards que carregam dinamicamente, sem min-height declarada nos seus containers.

CLS: 0.87. Catastrófico. O hero empurra o carousel pra baixo aos 835ms. O carousel empurra os cards. Os cards empurram o footer. Layout shift em cascata.

A escolha do Layout é só o começo

Aqui está o ponto que separa apps boas de apps ótimas: o Layout canônico resolve o CLS do “chrome” da página (header, menu, footer, breadcrumbs). Mas não resolve o CLS do conteúdo dinâmico dentro do MainContent.

É aí que entra a técnica de Skeleton com altura reservada.

A técnica · Skeleton + altura reservada

A intuição é simples: se você sabe que um banner vai aparecer, reserve o espaço dele agora. Coloque um skeleton CSS-only no lugar. Quando o banner chegar, ele substitui o skeleton no mesmo espaço. Zero shift.

Em três passos

1. Adicione min-height ao Container que vai receber dados dinâmicos:

.banner-zone {
  min-height: 240px;
  background: var(--color-neutral-2);
  border-radius: 8px;
}

2. Crie um Block BannerSkeleton com HTML CSS-only:

<!-- HTML -->
<div class="banner-skeleton"></div>
<!-- HTML -->
<div class="banner-skeleton"></div>

/* CSS — shimmer animation */
.banner-skeleton {
  height: 240px;
  background: linear-gradient(90deg, #e5e7eb, #f3f4f6, #e5e7eb);
  background-size: 200% 100%;
  animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

3. No Service Studio, use If para alternar entre skeleton e Block real:

Container [Style Class: banner-zone]
└─ If Condition = BannerLoaded
   ├─ True:  Banner
   └─ False: BannerSkeleton

Quando o OnAfterFetch da Server Action seta BannerLoaded = true, o Block real entra no mesmo espaço de 240px. Zero CLS.

Visualizando o efeito

A comparação fica clara quando você vê os dois cenários lado a lado — o anti-pattern com container vazio que produz CLS, e o pattern com altura reservada que produz zero shift:

Por que If e não Visible?

Em OutSystems, há dois jeitos de esconder um Block. Eles parecem equivalentes. Não são.

Para Blocks pesados, sempre If. Visible apenas para toggle visual rápido em conteúdo já carregado.

A regra prática

Combinando o que a documentação oficial define com o que medi em produção, a regra que extraí é:

Use o Layout canônico com placeholders reservados para resolver CLS do chrome. Use Container com min-height + skeleton + If para resolver CLS do conteúdo dinâmico dentro do MainContent.

Isso é suficiente para chegar a CLS < 0.1 em qualquer screen Reactive Web, independente da complexidade.

Como verificar na sua app agora

Cole isso no DevTools console:

// 1. Qual Layout block está em uso?
const layout = document.querySelector('[data-block*="Layout"]');
console.log('Layout:', layout?.getAttribute('data-block'));
// 1. Qual Layout block está em uso?
const layout = document.querySelector('[data-block*="Layout"]');
console.log('Layout:', layout?.getAttribute('data-block'));

// 2. Quais placeholders existem?
const reserved = ['Title','MainContent','Breadcrumbs','Actions','Header','Menu','Footer'];
const found = reserved.filter(name =>
  document.querySelector(`[id$="-${name}"]`)
);
console.log('Placeholders reservados ativos:', found);

// 3. Quais containers vão receber conteúdo sem min-height?
[...document.querySelectorAll('[data-block]')].forEach(el => {
  const style = getComputedStyle(el);
  const hasMinHeight = style.minHeight !== '0px' && style.minHeight !== 'auto';
  if (!hasMinHeight && el.children.length === 0) {
    console.warn('Container sem min-height:', el.id);
  }
});

Se você encontrar placeholders não-reservados (como Content no lugar de MainContent) ou containers vazios sem min-height, esses são seus candidatos para aplicar a técnica de skeleton com altura reservada.

O ponto que vale repetir

A diferença entre CLS 0.02 e CLS 0.87 em apps OutSystems não é sobre quão complexa a screen é. É sobre seguir uma convenção que a documentação descreve há anos, mas que poucos times conectam com Web Vitals modernos.

Use os 7 placeholders reservados do Layout block. Reserve altura nos containers dinâmicos. Substitua conteúdo no mesmo espaço com If + Skeleton CSS-only.

A documentação já te disse o que fazer. O que faltava era ligar com a métrica que o Google penaliza.

OutSystems #Reactive #WebCore #Web Vitals #CLS #Performance #Low Code


메타데이터
post_id
ba57eae07b4d
slug
por-que-seu-app-outsystems-tem-cls-alto-e-o-que-os-placeholders-canônicos-têm-a-ver-com-isso-ba57eae07b4d
url
https://medium.com/@ddauster/por-que-seu-app-outsystems-tem-cls-alto-e-o-que-os-placeholders-can%C3%B4nicos-t%C3%AAm-a-ver-com-isso-ba57eae07b4d
canonical_url
https://medium.com/@ddauster/por-que-seu-app-outsystems-tem-cls-alto-e-o-que-os-placeholders-can%C3%B4nicos-t%C3%AAm-a-ver-com-isso-ba57eae07b4d
author_url
https://medium.com/@ddauster
status
ok
fetched_at
2026-07-24 03:33:04