B169 — estabilizar a lista de Dobradinhas: medir overflow de chips antes da paint (useLayoutEffect)

O ciclo medir→recolher de RelationChipCell rodava em useEffect (pós-pintura),
fazendo cada remount (navegação RSC, paginação, ordenação, filtro) pintar o
clamp max-h-18 e só então colapsar para o slice de 3 linhas — uma onda de
LayoutShift por célula (tabela 1122→682px). Mudar os 3 effects de
medição/invalidação para useLayoutEffect faz a primeira pintura já chegar na
altura final (medido: tabela estável, zero LayoutShift nas navegações).

Documenta o resíduo do load SSR (paint pré-hidratação) e registra follow-up
B170 (#441) para o spec do contrato de colapso + avaliação do shift único.
This commit is contained in:
Francisco Solla 2026-08-08 17:50:40 -03:00
parent 09d44c1b9d
commit 3d55d5760f
4 changed files with 148 additions and 3 deletions

View file

@ -10,6 +10,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-08):** **C90** (Issue #426) — os três campos de responsável da Atividade (`responsible` Contact, `advisors` staff, `leadership`) viraram **um campo `responsible` polimórfico multi-valor** (`campaignUser` staff · `leadership` · `stateDeputy`), removendo os dois antigos do modelo/UI; assessor continua vendo/curando o compromisso onde está listado como responsável via notação-objeto `equals {relationTo,value}` no access (provado por int test com `payload.find` real); seletor `ResponsibleMultiSelect` (chips tipados) + busca única `searchActivityResponsibleOptions` (escopo de leitura do ator, liderança engajada) — reusado pelo C91 na agenda; migração esquema+dados (advisors/leadership → `responsible`, Contact antigo descartado, contagem via NOTICE; SQL de reconciliação ensaiado contra cenário misto); busca `q` da lista virou título-only (regressão assumida no gate); notificação de atenção notifica só staff em `responsible`. Intenção [`remodelar-responsaveis-atividade.md`](docs/plans/remodelar-responsaveis-atividade.md); impl [`remodelar-responsaveis-atividade-impl.md`](docs/plans/remodelar-responsaveis-atividade-impl.md).
**Recently resolved (2026-08-08):** **B169** (Issue #425) — fim do flicker de linhas/colunas na lista de Dobradinhas (e nas demais listas com células de chips: Municípios, Lideranças, Assessores): o ciclo "medir → recolher" de `RelationChipCell` passou de `useEffect` (pós-pintura) para `useLayoutEffect` (antes da paint). Antes, cada remount (navegação RSC, paginação, ordenação, filtro) pintava o clamp `max-h-18` (72px) e só então colapsava para o slice de 3 linhas — a tabela ia 1122→682px numa onda de LayoutShift por célula (medido `0.021×6` por navegação). Agora a primeira pintura já é a altura final; medido durante ordenação: tabela estável em 682px, zero LayoutShift das células. Resíduo documentado: o load SSR inicial ainda pinta o estado medindo (pré-hidratação) — `useLayoutEffect` não alcança essa paint; estimar largura no servidor foi rejeitado (reintroduziria a estimativa por label que o código evita). Sem migration, dados, access ou mudança visual. Impl [`estabilizar-lista-dobradinhas-flicker-impl.md`](docs/plans/estabilizar-lista-dobradinhas-flicker-impl.md).
**Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*<worktree>\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested).
**Recently resolved (2026-08-08):** **B164** (Issue #400) — barra de navegação inferior fixa no mobile para staff, com 5 itens (Início, Municípios, Atualizações, Agenda, Mais) e drawer overflow "Mais" para destinos secundários (Quadro, Territórios, Organizações, Líderanças, etc.) + rodapé Perfil/Sair. Leader e desktop mantêm sidebar-only (lockdown intacto). `CampaignContentScroll` e `CampaignQuickActionsHost` ajustados para safe-area e z-index do FAB sobre a barra. `/campanha/atualizacoes` é placeholder honesto (âncora C89). Impl [`barra-navegacao-inferior-mobile-impl.md`](docs/plans/barra-navegacao-inferior-mobile-impl.md).

View file

@ -0,0 +1,92 @@
# Impl: Estabilizar a lista de Dobradinhas (parar o flicker de linhas/colunas)
Status: aprovado
Atualizado em: 2026-08-08
Issue: #425
Intenção: docs/plans/estabilizar-lista-dobradinhas-flicker.md
Appetite restante: ~0,5–1 dia eng (herdado)
## Leitura da intenção
- **Outcome:** a lista de Doradrinhas (`/campanha/dobradinhas`) deixa de "piscar": linhas (e a sensação de colunas) mudam de tamanho em onda após a primeira pintura, a cada carga, paginação, ordenação ou filtro. A tabela deve aparecer já no tamanho final; "Ver mais…" mantém o recolhimento/expansão sob demanda.
- **O que NÃO negociar:** não redesenhar as células de chips; não mudar a regra de recolher em 3 linhas; não mexer em dados/permissões/URLs/schema; corrigir no dono compartilhado das células de chips (`RelationChipCell`) para valer nas demais listas (Municípios, Lideranças, Assessores) sem regressão.
- **O que reavaliar:** a intenção assumia que o ciclo "medir → recolher" era o culpado e que a forma de corrigir estava "no dono compartilhado". Investigação confirmou a causa raiz empírica — mas a correção tem um resíduo no load SSR que a intenção não previu (ver "Abordagem recomendada" e "Riscos").
## Causa raiz (confirmada por medição no browser, porto 3269 / DB `teqo_wt169`)
`RelationChipCell` (src/components/campaign/shared/RelationChipCell.tsx) resolve o overflow fazendo a **mediação num `useEffect` (pós-pintura)**:
1. Estado inicial/SSR: `visibleChipCount === null` → `measuring = true` → monta **TODOS os chips** (até 192 no seed sintético) com `max-h-18 overflow-hidden` (clamp de 72px). A linha nasce com ~98px e a tabela com ~1122px.
2. `useEffect` roda **depois da paint** → mede quantos cabem em 3 linhas → `setVisibleChipCount`.
3. Re-render: `visibleChips = chips.slice(0, visibleChipCount)`, `clamping = false` → linha colapsa para ~58px, tabela para ~682px. **Reflow + LayoutShift real.**
Medições (PerformanceObserver `layout-shift`, `hadRecentInput=false`):
- **Load original:** shifts `[0.139, 0.215, 0.129, 0.021×6]`; tabela `1122→682px`; linhas `98→58px`; 192 chips montados no estado medindo.
- **Navegação original (ordenar por partido):** shifts `[0.215, 0.128, 0.021×6]` — a "fila" de 0.021 é **uma onda por célula** ajeitando a altura.
- **Probe `useLayoutEffect` (navegação):** a fila `0.021×6` **some**; frames da tabela ficam estáveis em `682px`/`58px` — nenhum frame intermediário de 1122px. (Os shifts residuais 0.193/0.043/0.021 são do shell, não da tabela.)
- **Probe `useLayoutEffect` (load/reload):** ainda `1122→682px` — porque o **SSR envia o HTML no estado medindo** (todos os chips) e o browser pinta isso antes da hidratação; o `useLayoutEffect` só roda após.
Conclusões:
- **Navegações client-side (paginação, ordenação, filtros)** — o sintoma que "se repete a cada…" — são 100% resolvidas movendo a mediação para `useLayoutEffect` (medição síncrona antes da paint no remount RSC).
- **Load SSR inicial** ainda produz um único shift da paint pré-hidratação com o fix apenas de `useLayoutEffect`.
## Abordagem recomendada
```mermaid
flowchart LR
A[Medição em useLayoutEffect<br/>antes da paint no cliente] --> B[Remount RSC pinta colapsado<br/>navegações 100% estáveis]
C[SSR medindo envia todos os chips] --> D{Load inicial}
D -->|aceitável| E[1 shift único pré-hidratação]
```
**Opções consideradas:**
- **A — `useLayoutEffect` no ciclo de mediação** (os efeitos de measurar/invalidar de `RelationChipCell`). Simples, dono compartilhado, resolve o caso dominante (paginação/ordenação/filtro) por completo.
- **B — `useLayoutEffect` + pré-render do estado colapsado no servidor** (estimativa determinística do número de chips que cabem em 3 linhas, usando a largura máxima conhecida das colunas `max-w-64/72` e comprimento dos labels). Zeraria também a paint SSR, mas reintroduz **exatamente** a estimativa por largura que o código deliberadamente evita ("read from the real layout instead of estimated from label lengths") — risco de divergência visual na última linha.
- **C — reserva de altura estável no estado medindo** (clamp igual ao colapsado). Testada empiricamente como variação de probe (`clamping` persistente com `hasHiddenChips`): **não** estabilizou o load (o `max-h` é teto, não piso — o slice colapsado é naturalmente mais baixo).
**Recomendação: A**, com o resíduo do load SSR documentado e tratado pelo `useLayoutEffect` + a medição já existente no `ResizeObserver` cobrindo ajustes pós-layout. A se o produto exigir (ver "Riscos") pode ir como follow-up, **não** nesta entrega (appetite).
**Rejeitadas:** B (estimativa de texto), C (empiricamente falhou), e qualquer mudança de layout/regra de colapso (anti-goal da intenção).
### Componentes / mudanças
- **`RelationChipCell`** (src/components/campaign/shared/RelationChipCell.tsx): trocar os `useEffect` de medição e invalidação (chipsKey, measuring, ResizeObserver de largura) por `useLayoutEffect`, para que a mediação aconteça **antes** da primeira paint do componente no cliente. Mantém-se `useEffect` para effects não-layout (blur debounce, reconciliações de estado puro). Adição mínima: import `useLayoutEffect`; troca de `useEffect` → `useLayoutEffect` nos 3 effects de medição (385–403 e 410–449, linhas de referência atuais).
- Detalhe de correção: como hoje o `useEffect` de `invalidateMeasurement` (chipsKey) roda pós-paint, mover para layout effect garante que a re-medida provocada por mudança de conteúdo também aconteça antes da paint no remount.
- **Migration:** sem migration (nenhuma mudança de schema/dados).
- **Access / Consent:** toca nenhum.
- **UI:** Impeccable B — estabilidade de lista existente; estados visuais e copy preservados; shells de lista (`CampaignTable`) intocados.
### Dados → forma
- N/A — não há dados novos; aceite mensurável por observação (CLS da carga/navegação ≈ 0 atribuível às células, validado via PerformanceObserver no browser durante a execução).
## Fases verificáveis
1. **Tracer / server+UI** — mudança pontual em `RelationChipCell` (useLayoutEffect na medição). Quota do appetite: ~30 min.
2. **Verificação no browser** — dev server do worktree; logar como coordenador seed; `PerformanceObserver('layout-shift')` em `/campanha/dobradinhas`:
- Navegação client-side (ordenar por partido, paginar, filtrar): nenhuma mudança de altura da tabela (frames estáveis 682px/58px) — **é o gate principal**.
- Load/reload: shift residual documentado (único, pré-hidratação).
- Confirmar nas outras listas que usam as células (Municípios, Lideranças, Assessores) sem regressão visual.
3. **Gates** — `pnpm gate:fast`; `pnpm test` (unitários das células não devem quebrar: `measureOverflow` já é stubável); `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`; `pnpm build` local. Push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Zerar a paint SSR inicial com estimativa de largura (opção B) — postergado como follow-up; o `useLayoutEffect` + `ResizeObserver` existente já cobrem o layout real.
- Ajustar `COLLAPSED_CHIP_ROWS` / `max-h-18` — não muda a regra de colapso (anti-goal).
- Tratar fonts-cls / sidebar / Sollinha — outras fontes de shift de layout, fora do escopo (o plano de intenção já as separa).
## Riscos e mitigação
- **`useLayoutEffect` em SSR:** é inofensivo (não roda no servidor); o componente já é `'use client'`. Os testes unitários existentes (`municipalityPortfolioCell`, `leadershipStateDeputyRelationCell`, `stateDeputyAdvisorRelationCell`) stubbam `ResizeObserver` e não medem layout real — não devem quebrar; confirmar no gate.
- **Medição antes da paint pode custar um layout sync em massa** (centenas de chips) no primeiro render. O código atual já mede com `getBoundingClientRect` de todos os chips igualmente; a mudança só move o _timing_ — sem custo novo.
- **Divergência da intenção:** a intenção assumia que o ciclo medir→recolher "acontecia depois da primeira pintura" e que resolver no dono bastaria para tudo; a medição mostrou que no **load SSR** há uma pintura do estado medindo antes da hidratação que `useLayoutEffect` não alcança. Mantemos a correção no dono (cobre o que se repete) e documentamos o resíduo do load; não inventamos produto novo.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (onda de linhas/colunas em paginação/ordenação/filtro eliminada; "Ver mais…" intacto; demais listas sem regressão)
- [ ] Invariantes AGENTS/engineering-standards (nada de schema/access/URLs; identificadores em inglês)
- [ ] Testes de domínio previstos: unitários das células continuam verdes; verificação CLS por PerformanceObserver no browser registrada no diff/entrega
- [ ] Cobertura multi-lista validada (Municípios, Lideranças, Assessores) no browser

View file

@ -0,0 +1,43 @@
# B170 — Follow-up B169: contrato do colapso de chips + shift único no load SSR
Status: rascunho
Atualizado em: 2026-08-08
Issue: #441
Priority: P2
Model: composer-2.5
Model-local: deepseek-v4-flash-high
Impeccable: A — engenharia (teste de contrato + resíduo de first-paint), sem mudança de superfície
Appetite: ~0,5–1 dia eng
Responsável: —
## Intenção
Follow-up do B169 (#425, entregue). Duas lacunas registradas na triage de débitos daquela entrega, na mesma superfície (`RelationChipCell` / listas de chips de `/campanha`):
1. **Contrato do colapso sem teste** — nenhum spec cobre "Ver mais…"/"Ver menos"/`visibleChipCount` parcial com layout real (specs atuais usam `measureOverflow: false` ou colapsam para 1 chip). A mudança de timing do B169 (medição em `useLayoutEffect`) não tem regressão unitária do comportamento de colapso.
2. **Shift único no load SSR** — `useLayoutEffect` elimina a onda de LayoutShift nas navegações client-side (caso dominante), mas o carregamento inicial (SSR) ainda pinta o estado medindo (todos os chips, clamp `max-h-18`, ~1122px) antes da hidratação e só então colapsa (~682px) — um LayoutShift único pré-hidratação.
## Objetivo e aceite
- F1: um spec unitário pinando o contrato do colapso (recolher para N chips/3 linhas, "Ver mais…" expande, "Ver menos" recolhe) com `getBoundingClientRect` stubado para múltiplos chips.
- F2: avaliar e, se possível, eliminar o shift único do load SSR, **sem** reintroduzir a estimativa de largura por label (razão da rejeição na entrega B169: o dono mede layout real de propósito).
- Sem mudança de layout, regra de colapso (3 linhas), dados, access ou schema.
## Direção no codebase
- F1: `tests/unit/` — spec que renderiza `RelationChipCell` (ou wrapper) com > N chips e `Element.prototype.getBoundingClientRect` stubado (precedente: stubs de `scrollIntoView`/`ResizeObserver` nos specs existentes).
- F2: `src/components/campaign/shared/RelationChipCell.tsx` — única alternativa ao `useLayoutEffect` atual seria o servidor conhecer o slice antes da paint; caminho barato a investigar primeiro: garantir que o estado SSR inicial use o clamp de 3 linhas de forma **estável** (sem ondulação por célula), não a estimativa de largura.
## Rabbit holes / Não escopo
- Redesign das células de chips ou da tabela (anti-goal já travado).
- Outras fontes de deslocamento de layout na shell (sidebar, Sollinha, agenda).
- Migrations, dados, access.
## Dependências
- `depends: [425]` — destrava quando o B169 flipar done (Issue #441 nasceu blocked; `pnpm agent:ready -- --issue 441` após o plano chegar a main).
## Questões em aberto
- O shift único do load (0,13–0,26) é aceitável em produção? Se sim, F2 vira só o teste de contrato e o shift documentado no CHANGELOG (com medição). Validar com medição no browser; sem evidência de impacto, F2 é defer.

View file

@ -6,6 +6,7 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -382,12 +383,19 @@ export const RelationChipCell = ({
// chips must not throw away a measurement that is still valid. The remove
// button is absolutely positioned, so revealing it never changes a chip's box.
const chipsKey = chips.map((chip) => chip.key).join('|')
useEffect(() => {
// B169 — the whole measure→collapse cycle runs in `useLayoutEffect`, not
// `useEffect`: measuring after paint made the clamp height (`max-h-18`, 72px)
// render for a frame and only then collapse to the computed 3-row slice (rows
// that fit ~2 lines shift visibly) — a LayoutShift wave down the table on every
// remount (RSC navigation, pagination, sorting, filtering). Measuring
// synchronously before paint keeps the first frame already at final height.
useLayoutEffect(() => {
if (!effectiveMeasureOverflow) return
invalidateMeasurement()
}, [chipsKey, effectiveMeasureOverflow, invalidateMeasurement])
useEffect(() => {
useLayoutEffect(() => {
if (!effectiveMeasureOverflow) return
const row = chipRowRef.current
if (!row) return
@ -407,7 +415,7 @@ export const RelationChipCell = ({
* three visible rows — and the room the toggle needs on the third one — is
* read from the real layout instead of estimated from label lengths.
*/
useEffect(() => {
useLayoutEffect(() => {
if (!effectiveMeasureOverflow) return
if (visibleChipCount !== null) return
const row = chipRowRef.current