From 3d55d5760f00e288b38673115677329b9a6ffe4d Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 8 Aug 2026 17:50:40 -0300 Subject: [PATCH] =?UTF-8?q?B169=20=E2=80=94=20estabilizar=20a=20lista=20de?= =?UTF-8?q?=20Dobradinhas:=20medir=20overflow=20de=20chips=20antes=20da=20?= =?UTF-8?q?paint=20(useLayoutEffect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/CHANGELOG-AGENTS.md | 2 + ...abilizar-lista-dobradinhas-flicker-impl.md | 92 +++++++++++++++++++ .../estabilizar-lista-dobradinhas-followup.md | 43 +++++++++ .../campaign/shared/RelationChipCell.tsx | 14 ++- 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 docs/plans/estabilizar-lista-dobradinhas-flicker-impl.md create mode 100644 docs/plans/estabilizar-lista-dobradinhas-followup.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index c45b4ca0..3265ee58 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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`/`teqo_wt_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*\_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). diff --git a/docs/plans/estabilizar-lista-dobradinhas-flicker-impl.md b/docs/plans/estabilizar-lista-dobradinhas-flicker-impl.md new file mode 100644 index 00000000..baf50463 --- /dev/null +++ b/docs/plans/estabilizar-lista-dobradinhas-flicker-impl.md @@ -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
antes da paint no cliente] --> B[Remount RSC pinta colapsado
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 diff --git a/docs/plans/estabilizar-lista-dobradinhas-followup.md b/docs/plans/estabilizar-lista-dobradinhas-followup.md new file mode 100644 index 00000000..87cbf167 --- /dev/null +++ b/docs/plans/estabilizar-lista-dobradinhas-followup.md @@ -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. diff --git a/src/components/campaign/shared/RelationChipCell.tsx b/src/components/campaign/shared/RelationChipCell.tsx index 808b9e4a..a104491b 100644 --- a/src/components/campaign/shared/RelationChipCell.tsx +++ b/src/components/campaign/shared/RelationChipCell.tsx @@ -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