From 23a061098ac7656bfbcc71e63777c4c4f6d90ff7 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 17:36:52 -0300 Subject: [PATCH] =?UTF-8?q?feat(campaign):=20serializa=20muta=C3=A7=C3=B5e?= =?UTF-8?q?s=20do=20editor=20de=20rela=C3=A7=C3=B5es=20por=20c=C3=A9lula?= =?UTF-8?q?=20(Closes=20#378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fila encadeada por instância no MunicipalityRelationEditor: delta otimista imediato + transporte serializado (próximo elo só dispara quando o anterior assenta), falha reverte só o delta do próprio elo e a fila segue, busy derivado da fila (nunca preso), drain aplica o último selectedIDs confirmado só quando a resposta final o traz (JSON) — sem ele (dobradinhas) confia no RSC. Substitui requestSeqRef/latestConfirmedRef-seq por fila; tempID de create em contador próprio. Contratos dos wrappers e call sites intactos. --- docs/changelog/2026-08-24-b160.md | 1 + ...mutacoes-editor-relacao-municipios-impl.md | 130 +++++++ .../shared/MunicipalityRelationEditor.tsx | 180 +++++---- .../municipalityRelationEditor.unit.spec.tsx | 343 +++++++++++++++++- 4 files changed, 571 insertions(+), 83 deletions(-) create mode 100644 docs/changelog/2026-08-24-b160.md create mode 100644 docs/plans/serializar-mutacoes-editor-relacao-municipios-impl.md diff --git a/docs/changelog/2026-08-24-b160.md b/docs/changelog/2026-08-24-b160.md new file mode 100644 index 00000000..9dcd8311 --- /dev/null +++ b/docs/changelog/2026-08-24-b160.md @@ -0,0 +1 @@ +**B160 — serializar mutações do editor de relações por célula (2026-08-24):** o editor compartilhado (`MunicipalityRelationEditor`, B159) disparava `onToggle`/`onCreate` em paralelo e reconciliava por "última confirmação vence" (`requestSeqRef`/`latestConfirmedRef`), então duas mutações rápidas da mesma célula podiam terminar no banco em ordem diferente da intenção, e a falha revertia só o delta local — podendo apagar alteração posterior válida. **Fila encadeada por instância do editor:** cada toggle/create aplica o delta otimista imediato e enfileira o transporte (`queueRef` + pump serializado com `drainingRef`); o próximo transport só dispara quando o anterior assenta, células/linhas diferentes seguem em paralelo (fila por instância, nunca global). Falha reverte somente o delta do próprio elo (closure) e a fila segue; busy deriva da fila (decrementado em `finally`, nunca preso); no drain o último `selectedIDs` confirmado substitui o estado local só quando a resposta final o trouxe (JSON de assessores/lideranças) — sem ele (dobradinhas) o estado otimista permanece e o RSC reconcilia (`latestConfirmedRef.current = result.selectedIDs ?? null`). Substituída a máquina de sequência: `requestSeqRef` removido (respostas em ordem tornam a comparação de sequência obsoleta), `pendingCountRef` → tamanho da fila, `tempID` de criação desacoplado num contador próprio (`nextCreateTempIDRef`). Contratos `MunicipalityRelationTriggerProps`/`MunicipalityRelationMutationResult`, wrappers e call sites intactos (zero diff); sem migration/access/rotas; UI Impeccable A. Testes: teste que pinava settle fora-de-ordem reescrito para a nova premissa no mesmo commit (B só dispara após A; falha de A não reverte B; busy nunca preso; drain sem `selectedIDs` confia no RSC; paralelismo entre duas instâncias), mantidos dedupe create×RSC e known-entries; suíte unit completa verde (257 arquivos/2460 testes) + int da superfície (30 testes) + e2e smoke `campaignHomeActions` (18 testes, prod mode). Impl plan `docs/plans/serializar-mutacoes-editor-relacao-municipios-impl.md` (self-score 4/5). \ No newline at end of file diff --git a/docs/plans/serializar-mutacoes-editor-relacao-municipios-impl.md b/docs/plans/serializar-mutacoes-editor-relacao-municipios-impl.md new file mode 100644 index 00000000..fe308e8c --- /dev/null +++ b/docs/plans/serializar-mutacoes-editor-relacao-municipios-impl.md @@ -0,0 +1,130 @@ +# Impl: Serializar mutações do editor de relações por célula + +Status: rascunho +Atualizado em: 2026-08-24 +Issue: #378 +Intenção: docs/plans/serializar-mutacoes-editor-relacao-municipios.md +Appetite restante: ~0,5 dia eng; manter sem migration, rota nova, mudança de access ou de contratos + +## Leitura da intenção + +- **Outcome:** mutações de uma mesma célula persistidas na ordem em que foram solicitadas, mantendo a resposta otimista imediata. O editor compartilhado (`MunicipalityRelationEditor`) hoje dispara `onToggle`/`onCreate` em paralelo (`void (async () => …)()` L194/L234) e reconcilia por "última confirmação vence" (`requestSeqRef` L111, `latestConfirmedRef` L112, `finishRequest` L166-171) — a ordem de chegada das transações no banco pode divergir da ordem de intenção, e a falha reverte só o delta local da própria mutação (L195-202, L235-242), podendo apagar alteração posterior válida. É exatamente esse o bug-alvo. +- **O que NÃO negociar:** resposta otimista imediata (nenhum bloqueio de UI durante salvamento); sem fila global — a chave de serialização é a instância do editor (célula/linha); células e linhas diferentes continuam salvando em paralelo; falha intermediária não apaga alteração posterior válida nem deixa o editor preso em loading; sem versionamento otimista/idempotency key no servidor (o servidor já serializa por lock advisory por município — a fila é puramente client-side); sem mudança de schema/migrations/access/rotas/contratos; os três wrappers (Assessores, Lideranças, Dobradinhas) e call sites não mudam de props. +- **O que reavaliar:** o destino da máquina de sequência atual (`requestSeqRef`/`latestConfirmedRef`/`pendingCountRef`) — a fila encadeada muda a premissa do teste `municipalityRelationEditor.unit.spec.tsx` L31-79 (fora-de-ordem → em-ordem), que precisa ser reescrito no mesmo commit; o `tempID` negativo de criação (`-requestSeq` L229) fica acoplado ao contador de sequência e precisa de contador próprio; se a fila vira hook co-locado testável ou permanece inline no editor (decisão de engenharia, não de produto). + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph Editor["MunicipalityRelationEditor — 1 instância por célula/linha"] + G["gesture toggle/create"] --> OD["aplica delta otimista em selectedIDs — resposta imediata"] + OD --> Q["enfileira mutação na fila da instância"] + Q --> P["pump: dispara o próximo transport quando o anterior resolve"] + P --> R["resposta em ordem"] + R --> OK{"sucesso?"} + OK -->|sim| DRAIN["grava selectedIDs confirmado; no drain aplica / senão RSC reconcile"] + OK -->|erro| REV["reverte somente o delta da mutação que falhou"] + REV --> P + DRAIN --> P + end + P --> W1["Wrapper Assessores — JSON /campanha/municipios/advisors"] + P --> W2["Wrapper Lideranças — JSON /campanha/municipios/leaderships"] + P --> W3["Wrapper Dobradinhas — server actions FormData"] + W1 --> S[("Postgres — lock advisory por município")] + W2 --> S + W3 --> S +``` + +**Opções consideradas:** A) fila encadeada por instância do editor (transporte serializado, delta otimista imediato, revert por-mutação) | B) manter requisições paralelas e só reordenar a aplicação local | C) abortar a fila na primeira falha +**Recomendação:** A — resolve o problema na origem (ordem de chegada/execução no banco, não a aplicação local), mantém o aceite de falha intermediária (reverte só o delta da mutação que falhou e segue a fila) e preserva a resposta imediata. Com respostas em ordem, `latestConfirmedRef` e o reconcile RSC continuam válidos sem comparação de sequência. +**Rejeitadas:** B porque a divergência é a ordem da transação no servidor — reordenar a aplicação local não reordena o banco e mantém o transporte fora de ordem; C porque viola o aceite ("falha intermediária não apaga alteração posterior válida") e pode deixar display divergente do servidor. + +### Decisões de engenharia + +**Onde vive a fila** +Opções: dentro do `MunicipalityRelationEditor` (por instância) | hook compartilhado em `lib/` | por-wrapper +Recomendação: dentro do editor (por instância) — o editor já é dono da máquina otimista (pending/sequência/reconcile) e cada célula é uma instância do editor montada por linha (`cell: (municipality) => …` em `MunicipalityList.tsx` L496/L550/L583; cards em `MunicipalityMobileCard.tsx` L344/L374/L395), então a fila por instância serializa exatamente as mutações daquela célula e deixa células/linhas diferentes em paralelo, como o aceite exige. Os wrappers têm transportes diferentes (JSON routes vs server actions FormData) mas ambos retornam `Promise` — a serialização é agnóstica ao transporte. Para testabilidade, a fila pode ser um hook pequeno co-locado em `src/components/campaign/shared/` (ex. `useRelationMutationQueue.ts`), sem exportar contrato público. +Alternativas rejeitadas: hook em `lib/` porque há um único consumidor (YAGNI dentro do appetite de ~0,5 dia; não existe utilidade de fila/promise-chain em `lib/` hoje — não inventar caminho paralelo; a intenção pede fila por instância/célula); por-wrapper porque duplicaria a serialização em três wrappers, vazaria a política de ordenação para o transporte e reabriria a divergência que B159 consolidou no editor. + +**Política de falha na fila** +Opções: (a) encadear promises — cada mutação só dispara quando a anterior resolve — e reverter apenas o delta da mutação que falhou, mantendo as posteriores na fila | (b) manter requisições paralelas e só reordenar a aplicação | (c) abortar a fila na falha +Recomendação: (a) — o aceite exige que falha intermediária não apague alteração posterior válida; com encadeamento as respostas chegam em ordem, a semântica de `latestConfirmedRef`/reconcile RSC continua válida e o estado final persistido reflete a ordem de intenção. A falha de A reverte o delta otimista de A (closure do elo, como hoje L195-202/L235-242) e a fila segue processando B — B foi construída sobre o estado otimista e o servidor nunca viu A, então estado final = baseline + B, idêntico ao display pós-revert. +Alternativas rejeitadas: (b) não resolve o problema — a divergência é a ordem da transação no banco; (c) viola o aceite e pode deixar o editor com estado divergente do servidor. + +**Interação durante requisição pendente** +Opções: aplicar o delta otimista imediatamente e enfileirar o transporte | esperar a requisição em voo terminar para aplicar o próximo delta +Recomendação: delta otimista imediato + transporte serializado — o aceite exige "resposta imediata da interface" e o anti-goal "não bloquear a UI durante salvamento"; o toggle seguinte não espera a rede, apenas o seu transporte espera a vez na fila. +Alternativas rejeitadas: esperar a requisição reintroduz o bloqueio que o editor otimista foi criado para eliminar e contradiz o fluxo "continuar vendo a resposta otimista" da intenção. + +**Dobradinha sem `selectedIDs` na resposta** +Opções: a fila passa a exigir `selectedIDs` em todo sucesso | a fila serializa só o transporte e mantém a confirmação pelo RSC quando a lista não vem +Recomendação: a fila serializa o transporte e nunca depende de `selectedIDs` — no contrato `MunicipalityRelationMutationResult`, `selectedIDs` é opcional por design (B159), e o toggle de dobradinha retorna só `{ status: 'success' }` (`MunicipalityStateDeputyRelationCell.tsx` L83-85); a confirmação continua vindo do RSC (`currentIDs`/efeito L119-124) e, quando a lista vem (JSON de assessores/lideranças), o drain aplica o último `selectedIDs` confirmado. +Alternativas rejeitadas: exigir a lista quebraria o contrato e forçaria mudança no wrapper/server action de dobradinha — fora do escopo ("não mudar contratos"). + +**Destino de `requestSeqRef`/`latestConfirmedRef`/`pendingCountRef`** +Opções: manter a máquina de sequência paralela e adicionar a fila por fora | substituir pela fila, preservando o que ainda faz sentido +Recomendação: substituir — com respostas em ordem, `requestSeqRef` perde a função (o "maior sequência confirmada" é sempre a última resposta); `pendingCountRef` vira o tamanho da fila (busy derivado dela, decrementado em `finally` de cada elo — nunca preso); `latestConfirmedRef` permanece com a mesma semântica (último `selectedIDs` confirmado aplicado no drain quando a fila esvazia), sem comparação de sequência. O `tempID` negativo de create deixa de ser `-requestSeq` (L229) e passa a um contador monotônico próprio da instância (`-1, -2, …`), desacoplando o sentinela pendente da ordenação e preservando a invariante `isPendingCreateID` (L71). +Alternativas rejeitadas: manter a máquina paralela ao lado da fila duplicaria dois mecanismos de concorrência no mesmo componente (divergência provável, teste pior, mais código para o appetite). + +### Componentes / mudanças + +- **`src/components/campaign/shared/MunicipalityRelationEditor.tsx`** — dono da fila por instância: `toggle`/`create` aplicam delta otimista imediato e enfileiram o transport; pump serializado (cada elo dispara quando o anterior resolve); falha reverte só o delta do próprio elo e segue a fila; busy derivado do tamanho da fila; `latestConfirmedRef` mantido sem sequência; reconcile de props externas, bridges de criação e canal de falha (`useCampaignCellFailureChannel`) preservados. +- **`src/components/campaign/shared/useRelationMutationQueue.ts`** (novo, co-locado) — fila testável isoladamente: `enqueue(mutation, revertDelta)`, pump encadeado, drain, busy; sem exportar contrato público (opcional se ficar inline no editor, mas a fase 1 exige teste isolado — recomenda-se o hook co-locado). +- **Wrappers** (`MunicipalityListAdvisorsControl.tsx`, `MunicipalityListLeadershipsControl.tsx`, `MunicipalityStateDeputyRelationCell.tsx`) — sem mudança de contrato; esperado zero diff (continuam retornando `Promise`). +- **Call sites** (`MunicipalityList.tsx`, `MunicipalityMobileCard.tsx`, `MunicipalityListMobileCards.tsx`) — sem mudança. +- **`tests/unit/municipalityRelationEditor.unit.spec.tsx`** — reescrever o teste L31-79 (premissa muda: fora-de-ordem → em-ordem) e adicionar casos de fila; ver "Fases verificáveis". +- **Migration:** sem migration — nenhuma mudança de schema. +- **Access / Consent:** nenhuma política nova — fila client-side pura, mesmo staff gate/escopo servidor intactos. +- **UI:** Impeccable A — nenhuma mudança visual; comportamento de transporte apenas. B (microfeedback de fila, ex. spinner por chip) rejeitado porque não está no aceite, adiciona churn visual sem pedido de produto e o trigger já sinaliza busy (`triggerBusy`/spinner L297/L314). + +## Fases verificáveis + +1. **Fila modelada e testada** — implementar a fila encadeada (hook co-locado ou inline) com: enqueue de `{ mutation, revertDelta }`, pump disparando um transport por vez, drain com aplicação do último `selectedIDs` confirmado, busy derivado da fila. Testes unit isolados com transporte simulado (array de resolvers manuais, como o padrão `pending` de `municipalityRelationEditor.unit.spec.tsx` L32-38): sucesso em ordem; mutação B enfileirada durante A pendente **não dispara** o transport até A resolver; falha de A reverte só o delta de A e B ainda dispara e persiste; estado persistido final = baseline + B; drain sem `selectedIDs` (dobradinha) não sobrescreve e confia no RSC; busy nunca preso em erro. +2. **Integração ao editor sem mudar contratos** — `toggle`/`create` passam a enfileirar; remover `requestSeqRef` e o acoplamento do `tempID`; `pendingCountRef` → fila; `latestConfirmedRef` preservado; reescrever o teste L31-79 **no mesmo commit** para pinar o novo comportamento (B só dispara após A; falha de A não reverte B); manter verdes os testes de dedupe de create adotado por RSC (L81-127), known-entries vs opções (L129-160) e overlay. +3. **Validação dos 3 fluxos + gates** — suíte de wrappers intacta: assessores/lideranças (`campaignMunicipalityLeadershipsCreate.unit.spec.tsx` com `vi.mock('@/lib/campaignJsonRequest')`) e dobradinha (`municipalityStateDeputyRelationCell.unit.spec.tsx` com `commitAction` mockado e assert de FormData L62-67) verdes sem diff; cobertura indireta (`campaignCellEditOverlay.unit.spec.ts` L47-89, `campaignComponents.unit.spec.ts` L977-1010) verde; paralelismo entre instâncias (duas células em dois editors: transports disparam concorrentes) pinado em teste; `pnpm gate:fast` (lint + typecheck + test:unit) verde e `pnpm test:int` verde (int chamam `*Record` direto, sem transporte — a fila não as toca). + +## Rabbit holes / Não escopo (engenharia) + +- Não criar fila global nem por página; a chave de serialização é a instância do editor (célula/linha). +- Não adicionar retry, idempotency key ou versionamento otimista no servidor — a intenção exclui e o lock advisory por município já serializa no servidor. +- Não mudar `MunicipalityRelationTriggerProps`/`MunicipalityRelationMutationResult`, props de `MunicipalityList`/`MunicipalityMobileCard` nem os call sites. +- Não refatorar as rotas JSON (`/campanha/municipios/advisors`, `/leaderships`) nem as server actions de dobradinha; não unificar transportes. +- Não tocar `useCampaignCellAutosave` — abort-superseded é a política correta para célula de valor único; fila é para a lista de relações. Guardar como precedente, não como base. +- Não migrar `RelationChipCell` nem outros call sites de células de relação fora desta lista. +- Não debounce/coalescing de mutações: duas mutações rápidas são duas transações na ordem, não uma. +- Não mudar o comportamento de close/unmount: requests em voo continuam (close já é o commit) e falha pós-close vai a toast pelo canal existente (`useCampaignCellFailureChannel`). + +## Adiado com gatilho (triage do /simplify) + +- **Teste dedicado do create resolvendo `success` sem `createdEntry`** (branch defensiva herdada de B159 que reverte + reporta `createErrorMessage`; comportamento verificado no código, sem teste próprio). Gatilho: próxima mexida no `MunicipalityRelationEditor` OU evidência de drift de contrato (um wrapper devolvendo sucesso sem `createdEntry`). O teste de create falho em fila (`error`) já cobre o revert; o caso `success`-sem-entry é ~10 linhas quando o editor for tocado. + +## Riscos e mitigação + +- **Reconcile RSC durante fila ativa:** props externas chegando com a fila não vazia são adotadas (efeito L119-124) e o drain pode sobrescrever com o último `selectedIDs` confirmado. Mitigar: preservar a semântica atual (reset de `latestConfirmedRef` na adoção externa quando a fila está vazia; com fila ativa as respostas em ordem são consistentes com o RSC, pois o servidor serializa) e pinar em teste (rerender com `currentIDs` no meio da fila). +- **Editor preso em loading:** qualquer caminho de erro que esqueça o decremento deixa busy verdadeiro. Mitigar: busy derivado exclusivamente do tamanho da fila, decrementado em `finally` de cada elo; teste pinando erro seguido de sucesso na mesma fila. +- **Reversão errada de delta:** com a fila encadeada, cada falha deve reverter só o delta da própria mutação. Mitigar: o elo carrega seu `revertDelta` capturado no enqueue (closure), como hoje (L195-202/L235-242); teste pinando falha de A com B pendente. +- **Regressão nos wrappers:** a fila pode vazar exigência de resposta. Mitigar: contrato intacto; wrappers verdes sem diff; caso dobradinha sem `selectedIDs` coberto. +- **Teste L31-79 muda de premissa:** se reescrito depois do commit da fila, o CI quebra. Mitigar: reescrever no mesmo commit da mudança. +- **Unmount com fila não vazia:** elos em voo completam no servidor e os `setState` seguintes são no-ops (mesmo comportamento de hoje). Mitigar: nenhuma mudança de comportamento; documentar no teste de close que não há aborto. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: 2+ mutações rápidas da mesma célula enviadas na ordem da interação; falha intermediária não apaga alteração posterior válida nem deixa o editor preso em loading; células e linhas diferentes salvam em paralelo; testes de conclusão e falha fora de ordem no transporte simulado confirmam o estado persistido final +- [ ] Fila por instância do editor; nenhuma fila global +- [ ] `toggle`/`create` aplicam delta otimista imediato e enfileiram o transporte (UI nunca bloqueada) +- [ ] Falha reverte somente o delta da mutação que falhou e a fila continua; busy derivado da fila, nunca preso +- [ ] Contratos `MunicipalityRelationTriggerProps`/`MunicipalityRelationMutationResult` e props dos call sites intactos; wrappers sem diff +- [ ] Dobradinha sem `selectedIDs` continua confirmando via RSC +- [ ] Teste L31-79 atualizado para pinar a nova semântica (ordem + falha) no mesmo commit; novos testes de fila (B só após A; falha de A não reverte B) e de paralelismo entre instâncias +- [ ] `pnpm gate:fast` verde e `pnpm test:int` verde; sem migration, sem mudança de access/rotas/contratos públicos +- [ ] Invariantes AGENTS/engineering-standards preservadas (sem twin; dono existente editado; testes no padrão da casa) + +## Self-score decision-quality + +4/5 — por critério: + +- **Decisões caras registradas com opções/rejeitadas:** 5/5 — as cinco deliberações obrigatórias (local da fila, política de falha, interação pendente, dobradinha sem `selectedIDs`, destino da máquina de sequência) estão no formato Opções/Recomendação — porque/Alternativas rejeitadas. +- **Cabe no appetite com cortes explícitos:** 4/5 — escopo contido ao editor + testes; cortes explícitos de retry/servidor/global/coalescing; pequena incerteza residual na interação RSC-durante-fila, endereçada por teste em vez de prova formal. +- **Rabbit holes nomeados:** 5/5 — lista nomeada com gatilhos de corte (global, servidor, autosave, `RelationChipCell`, unificação de transportes). +- **Reutiliza donos existentes, não cria twin:** 5/5 — fila dentro do editor que já é dono da máquina otimista; nenhum caminho paralelo novo em `lib/`; wrappers e call sites intocados. +- **Outcome e lockdowns da intenção intactos:** 4/5 — sem mudança visual, de contratos ou de servidor; a mudança de premissa do teste L31-79 é consequência direta e necessária do outcome (ordem garantida), não escopo extra. diff --git a/src/components/campaign/shared/MunicipalityRelationEditor.tsx b/src/components/campaign/shared/MunicipalityRelationEditor.tsx index f229292c..78e39b14 100644 --- a/src/components/campaign/shared/MunicipalityRelationEditor.tsx +++ b/src/components/campaign/shared/MunicipalityRelationEditor.tsx @@ -29,6 +29,26 @@ export type MunicipalityRelationMutationResult = } | { status: 'error'; message: string } +/** + * B160 — one link of the per-instance mutation queue. The transport + * (`run`) is serialized per cell: the next mutation only fires when the + * previous one settles, so the server persists mutations in the order the + * person chose them. A failure undoes only this link's optimistic delta + * (`revert`) and the queue keeps going; `onSuccess` carries domain + * bookkeeping that must not run on failure (e.g. swapping a create's + * tempID for the persisted entry) and returns false when the success is + * not acceptable (a create without `createdEntry`), so the confirmed + * state stays at the last genuinely persisted mutation. + */ +type QueuedMutation = { + run: () => Promise + revert: () => void + errorMessage: string + onSuccess?: ( + result: Extract, + ) => boolean +} + /** * B193 — the closed-display override the dense mobile card passes through the * relation wrappers (Advisors/Lideranças/Dobradinhas) to this editor. @@ -71,6 +91,13 @@ type MunicipalityRelationEditorProps = MunicipalityRelationTriggerProps & { const isPendingCreateID = (id: number): boolean => id < 0 const EMPTY_ENTRIES: MunicipalityRelationEntry[] = [] +const applyToggleDelta = (ids: number[], id: number, assigned: boolean): number[] => { + const next = new Set(ids) + if (assigned) next.add(id) + else next.delete(id) + return [...next] +} + export const MunicipalityRelationEditor = ({ municipalityName, currentIDs, @@ -106,10 +133,11 @@ export const MunicipalityRelationEditor = ({ const [pendingCreates, setPendingCreates] = useState< ReadonlyMap >(new Map()) - const pendingCountRef = useRef(0) const lastPropsIDsRef = useRef(currentIDs) - const requestSeqRef = useRef(0) - const latestConfirmedRef = useRef<{ seq: number; ids: number[] } | null>(null) + const latestConfirmedRef = useRef(null) + const queueRef = useRef([]) + const drainingRef = useRef(false) + const nextCreateTempIDRef = useRef(-1) const handleOpenChange = (nextOpen: boolean) => { noteOpenChange(nextOpen) @@ -119,7 +147,7 @@ export const MunicipalityRelationEditor = ({ useEffect(() => { if (sameIdSet(currentIDs, lastPropsIDsRef.current)) return lastPropsIDsRef.current = currentIDs - if (pendingCountRef.current === 0) latestConfirmedRef.current = null + if (queueRef.current.length === 0 && !drainingRef.current) latestConfirmedRef.current = null setSelectedIDs(currentIDs) }, [currentIDs]) @@ -163,93 +191,92 @@ export const MunicipalityRelationEditor = ({ [selectableOptions, query], ) - const finishRequest = () => { - pendingCountRef.current = Math.max(0, pendingCountRef.current - 1) - if (pendingCountRef.current > 0) return - setIsPending(false) - if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.ids) - } - - const rememberConfirmation = (requestSeq: number, result: MunicipalityRelationMutationResult) => { - if (result.status !== 'success' || !result.selectedIDs) return - if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) { - latestConfirmedRef.current = { seq: requestSeq, ids: result.selectedIDs } + /** + * B160 — serialized pump for this cell's mutation queue. One transport at a + * time, in the order the person chose; the optimistic delta is already + * applied at enqueue time, so the UI never waits on the network. When the + * queue drains, the last confirmed server state replaces the local one — + * only if the final response carried `selectedIDs` (JSON relations); a + * response without it (dobradinhas) nulls the confirmation and keeps the + * optimistic state, letting the RSC reconcile. + */ + const pump = () => { + if (drainingRef.current) return + const next = queueRef.current.shift() + if (!next) { + setIsPending(false) + if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current) + return } + drainingRef.current = true + setIsPending(true) + void (async () => { + let result: MunicipalityRelationMutationResult | undefined + try { + result = await next.run() + } catch { + next.revert() + reportFailure(next.errorMessage) + } + if (result) { + if (result.status === 'error') { + next.revert() + reportFailure(result.message || next.errorMessage) + } else { + // Bookkeeping runs outside the try: a throw after the server + // persisted must never revert the optimistic delta. + const accepted = next.onSuccess?.(result) ?? true + if (accepted) latestConfirmedRef.current = result.selectedIDs ?? null + } + } + drainingRef.current = false + pump() + })() } const toggle = (id: number, assigned: boolean) => { if (isPendingCreateID(id)) return setErrorMessage(null) - setSelectedIDs((current) => { - const next = new Set(current) - if (assigned) next.add(id) - else next.delete(id) - return [...next] - }) + setSelectedIDs((current) => applyToggleDelta(current, id, assigned)) setQuery('') - pendingCountRef.current += 1 - setIsPending(true) - const requestSeq = (requestSeqRef.current += 1) - - void (async () => { - const revertDelta = () => { - setSelectedIDs((current) => { - const next = new Set(current) - if (assigned) next.delete(id) - else next.add(id) - return [...next] - }) - } - - try { - const result = await onToggle(id, assigned) - if (result.status === 'error') { - revertDelta() - reportFailure(result.message || saveErrorMessage) - finishRequest() - return - } - rememberConfirmation(requestSeq, result) - finishRequest() - } catch { - revertDelta() - reportFailure(saveErrorMessage) - finishRequest() - } - })() + queueRef.current.push({ + run: () => onToggle(id, assigned), + revert: () => { + setSelectedIDs((current) => applyToggleDelta(current, id, !assigned)) + }, + errorMessage: saveErrorMessage, + }) + pump() } const create = (name: string) => { if ([...pendingCreates.values()].some((entry) => entry.label === name)) return setErrorMessage(null) setQuery('') - pendingCountRef.current += 1 - setIsPending(true) - const requestSeq = (requestSeqRef.current += 1) - const tempID = -requestSeq + const tempID = nextCreateTempIDRef.current-- const pendingEntry = { id: tempID, label: name } setSelectedIDs((current) => [...current, tempID]) setPendingCreates((current) => new Map(current).set(tempID, pendingEntry)) - void (async () => { - const revertCreate = () => { - setSelectedIDs((current) => current.filter((id) => id !== tempID)) - setPendingCreates((current) => { - const next = new Map(current) - next.delete(tempID) - return next - }) - } + const revertCreate = () => { + setSelectedIDs((current) => current.filter((id) => id !== tempID)) + setPendingCreates((current) => { + const next = new Map(current) + next.delete(tempID) + return next + }) + } - try { - const result = await onCreate(name) - if (result.status === 'error' || !result.createdEntry) { + queueRef.current.push({ + run: () => onCreate(name), + revert: revertCreate, + errorMessage: createErrorMessage, + onSuccess: (result) => { + if (!result.createdEntry) { revertCreate() - reportFailure(result.status === 'error' ? result.message : createErrorMessage) - finishRequest() - return + reportFailure(createErrorMessage) + return false } - rememberConfirmation(requestSeq, result) const createdEntry = result.createdEntry setSelectedIDs((current) => [ ...new Set([...current.filter((id) => id !== tempID), createdEntry.id]), @@ -265,13 +292,10 @@ export const MunicipalityRelationEditor = ({ : [...current, createdEntry], ) onCreated?.(createdEntry) - finishRequest() - } catch { - revertCreate() - reportFailure(createErrorMessage) - finishRequest() - } - })() + return true + }, + }) + pump() } const statusMessage = errorMessage ? errorMessage : isPending ? savingMessage : '' diff --git a/tests/unit/municipalityRelationEditor.unit.spec.tsx b/tests/unit/municipalityRelationEditor.unit.spec.tsx index 3ef299aa..d5e113bd 100644 --- a/tests/unit/municipalityRelationEditor.unit.spec.tsx +++ b/tests/unit/municipalityRelationEditor.unit.spec.tsx @@ -27,8 +27,8 @@ afterAll(() => { Reflect.deleteProperty(Element.prototype, 'scrollIntoView') }) -describe('MunicipalityRelationEditor optimistic reconcile', () => { - it('adopts the newest sent response when concurrent toggles settle out of order', async () => { +describe('MunicipalityRelationEditor mutation queue (B160)', () => { + it('serializes same-cell mutations: the second transport only fires after the first settles', async () => { const pending: Array<(result: MunicipalityRelationMutationResult) => void> = [] const onToggle = vi.fn( () => @@ -66,16 +66,349 @@ describe('MunicipalityRelationEditor optimistic reconcile', () => { fireEvent.click(screen.getByRole('button', { name: /Editar relações/ })) fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } }) fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'bea' } }) fireEvent.click(await screen.findByRole('option', { name: 'Beatriz Souza' })) - expect(onToggle).toHaveBeenNthCalledWith(1, 1, true) - expect(onToggle).toHaveBeenNthCalledWith(2, 2, true) - pending[1]?.({ status: 'success', selectedIDs: [1, 2] }) + expect(onToggle).toHaveBeenCalledTimes(1) + expect(onToggle).toHaveBeenCalledWith(1, true) + expect(screen.getByLabelText('Salvando relações.')).toBeTruthy() + pending[0]?.({ status: 'success', selectedIDs: [1] }) + await waitFor(() => expect(onToggle).toHaveBeenCalledTimes(2)) + expect(onToggle).toHaveBeenCalledWith(2, true) + + pending[1]?.({ status: 'success', selectedIDs: [1, 2] }) + await waitFor(() => expect(screen.getByRole('button', { name: /Álvaro Lima,Beatriz Souza/ })).toBeTruthy(), ) + expect(screen.queryByLabelText('Salvando relações.')).toBeNull() + }) + + it('reverts only the failed mutation delta; later mutations still fire and persist', async () => { + const pending: Array<(result: MunicipalityRelationMutationResult) => void> = [] + const onToggle = vi.fn( + () => + new Promise((resolve) => { + pending.push(resolve) + }), + ) + render( + createElement( + TooltipProvider, + null, + createElement(MunicipalityRelationEditor, { + municipalityName: 'Seabra', + currentIDs: [], + options: [ + { id: 1, label: 'Álvaro Lima' }, + { id: 2, label: 'Beatriz Souza' }, + ], + variant: 'popover', + title: 'Gerenciar relações', + description: 'Descrição.', + searchPlaceholder: 'Buscar…', + searchLabel: 'Buscar relação', + savingMessage: 'Salvando relações.', + saveErrorMessage: 'Falha ao salvar.', + triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`, + emptyState: createElement('span', null, 'Nenhuma'), + createLabel: (name) => `Criar “${name}”`, + onToggle, + onCreate: vi.fn(), + }), + ), + ) + + fireEvent.click(screen.getByRole('button', { name: /Editar relações/ })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'bea' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Beatriz Souza' })) + + pending[0]?.({ status: 'error', message: 'Falha ao salvar.' }) + + await waitFor(() => expect(onToggle).toHaveBeenCalledTimes(2)) + expect(onToggle).toHaveBeenCalledWith(2, true) + await waitFor(() => expect(screen.queryByRole('button', { name: /Álvaro Lima/ })).toBeNull()) + + pending[1]?.({ status: 'success', selectedIDs: [2] }) + + await waitFor(() => + expect(screen.getByRole('button', { name: /Editar relações — Beatriz Souza/ })).toBeTruthy(), + ) + expect(screen.getAllByText('Falha ao salvar.').length).toBeGreaterThan(0) + expect(screen.queryByLabelText('Salvando relações.')).toBeNull() + }) + + it('applies the last confirmed state when the FINAL link fails (drain over the revert)', async () => { + const pending: Array<(result: MunicipalityRelationMutationResult) => void> = [] + const onToggle = vi.fn( + () => + new Promise((resolve) => { + pending.push(resolve) + }), + ) + render( + createElement( + TooltipProvider, + null, + createElement(MunicipalityRelationEditor, { + municipalityName: 'Seabra', + currentIDs: [], + options: [ + { id: 1, label: 'Álvaro Lima' }, + { id: 2, label: 'Beatriz Souza' }, + ], + variant: 'popover', + title: 'Gerenciar relações', + description: 'Descrição.', + searchPlaceholder: 'Buscar…', + searchLabel: 'Buscar relação', + savingMessage: 'Salvando relações.', + saveErrorMessage: 'Falha ao salvar.', + triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`, + emptyState: createElement('span', null, 'Nenhuma'), + createLabel: (name) => `Criar “${name}”`, + onToggle, + onCreate: vi.fn(), + }), + ), + ) + + fireEvent.click(screen.getByRole('button', { name: /Editar relações/ })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'bea' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Beatriz Souza' })) + + pending[0]?.({ status: 'success', selectedIDs: [1] }) + await waitFor(() => expect(onToggle).toHaveBeenCalledTimes(2)) + pending[1]?.({ status: 'error', message: 'Falha ao salvar.' }) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Editar relações — Álvaro Lima' })).toBeTruthy() + expect(screen.queryByRole('button', { name: /Álvaro Lima,Beatriz/ })).toBeNull() + }) + expect(screen.getAllByText('Falha ao salvar.').length).toBeGreaterThan(0) + expect(screen.queryByLabelText('Salvando relações.')).toBeNull() + }) + + it('reverts a failed create mid-queue and still fires the later link in order', async () => { + const pending: Array<(result: MunicipalityRelationMutationResult) => void> = [] + const onToggle = vi.fn( + () => + new Promise((resolve) => { + pending.push(resolve) + }), + ) + const onCreate = vi.fn( + () => + new Promise((resolve) => { + pending.push(resolve) + }), + ) + render( + createElement( + TooltipProvider, + null, + createElement(MunicipalityRelationEditor, { + municipalityName: 'Seabra', + currentIDs: [], + options: [{ id: 1, label: 'Álvaro Lima' }], + variant: 'popover', + title: 'Gerenciar relações', + description: 'Descrição.', + searchPlaceholder: 'Buscar…', + searchLabel: 'Buscar relação', + savingMessage: 'Salvando relações.', + saveErrorMessage: 'Falha ao salvar.', + createErrorMessage: 'Falha ao criar.', + triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`, + emptyState: createElement('span', null, 'Nenhuma'), + createLabel: (name) => `Criar “${name}”`, + onToggle, + onCreate, + }), + ), + ) + + fireEvent.click(screen.getByRole('button', { name: /Editar relações/ })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { + target: { value: 'Nova Pessoa' }, + }) + fireEvent.click(await screen.findByRole('option', { name: /Criar “Nova Pessoa”/ })) + + expect(onToggle).toHaveBeenCalledTimes(1) + expect(onCreate).not.toHaveBeenCalled() + + pending[0]?.({ status: 'success', selectedIDs: [1] }) + + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)) + expect(onCreate).toHaveBeenCalledWith('Nova Pessoa') + + pending[1]?.({ status: 'error', message: 'Falha ao criar.' }) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Editar relações — Álvaro Lima' })).toBeTruthy() + expect(screen.queryByRole('button', { name: /Nova Pessoa/ })).toBeNull() + }) + expect(screen.getAllByText('Falha ao criar.').length).toBeGreaterThan(0) + expect(screen.queryByLabelText('Salvando relações.')).toBeNull() + }) + + it('keeps optimistic state when the final response carries no selectedIDs (dobradinhas rely on RSC)', async () => { + const pending: Array<(result: MunicipalityRelationMutationResult) => void> = [] + const onToggle = vi.fn( + () => + new Promise((resolve) => { + pending.push(resolve) + }), + ) + const element = (currentIDs: number[]) => + createElement( + TooltipProvider, + null, + createElement(MunicipalityRelationEditor, { + municipalityName: 'Seabra', + currentIDs, + options: [{ id: 1, label: 'Álvaro Lima' }], + variant: 'popover', + title: 'Gerenciar relações', + description: 'Descrição.', + searchPlaceholder: 'Buscar…', + searchLabel: 'Buscar relação', + savingMessage: 'Salvando relações.', + saveErrorMessage: 'Falha ao salvar.', + triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`, + emptyState: createElement('span', null, 'Nenhuma'), + createLabel: (name) => `Criar “${name}”`, + onToggle, + onCreate: vi.fn(), + }), + ) + const view = render(element([])) + + fireEvent.click(screen.getByRole('button', { name: /Editar relações/ })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' })) + + pending[0]?.({ status: 'success' }) + + await waitFor(() => + expect(screen.getByRole('button', { name: /Editar relações — Álvaro Lima/ })).toBeTruthy(), + ) + + view.rerender(element([1])) + + expect(screen.getByRole('button', { name: /Editar relações — Álvaro Lima/ })).toBeTruthy() + expect(screen.queryByLabelText('Salvando relações.')).toBeNull() + }) + + it('keeps independent cells in parallel: two editors fire transports concurrently', async () => { + const pending: Array void>> = [[], []] + const onToggleA = vi.fn( + () => + new Promise((resolve) => { + pending[0].push(resolve) + }), + ) + const onToggleB = vi.fn( + () => + new Promise((resolve) => { + pending[1].push(resolve) + }), + ) + const editor = (onToggle: typeof onToggleA) => + createElement( + TooltipProvider, + null, + createElement(MunicipalityRelationEditor, { + municipalityName: 'Seabra', + currentIDs: [], + options: [{ id: 1, label: 'Álvaro Lima' }], + variant: 'popover', + title: 'Gerenciar relações', + description: 'Descrição.', + searchPlaceholder: 'Buscar…', + searchLabel: 'Buscar relação', + savingMessage: 'Salvando relações.', + saveErrorMessage: 'Falha ao salvar.', + triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`, + emptyState: createElement('span', null, 'Nenhuma'), + createLabel: (name) => `Criar “${name}”`, + onToggle, + onCreate: vi.fn(), + }), + ) + render(createElement('div', null, editor(onToggleA), editor(onToggleB))) + + const triggers = screen.getAllByRole('button', { name: /Editar relações/ }) + fireEvent.click(triggers[0]) + fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' })) + fireEvent.click(triggers[0]) + fireEvent.click(triggers[1]) + fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' })) + + expect(onToggleA).toHaveBeenCalledTimes(1) + expect(onToggleB).toHaveBeenCalledTimes(1) + + pending[0][0]?.({ status: 'success', selectedIDs: [1] }) + pending[1][0]?.({ status: 'success', selectedIDs: [1] }) + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /Editar relações — Álvaro Lima/ })).toHaveLength( + 2, + ) + }) + }) + + it('keeps the pending confirmation when server props are adopted mid-flight (guard !drainingRef)', async () => { + const pending: Array<(result: MunicipalityRelationMutationResult) => void> = [] + const onToggle = vi.fn( + () => + new Promise((resolve) => { + pending.push(resolve) + }), + ) + const element = (currentIDs: number[]) => + createElement( + TooltipProvider, + null, + createElement(MunicipalityRelationEditor, { + municipalityName: 'Seabra', + currentIDs, + options: [{ id: 1, label: 'Álvaro Lima' }], + variant: 'popover', + title: 'Gerenciar relações', + description: 'Descrição.', + searchPlaceholder: 'Buscar…', + searchLabel: 'Buscar relação', + savingMessage: 'Salvando relações.', + saveErrorMessage: 'Falha ao salvar.', + triggerLabel: (entries) => `Editar relações — ${entries.map((entry) => entry.label)}`, + emptyState: createElement('span', null, 'Nenhuma'), + createLabel: (name) => `Criar “${name}”`, + onToggle, + onCreate: vi.fn(), + }), + ) + const view = render(element([])) + + fireEvent.click(screen.getByRole('button', { name: /Editar relações/ })) + fireEvent.change(screen.getByLabelText('Buscar relação'), { target: { value: 'alv' } }) + fireEvent.click(await screen.findByRole('option', { name: 'Álvaro Lima' })) + + view.rerender(element([9])) + + pending[0]?.({ status: 'success', selectedIDs: [1] }) + + await waitFor(() => + expect(screen.getByRole('button', { name: /Editar relações — Álvaro Lima/ })).toBeTruthy(), + ) }) it('does not duplicate a create adopted by server props before the request resolves', async () => {