# Impl: Quick-edit sheet mobile da lista de municípios quebra ao abrir (DialogClose fora do contexto Drawer) Status: aprovado Atualizado em: 2026-08-10 Issue: #540 (C109) Intenção: docs/plans/quick-edit-sheet-mobile-quebrado.md Appetite restante: herdado (~2–4 h eng) ## Leitura da intenção - **Outcome:** no celular, tocar em "Registrar atualização" (ou qualquer quick-edit sheet com footer custom) no card de município abre o sheet e registra, sem console error. - **O que NÃO negociar:** popover desktop e fallback sem provider intactos; sem mudança de access/Consent/URL; affordance "Cancelar" no rodapé preservado (decisão B32). - **O que reavaliar:** a hipótese da intenção (portal preserva contexto da origem). **Confirmada no código**: `CampaignCellEditOverlay` portaliza `footer` (`createPortal(footer, footerTarget)` em `CampaignCellEditOverlay.tsx:207`) para o `DrawerFooter` do host (`CampaignListSheetHost.tsx:155`); `createPortal` resolve contexto React da árvore de ORIGEM (a célula, sem `Drawer.Root`), então `DrawerCloseButton` (base-ui `DialogClose`) explode em `useDialogRootContext()`. O fallback sem provider renderiza o footer inline (contexto OK) — por isso o unit test atual passa. ## Abordagem recomendada ```mermaid flowchart LR Cell["Célula (MunicipalityListUpdateControl / LevelControl)"] -->|openSheet chrome {title, description, footer}| Host["CampaignListSheetHost (Drawer.Root)"] Cell -->|footer JSX criado aqui, estado do form vive aqui| Host Host -->|renderiza chrome.footer inline no DrawerFooter| Footer["submit + Cancelar
(DrawerClose resolve contexto do Drawer.Root)"] Cell -->|body portalado (mantido)| Body["body no DrawerContent"] ``` **Opções consideradas:** A) footer vira parte do `chrome` do shared-sheet (mesmo mecanismo de title/description); a célula passa o footer no `openSheet`, o host renderiza dentro do `DrawerFooter` — portal do footer removido | B) envolver o footer portalado num wrapper de provider do Drawer (hack) | C) mover `DrawerCloseButton` para fora do footer custom (perder "Cancelar"). **Recomendação:** A — o ReactNode do footer é um elemento criado na célula (estado do form, `useActionState`, `isPending` continuam vivendo lá; a associação `form` attribute é DOM-level e não precisa de contexto), mas é **renderizado** na árvore do host, onde o `Drawer.Root` existe — contexto correto por construção, alinhado ao design existente do chrome, e remove o portal do footer por completo (sem timing de `portalRevision` para o footer). **Rejeitadas:** B porque mascara o defeito com contexto duplicado e o primeiro crash só muda de endereço; C porque regride o affordance decidido em B32 (submit + Cancelar no rodapé de sheet com body rolável). ### Componentes / mudanças - **`CampaignListSheetChrome`** (`src/components/campaign/shared/CampaignListSheetHost.tsx`): troca `hasCustomFooter: boolean` por `footer?: ReactNode` (derivado: `Boolean(chrome.footer)`). `DrawerFooter` passa a renderizar `{chrome.footer ?? Fechar}` — o div de portal (`ref={attachFooterPortal}`) sai; `footerPortalRef` e `attachFooterPortal` saem do contexto e do provider (`portalRevision` permanece, servindo o body). `openSheet` inclui `current.footer === next.footer` na comparação de memo (na prática a célula sempre cria JSX fresco — comportamento equivalente ao de hoje, onde `handleOpenChange` da LevelControl já quebra o memo). - **`CampaignCellEditOverlay.tsx`** (path shared, `variant === 'sheet' && sharedSheet`): para de portalar o footer — `openSheet({ ..., footer, ... })`; render retorna só trigger + liveRegion + body portalado. Path fallback (sem provider) e popover intactos. - **Call sites** (`MunicipalityListUpdateControl.tsx:83`, `MunicipalityListLevelControl.tsx:231`): **zero mudança de props** — o footer continua sendo prop do overlay; os dois são os únicos call sites com footer custom no repo (verificado por grep), e os outros consumidores do provider (AdvisorsTable, dobradinhas, lideranças, trend/expected/leaderships/assessores) usam o `Fechar` default que o host já renderiza inline — não afetados. - **Migration:** sem migration (UI only). - **Access / Consent:** nenhum. - **UI:** Impeccable A — defect de render, restaura comportamento existente, sem redesenho. ## Fases verificáveis 1. **Root cause em teste** — unit test do shared path (provider + célula) que falha em `main` (crash do `DialogClose`) e passa com o fix: `tests/unit/campaignCellEditOverlay.unit.spec.ts` ganha describe do shared path cobrindo `MunicipalityListUpdateControl` e `MunicipalityListLevelControl` (abre → footer com submit + "Cancelar" → fecha via "Cancelar" sem crash). 2. **Fix no host/overlay** — chrome com footer; overlay sem portal; remoção de `footerPortalRef`/`hasCustomFooter`. 3. **Gates** — `pnpm gate:fast`; e2e mobile: novo teste no describe B42 (`tests/e2e/campaignMunicipalities.e2e.spec.ts`) abre o sheet "Registrar atualização" no card (dialog visível, footer com "Registrar atualização" + "Cancelar", fecha) — regressão que vermelha em main; e2e existente do popover intacto. 4. **Verificação manual no browser** — abrir/registrar/fechar os dois sheets custom no viewport mobile. ## Rabbit holes / Não escopo (engenharia) - Redesenhar sheet/footer dos quick-edits. - Outros drawers/shared-sheets (agenda, convites, notificações) — mecanismo próprio, fora. - Refatorar a comparação de memo do `openSheet` (comportamento já não-detectável hoje). ## Riscos e mitigação - **E14 e B42 compartilham o path** — o fix cobre ambos; unit + e2e cobrem os dois call sites. - **Reabertura do sheet** — `dismissSheet` zera o chrome e o próximo `openSheet` recria o footer fresco; unit test cobre abrir→fechar→reabrir. - **Footer stale no host** — o efeito do overlay re-envia o chrome a cada re-render da célula (dep `footer` já existe), então `isPending`/spinner chegam ao host; `useLayoutEffect` roda antes do paint. - **Contexto do form** — o submit do footer continua associado pelo `form` attribute (DOM), como hoje; unit F5 existente garante a associação no path fallback. ## Aceite de engenharia - [ ] Quick-edit sheet com footer custom abre no shared path sem console error, e "Cancelar" fecha (unit + e2e mobile novos) - [ ] Popover desktop e fallback sem provider intactos (unit existente + e2e existentes verdes) - [ ] Invariantes: sem migration, sem access/Consent, sem mudança de props nos call sites