The aria-live region that unmounts with the overlay becomes B32+ F4, and the catalog bundle debt in B33+ stops being conditional now that B41 and B42 both shipped on that route without paying it.
25 KiB
Escala e DRY pós-B33 (ordenação e filtros de dobradinhas)
Status: entregue (2026-07-27)
Atualizado em: 2026-07-27
Item do roadmap: docs/roadmap.md (Fill-ins abertos, B33+, fill-in de engenharia)
Impeccable: A — N/A (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de URL nem markup visível)
Appetite: ~0,5 dia eng; fase única, sem migration, sem collection, sem Consent
Responsável: —
Como ficou (2026-07-27)
Entregue como planejado no essencial — um hook em shared/, três call sites, JSX intocado — com quatro correções que a auditoria pré-implementação impôs sobre o texto abaixo. Ele fica preservado como registro do raciocínio; o que vale é esta seção.
-
O racional da "Decisão travada" caiu: um único
toHrefcobre os três. O plano supunha que os domínios tinham assinaturas de canonicalização diferentes e que unificá-las custaria mudarbuildXListHref/parseXListParams. Falso:buildStateDeputyListHref,buildTerritoryListHrefebuildMunicipalityListHrefjá canonicalizam internamente (parseXListParams(xListStateToRawParams(...))dentro debuildXListSearchParams/serializeTerritoryListSearchParams), ebuildMunicipalityFiltersKeyé literalmentebuildMunicipalityListSearchParams(state).toString()— a query string canônica, equivalente a comparar hrefs sob o mesmo basePath. Logo oparse(...)explícito em dobradinhas e territórios era trabalho redundante, não diferença de domínio, e o hook recebe um só callbacktoHref, não o partoCanonical/toHref. A decisão de fundo (o hook recebe callback e nunca importa os módulos de URL por domínio) permanece intacta. -
Achado novo — divergência de comportamento, corrigida. Em
/campanha/municipioscada filtro/sort carregava a busca digitada e não commitada; em dobradinhas e territóriosnavigateTochamavaclearDebounce()primeiro, então tocar um filtro no meio da digitação descartava a busca pendente enquanto o input seguia exibindo o texto. O hook unifica na semântica de municípios vianavigateWithSearch, que é por isso a terceira função exposta — a "Questão em aberto" fecha com três nomes (navigateTo,navigateWithSearch,scheduleSearch), não dois — dos quais sónavigateWithSearchsobreviveu à segunda rodada de/simplify(ponto 10a).clearDebouncenão é exposto:navigateTolimpa antes de qualquer guarda, então os três "Limpar" só precisam desetSearch('') + navigateTo(clearXListFilters(state))— o que também eliminou orouter.replacesolto de municípios. -
Export orfanado, deletado.
shouldUpdateMunicipalitySearchUrl(municipalityListUrl.ts) eranormalizedText(input) !== currentQcom dois consumidores:MunicipalityFilterse o próprio spec. Com a guarda genérica dentro do hook, morreu junto com seudescribeemmunicipalityList.unit.spec.ts— knip trataexportscomo ERROR no CI. -
Rede de segurança onde não havia nenhuma:
tests/unit/campaignListFilterNavigation.unit.spec.tsrenderiza os três filtros em jsdom comnext/navigationmockado e timers falsos (toFake: ['setTimeout','clearTimeout']— o scheduler do React precisa de microtasks reais). Cinco casos × três listas na primeira volta (oito depois do ponto 9): debounce de 1000ms num únicoreplacecanônico, reinício da janela ao continuar digitando, dedup de no-op, cancelamento no unmount e o carregamento do rascunho de busca. Escrito antes da migração, o quinto caso nasceu vermelho em dobradinhas e territórios e verde em municípios, que é exatamente o achado 2. -
A extração tinha reimplantado o fallback de transition que já existia em quatro lugares (
/simplify, reuse review). O blocouseCampaignListPending() ?? useTransition()estava copiado emCampaignSearchForm,CampaignFilterChips,SupporterFilters,ActivityFilterseCampaignTransitionAnchor— o hook novo foi a sexta cópia, num item cuja tese é justamente extrair um padrão de 3 call sites. VirouuseCampaignListTransition()emCampaignListPending.tsx, consumido pelos seis;useCampaignListPendingdeixou de ser exportado (o contexto agora se lê por um caminho só). -
normalizedText(search) || state.qtambém era a mesma expressão nos três — o hook passou a devolvê-la comodraftQ, o que tirou o último import decampaignListUrldos três componentes e dá um lugar para resolver a assimetria que sobrou (o resumo cai noqcommitado quando a caixa esvazia;navigateWithSearchlimpa). Semântica preservada como estava. -
Uma correção de a11y que a comparação linha a linha revelou: só
MunicipalityFilterstinhaaria-live="polite"no parágrafo visível de resumo, sobre ummd:block— era a terceira região viva da mesma página (com osr-onlydo próprio form e o deCampaignListResults), anunciando o resumo de filtros a cada tecla. Removida, junto com umdivde um filho só herdado de quando a linha tinha dois botões. Isto quebra a promessa de "zero markup" do saldo abaixo, deliberadamente: era bug, não estilo. Omd:whitespace-normalficou (o resumo de municípios é o mais longo dos três). -
Bug latente que a extração passou a possuir, corrigido (
/simplify, code-quality review). OsetTimeoutdo debounce capturava ostatedo render que o agendou, e os controles que navegam de fora da casca (CampaignSortableHead, paginação doCampaignListFooter, a âncora "Limpar" do estado vazio) mudamstatesem tocar no timer. Em territórios e dobradinhas isso significava: digite, clique num sort dentro de 1s, e um segundo depois a navegação pendente revertia o sort. Municípios escapava por acidente — okey=da página remonta o componente e o cleanup mata o timer. AgorastateRefdá ao timer e à guarda de dedup o estado commitado mais recente, então a navegação pendente soma-se ao sort em vez de atropelá-lo, nos três. O caso novo do spec (não reverte uma navegação feita de fora da casca) foi verificado vermelho nas três listas com ostateRefrevertido. -
Outros achados da mesma revisão, todos baratos:
navigateWithSearchpassava a busca crua e dependia de um invariante que só existia num JSDoc — agora normaliza no próprio hook (href idêntico, invariante local);{ ...next, page: 1 }em dois call sites era morto (buildXListHref(state, page)já ignorastate.pagequando recebe o argumento) e saiu junto com um terceiro emMunicipalityFilters; a guardanormalizedText(value) === state.qdescheduleSearchera uma segunda fonte de verdade para "isto muda a URL?" — exatamente o que oshouldUpdateMunicipalitySearchUrldeletado era — e foi removida, deixando a dedup só emnavigateTo; e o spec passou a montar dentro deCampaignListPendingBoundary, porque as três páginas renderizam sob uma e o fallback local deuseTransitioné o ramo que nunca roda em produção (antes, todas as asserções corriam pelo ramo morto). Somaram-se casos para "Limpar", para esvaziar uma busca commitada e para provar que o timer morreu após o sort: 5 → 8 casos × 3 listas. -
Segunda rodada de
/simplify(reuse + performance), depois do rebase. Cinco correções, todas de reuso ou de superfície: (a)setSearch+scheduleSearcheram sempre chamados em par nos trêsonChange(em duas grafias diferentes) — viraram umonSearchChange(value), o que tirou os dois da superfície exportada; comnavigateTojá privado atrás declearSearchAndNavigate, o hook devolve 6 nomes em vez de 8, e não existe mais forma de ligar metade do par. (b)MunicipalityFiltersreimplementava inline obuildMunicipalityFilterHrefquemunicipalityListFilters.tsjá exporta com quatro consumidores; passou a recebê-lo, e o pin de página 1 volta a morar num lugar por domínio. (c) O mesmo helper faltava em dobradinhas, ondebuildStateDeputyListHref(next, 1)estava escrito à mão em quatro sítios — nasceubuildStateDeputyFilterHref, e a assimetria de aridade do proptoHref(dois unários e uma lambda) desapareceu. (d) SóMunicipalityFiltersnão mostrava o resumo de filtros ativos no blocomd:hidden: no telemóvel, onde esse bloco é o ponto todo, o utilizador de municípios não tinha indicação nenhuma de quais filtros estavam ligados. Adicionado o parágrafo que os outros dois já tinham. (e) A normalização que o ponto 9 tinha metido dentro denavigateWithSearchfoi revertida:scheduleSearchsempre entregou a tecla crua aotoHref, então normalizar num caminho e não no outro era a segunda política que o ponto 9 dizia estar a remover — agora os dois entregam cru e o serializador decide, documentado no cabeçalho do hook. Fora do saldo:stripTrailingSlash(utilities/seo.ts) era um export sem consumidor pré-existente emmainque fazia o knip do CI falhar; deixou de ser exportado.
Duas revisões da mesma rodada foram deferidas com gatilho em vez de aplicadas, e estão registadas no roadmap: extrair o <Field> de "Ordenar" (idêntico nos três, já com deriva de altura do select) e o par serialize/parseXSortValue triplicado verbatim nos três módulos de URL. A revisão de performance mediu a entrega como neutra (mesmo número de router.replace, mesma cadência de RSC) e o seu único achado real é anterior a este item: /campanha/municipios embarca ~10 kB gzip do catálogo no cliente por causa de um isMunicipalitySlug, também no roadmap.
Saldo medido no fim (incluindo as duas rodadas de /simplify): 278 linhas removidas contra 48 adicionadas nos três filtros, 325 contra 228 em todo o src — e as 138 linhas do hook novo (metade comentário) estão dentro desse "adicionadas", ou seja ~97 linhas líquidas a menos, sem nenhuma mudança de contrato de URL. O markup só se moveu no ponto 7. CampaignSearchForm (lideranças/assessores/organizações) segue fora de escopo, mas pelo motivo durável, não pelo que este plano registrava antes: não é "sem debounce" (adicionar um debounce reabriria a questão) — é que ele monta o href como ${basePath}?q=…, descartando todos os outros params, porque esses três domínios não têm módulo de URL nem parseXListParams. Entregá-lo a um hook cujo contrato é "canonicalizar, comparar, deduplicar" tornaria o contrato falso naquele call site. B29 provavelmente o substitui por um LeadershipFilters real.
Verificação: gate do AGENTS.md verde e bare (tsc --noEmit, pnpm lint, format:check, knip, check:cycles, 562 unit + 413 int, pnpm build local); Aikido sem findings. O e2e não pôde ser usado como veredito nesta sessão: a máquina estava com load average ~58 (dois outros worktrees com dev server ativo), e campaignMunicipalities.e2e.spec.ts falhou de forma instável tanto na árvore nova quanto na árvore limpa em stash — o flake pré-existente registrado no AGENTS.md, amplificado por timeout. campaignTerritories.e2e.spec.ts passou em todas as rodadas, e nenhum teste de municípios exercita o formulário de filtros (eles navegam direto por ?q=), então o e2e não cobria esta superfície de qualquer forma. É o ponto fraco conhecido da entrega: a hidratação real segue sem pin.
Dados → decisão → apresentação
- Vou apresentar dados? Não (N/A) — o lote é custo de manutenção de um hook de navegação já entregue três vezes; nenhuma métrica, série ou escala nova.
- Anti-goals de dado: N/A.
Contexto
B33 (ordenacao-filtros-lista-dobradinhas.md, entregue 2026-07-26) adicionou paridade mobile (StateDeputyFilters) espelhando TerritoryFilters (B21 ✓) e MunicipalityFilters (B16 ✓). A 2ª passagem /simplify da entrega (revisor reuse, 2026-07-26) confirmou que as três já são o 3º call site do mesmo scaffold de navegação: SEARCH_DEBOUNCE_MS = 1000, useState de busca, fallback useCampaignListPending() ?? useTransition() local, debounceRef com cleanup no useEffect, clearDebounce, navigateTo (compara o href canônico com o atual e só navega se mudou) e scheduleSearch (debounce que reusa navigateTo) — ~35–50 linhas idênticas ou quase idênticas em cada um dos três arquivos:
src/components/campaign/stateDeputy/StateDeputyFilters.tsx:31–90src/components/campaign/municipality/TerritoryFilters.tsx:31–84src/components/campaign/municipality/MunicipalityFilters.tsx:35–96
A diferença entre os três está só em como o next: State vira State canônico e href — StateDeputyFilters/TerritoryFilters chamam parseXListParams(xListStateToRawParams(...)) seguido de buildXListHref; MunicipalityFilters compara por buildMunicipalityFiltersKey em vez de recomputar o href duas vezes. O corpo do <form> (busca + resumo de filtros + botão "Limpar" + bloco mobile com Field/NativeSelect de ordenação) também se repete, mas os filtros do meio do bloco mobile (Partido/Território+Assessoria/4 multi-filtros) divergem por domínio — não faz parte deste lote.
O E10 já havia identificado esse padrão previamente (ver "Adiado com gatilho" de escala-dry-pos-e10.md) para os multi-filtros do bloco mobile interno; este lote é sobre o scaffold de navegação/debounce ao redor deles, que é outro pedaço do mesmo arquivo.
Objetivos
- Um único hook (
useCampaignListFilterNavigation) concentrasearch/setSearch,isPending,navigateToescheduleSearch; os três componentes de filtro passam a chamá-lo em vez de reimplementardebounceRef/useEffect/clearDebounce. - Comportamento visível idêntico: mesmo debounce de 1000ms, mesmo dimming via
useCampaignListPending, mesmas URLs geradas — sem migration, sem mudança de contrato de URL. - Guardrail: o hook não decide o que é canônico (isso continua em
campaignListUrl.ts+ módulo por domínio); ele só orquestra o ciclo debounce → canonicalizar → comparar →router.replacedentro de uma transition.
Decisões travadas
- O hook recebe callbacks (
toCanonical,toHref), não os módulos de URL por domínio. Assim ele não importastateDeputyListUrl/territoryListUrl/municipalityListUrle continua emshared/sem inverter a direção de dependência (components/campaign/<domínio>→ hook genérico, nunca o contrário). Fonte:/simplifyB33 rodada 2 (2026-07-26), reuse review. Rejeitado: o hook aceitar oStategenérico e um únicoresolveListUrlimportado decampaignListUrl.ts— os três domínios já têm assinaturas de canonicalização levemente diferentes (MunicipalityFilterscompara porbuildMunicipalityFiltersKey, os outros dois recomputam href duas vezes), então forçar uma assinatura comum agora exigiria mudarbuildXListHref/parseXListParamsdos três módulos só para caber no hook — inverte o custo (a duplicação é mais barata que a unificação de assinatura). - Só o scaffold de navegação sai; o
<form>/JSX permanece por componente. O corpo visual diverge o bastante (Partido vs Território+Assessoria vs 4 multi-filtros) que um componente de shell genérico viraria umchildren-render-prop só para economizar ~15 linhas de JSX repetido (input de busca + resumo + botão Limpar), o que é o tipo de abstração prematura que oengineering-standards.mdcpede para evitar. Rejeitado:CampaignListFilterBarcom slot para os filtros do meio — analisado e descartado por esticar a interface para 3 usos ainda divergentes. - i18n e naming seguem o AGENTS.md: identificador em inglês (
useCampaignListFilterNavigation), sem strings visíveis novas.
Questões em aberto
- O hook expõe
scheduleSearchsó para o campo de busca, ou também umcommitNowpara os outros controles (select/popover) chamaremnavigateTodireto? Opções: (a) expornavigateTocru +scheduleSearch(debounce) como duas funções separadas do hook; (b) sóscheduleSearch, com os outros controles chamandonavigateToimportado à parte. Recomendação: (a) — é exatamente o que os três componentes já fazem hoje (search usascheduleSearch, sort/filtros usamnavigateTodireto), então o hook só precisa expor os dois nomes que já existem, sem inventar terceiro.
Abordagem proposta
flowchart LR
Hook["useCampaignListFilterNavigation<br/>(shared/)"] --> SD["StateDeputyFilters"]
Hook --> TF["TerritoryFilters"]
Hook --> MF["MunicipalityFilters"]
Componentes:
useCampaignListFilterNavigation(novo,src/components/campaign/shared/useCampaignListFilterNavigation.ts): recebe{ initialQuery, toCanonicalHref(next), isDebounced }, devolve{ search, setSearch, isPending, navigateTo(next), scheduleSearch(value, buildNext) }. Internamente reusauseCampaignListPending()com fallback auseTransition()local (mesmo padrão dos três arquivos hoje) e odebounceRef/cleanup.StateDeputyFilters.tsx/TerritoryFilters.tsx/MunicipalityFilters.tsx: substituem os blocos duplicados (linhas citadas acima) pela chamada ao hook, passando otoCanonicalHrefespecífico do domínio (que já existe:parseXListParams+buildXListHref, oubuildMunicipalityFiltersKey). JSX inalterado.- Migration: sem migration, sem collection, sem server action.
Dependências
- Nenhuma de outro plano aberto. Reusa
useCampaignListPending(shared/CampaignListPending.tsx) já compartilhado pelos três.
Não escopo
- Unificar o
<form>/JSX dos três filtros — analisado e rejeitado acima; cada domínio mantém seu próprio corpo mobile. - Multi-filtros do bloco mobile saindo de
*ListFilters.tsdefinitions — já é o F4 de escala-dry-pos-e10.md; este lote não duplica esse escopo. - Extrair o par head/filtro do header desktop (
CampaignSortableHead/CampaignHeaderFilterPopover) — já compartilhado desde B21 ✓; fora deste lote, que é só o scaffold de busca/debounce.
Rabbit holes
- "Já que estou extraindo, generalizo
Statecom um único módulo de URL parametrizado." Se alguém "só completar": os três contratos de URL (stateDeputyListUrl.ts/territoryListUrl.ts/municipalityListUrl.ts) colapsam num DSL genérico, exatamente o anti-padrão que "Decisões travadas" de B33/B21/B29 já rejeitaram por acoplar três contratos independentes num só ponto de mudança. Mitigação neste item: o hook recebe callbacks, nunca importa os módulos de URL por domínio.
Adiado com gatilho
Achados do /simplify (2026-07-27) que são maiores que limpeza:
- Shell de
<form>compartilhado — a evidência registrada em "Decisões travadas" é mensuravelmente falsa e fica corrigida aqui. Normalizando os substantivos de domínio,TerritoryFilterseStateDeputyFilterssão 110 de 147 linhas byte-idênticas, e o par<form role="search">+ parágrafosr-only(13 linhas) é byte-idêntico nos três. O plano tratou "o meio diverge" como prova de "a casca diverge", e são afirmações diferentes: a casca sai porchildren(o idioma queCampaignListResults/LeaderContactsPanel/mapSlotjá usam), sem o render-prop que foi rejeitado. Gatilho: o 4º filtro de lista (oLeadershipFiltersde B29). Quem reabrir não deve reusar o argumento antigo. Ressincronizar a busca com— decidido e feito na 2ª rodada destate.q/simplify, pelo terceiro caminho: o hook segue a URL quando oqcommitado muda e não há debounce pendente (committedQ), então Back/Forward e a âncora "Limpar busca e filtros" atualizam a caixa, mas o texto de quem está a digitar nunca é atropelado — que era o custo com que os dois revisores se cobravam mutuamente. Com isso okey={buildMunicipalityFiltersKey(state)}de/campanha/municipiossaiu (era a única razão para as três páginas divergirem, e remontar a cada navegação committed matava o timer, tornando a guarda de dedup do ponto 8 código morto justamente num dos três call sites);buildMunicipalityFiltersKeyorfanou e foi deletado. Sobra a política de montagem de/campanha/apoiadorese/campanha/atividades, que continuam comkey=— não são consumidores do hook, então nada quebra; unificar quando adotarem a navegação compartilhada.<Field>de "Ordenar" extraído (CampaignListSortSelect). Os três blocos de 21 linhas diferem só nos identificadores de domínio — e já divergiram:MunicipalityFiltersusaclassName="min-h-11 w-full"onde os outros dois usam"w-full [&_select]:h-11", duas alturas para o mesmo controlo na mesma família de listas. O precedente de extração está na mesma pasta e tem os mesmos três call sites (CampaignMobileMultiFilterField). Não foi feito aqui por não haver decisão de qual altura é a correta — é escolha de design, não de código. Gatilho: a decisão de altura, ou o shell acima (que engole este bloco).serializeXSortValue/parseXSortValuetriplicados verbatim (municipalityListUrl.ts,territoryListUrl.ts,stateDeputyListUrl.ts):`${key}|${dir}`e umsplit('|')com doisSet.has.campaignListUrl.tsé a casa desse tipo de parsing partilhado (já temnormalizedText,allParamValues,parseExhaustiveEnumParam) e não tem nenhum helper de sort. Genérico lá remove as três cópias sem tocar em contrato de URL. Gatilho: o 4º módulo de URL com sort (B29), ou o primeiro bug de deriva entre as cópias.SupporterFilters/ActivityFiltersadotarem o hook. GanharamuseCampaignListTransition, mas não a navegação. A razão registada aqui antes era factualmente falsa e fica corrigida (reuse review, 2ª rodada): não é "montam o href sem passo de parse" —buildSupporterListSearchParamsebuildActivityListSearchParamschamamparseXListParams(...)na primeira linha, exatamente a propriedade que justificou colapsar os outros três num sótoHref. O impedimento real é ovaluesRef/useOptimisticespelhando todos os filtros, para o qual o hook não tem equivalente; e migrar lhes daria dedup de no-op, mudança de comportamento em duas superfícies vivas. Gatilho: quando um deles precisar de dedup ou de busca com debounce. Independente disso, os dois reconstroem o href à mão a partir deusePathname()emborabuildSupporterListHref/buildActivityListHrefjá existam e estejam pinados — dívida separada, no roadmap./campanha/municipiosembarca ~10 kB gzip do catálogo dos 435 municípios no bundle do cliente — achado da performance review, anterior a este item e a única coisa que ela encontrou: umisMunicipalitySlugnum componente cliente arrastamunicipalityCataloginteiro, exatamente a violação de fronteira que o B14 pagou 21 kB para aprender ("client components não importam módulos de dados do servidor para valores"). Fora de escopo aqui porque a correção é no lado que consome o predicado, não no hook. Gatilho: próximo trabalho de bundle em/campanha/municipios(o B41/B42 tocam a mesma rota). Atualização 2026-07-27: o gatilho disparou duas vezes e ninguém pagou — B41 ✓ e B42 ✓ entregaram na rota sem tocar nisto, e a performance review do B42 reconfirmou o achado no mesmo endereço (municipalityListUrl.tsimportaisMunicipalitySlug; os clientes que o arrastam sãoMunicipalityFilters,MunicipalitySortableHeadeMunicipalityHeaderFilter). Ou seja: já não é condicional a um futuro — é a dívida de bundle corrente da rota, e o próximo item que abrir estes arquivos deveria pagá-la em vez de re-registrar o gatilho. Escopo real da correção: mover o predicado para um módulo client-safe (ou fazer o servidor validar o slug antes de serializar o href), não tocar no hook.- Terceiro idioma de mock de router nos testes.
campaignListFilterNavigation.unit.spec.tsusavi.hoisted+vi.mock('next/navigation')com spread do módulo real; a casa já temstub<AppRouterInstance>dentro deAppRouterContext.Provider(campaignComponents.unit.spec.ts), que dá as mesmas asserções sem mock de módulo. Não convertido por ser troca de harness num spec de 10 casos × 3 listas que está verde. Gatilho: o 4º spec que precise de router, ou o primeiro sintoma de deriva do spread.
Referências
docs/roadmap.md(Fill-ins abertos — B33+)- ordenacao-filtros-lista-dobradinhas.md — o pai do lote (B33 ✓)
- escala-dry-pos-e10.md — F4, o débito irmão do bloco mobile interno (não sobreposto por este lote)
src/components/campaign/stateDeputy/StateDeputyFilters.tsx(:31–:90)src/components/campaign/municipality/TerritoryFilters.tsx(:31–:84)src/components/campaign/municipality/MunicipalityFilters.tsx(:35–:96)src/components/campaign/shared/CampaignListPending.tsx—useCampaignListPending, reusado pelo hook novo- AGENTS.md — módulo de dependência
lib/→utilities/→components/; abstrações precisam de 3+ call sites (aqui há exatamente 3, o piso do próprio AGENTS.md)