diff --git a/docs/plans/quick-edit-sheet-mobile-quebrado-impl.md b/docs/plans/quick-edit-sheet-mobile-quebrado-impl.md
new file mode 100644
index 00000000..95fd6351
--- /dev/null
+++ b/docs/plans/quick-edit-sheet-mobile-quebrado-impl.md
@@ -0,0 +1,62 @@
+# 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
diff --git a/src/components/campaign/shared/CampaignCellEditOverlay.tsx b/src/components/campaign/shared/CampaignCellEditOverlay.tsx
index af41734c..a0f6cf68 100644
--- a/src/components/campaign/shared/CampaignCellEditOverlay.tsx
+++ b/src/components/campaign/shared/CampaignCellEditOverlay.tsx
@@ -186,7 +186,7 @@ export const CampaignCellEditOverlay = ({
sharedSheet.openSheet({
title,
description,
- hasCustomFooter: Boolean(footer),
+ footer,
sheetBodyClassName,
onOpenChange,
})
@@ -195,8 +195,7 @@ export const CampaignCellEditOverlay = ({
if (variant === 'sheet' && sharedSheet) {
const showPortals = open && sharedSheet.isActiveSheet(onOpenChange)
const bodyTarget = showPortals ? sharedSheet.bodyPortalRef.current : null
- const footerTarget = showPortals ? sharedSheet.footerPortalRef.current : null
- // portalRevision keeps this render in sync after the drawer mounts its targets.
+ // portalRevision keeps this render in sync after the drawer mounts its target.
void sharedSheet.portalRevision
return (
@@ -204,7 +203,6 @@ export const CampaignCellEditOverlay = ({
{triggerButton}
{liveRegion}
{showPortals && bodyTarget ? createPortal(children, bodyTarget) : null}
- {showPortals && footerTarget && footer ? createPortal(footer, footerTarget) : null}
>
)
}
diff --git a/src/components/campaign/shared/CampaignListSheetHost.tsx b/src/components/campaign/shared/CampaignListSheetHost.tsx
index 2d882e7a..92c2b0ae 100644
--- a/src/components/campaign/shared/CampaignListSheetHost.tsx
+++ b/src/components/campaign/shared/CampaignListSheetHost.tsx
@@ -25,15 +25,25 @@ import { cn } from '@/lib/utils'
export type CampaignListSheetChrome = {
title: string
description?: string
- hasCustomFooter: boolean
+ /**
+ * Custom footer for a sheet whose commit is an explicit submit (E14's
+ * "Registrar movimento", B42's "Registrar atualização"). Rendered INLINE in
+ * the host's DrawerFooter so its controls resolve the Drawer's context —
+ * the cell's own tree has no `Drawer.Root`, and a portaled footer keeps the
+ * source context and crashes base-ui's `DialogClose` (C109). The element is
+ * still CREATED by the cell (form state, `form` attribute association live
+ * there); only its render location moves — so it can rely on props/state
+ * captured by the cell and on providers ABOVE the sheet provider, never on
+ * providers local to the cell.
+ */
+ footer?: ReactNode
sheetBodyClassName?: string
onOpenChange: (open: boolean) => void
}
type CampaignListSheetContextValue = {
bodyPortalRef: RefObject
- footerPortalRef: RefObject
- /** Bumps when the drawer body/footer mount targets attach — portals read this. */
+ /** Bumps when the drawer body mount target attaches — portals read this. */
portalRevision: number
openSheet: (chrome: CampaignListSheetChrome) => void
dismissSheet: (onOpenChange: (open: boolean) => void) => void
@@ -47,12 +57,13 @@ export const useCampaignListSheet = () => useContext(CampaignListSheetContext)
/**
* One bottom Drawer for an entire list surface (B42 mobile cards). Cell bodies
* portal into it so we never mount hundreds of Drawer roots and never sync form
- * trees through provider state (which tripped React #185 in prod).
+ * trees through provider state (which tripped React #185 in prod). Custom
+ * footers travel in the chrome instead of portaling: a portaled footer keeps
+ * the cell's context, which has no `Drawer.Root`, and crashes (C109).
*/
export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) => {
const titleRef = useRef(null)
const bodyPortalRef = useRef(null)
- const footerPortalRef = useRef(null)
const [open, setOpen] = useState(false)
const [chrome, setChrome] = useState(null)
const [portalRevision, setPortalRevision] = useState(0)
@@ -63,11 +74,6 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
if (node) setPortalRevision((value) => value + 1)
}, [])
- const attachFooterPortal = useCallback((node: HTMLDivElement | null) => {
- footerPortalRef.current = node
- if (node) setPortalRevision((value) => value + 1)
- }, [])
-
const dismissSheet = useCallback((onOpenChange: (open: boolean) => void) => {
if (activeOnOpenChangeRef.current !== onOpenChange) return
setOpen(false)
@@ -89,7 +95,11 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
current.title === next.title &&
current.description === next.description &&
current.sheetBodyClassName === next.sheetBodyClassName &&
- current.hasCustomFooter === next.hasCustomFooter
+ // Deliberately NOT `hasCustomFooter`: a custom footer is a fresh JSX
+ // element on every cell render, and this comparison is what forces the
+ // host to adopt it — `isPending`/spinner freshness depends on the
+ // footer being re-rendered here (the cell no longer renders it itself).
+ current.footer === next.footer
) {
return current
}
@@ -121,7 +131,6 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
const contextValue = useMemo(
() => ({
bodyPortalRef,
- footerPortalRef,
portalRevision,
openSheet,
dismissSheet,
@@ -152,8 +161,7 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
)}
/>
-
- {!chrome.hasCustomFooter ? Fechar : null}
+ {chrome.footer ?? Fechar}
) : null}
diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts
index 547eb96c..c543d99b 100644
--- a/tests/e2e/campaignMunicipalities.e2e.spec.ts
+++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts
@@ -798,6 +798,74 @@ test.describe('Municípios — cards no celular (B42)', () => {
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
await expect(campaignPageChrome(page, municipality.name)).toBeVisible()
})
+
+ /**
+ * C109: the custom-footer sheets ("Registrar atualização" here, the same
+ * mechanism as E14's "Registrar movimento") used to portal their footer from
+ * the cell into the shared Drawer. `createPortal` keeps the source context,
+ * which has no `Drawer.Root`, so base-ui's `DialogClose` threw and the sheet
+ * never rendered. The footer must open, submit, and close inside the shared
+ * Drawer.
+ */
+ test('opens the update quick-edit sheet, registers, and closes via Cancelar (C109)', async ({
+ campaign,
+ page,
+ }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator', {
+ name: fixtures.value('Coordenadora C109'),
+ })
+ const password = coordinator.password
+ const municipality = await fixtures.claimMunicipality()
+
+ await campaign.login(page, coordinator.email!, password)
+ const listURL = `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`
+ await page.goto(listURL)
+
+ const card = page.locator('[data-view="mobile-cards"] article').first()
+ await expect(card).toBeVisible()
+ await card.getByRole('button', { name: /^Registrar atualização em/ }).click()
+
+ // The sheet's primary action lives in the custom footer — the two buttons
+ // the phone thumb expects, rendered inside the shared Drawer.
+ const sheet = page.getByRole('dialog', { name: 'Registrar atualização' })
+ await expect(sheet).toBeVisible()
+ await expect(
+ sheet.getByRole('button', { name: 'Registrar atualização', exact: true }),
+ ).toBeVisible()
+ await expect(sheet.getByRole('button', { name: 'Cancelar' })).toBeVisible()
+ await expect(page).toHaveURL(listURL)
+
+ await sheet
+ .getByRole('textbox', { name: 'Texto' })
+ .fill('Sinal registrado pelo sheet mobile do card.')
+ await sheet.getByLabel('Polaridade').getByRole('radio', { name: 'Boa' }).click()
+ await Promise.all([
+ expectPostResponse(page, '/campanha/municipios'),
+ sheet.getByRole('button', { name: 'Registrar atualização', exact: true }).click(),
+ ])
+
+ // The toast is intentionally transient; the persisted freshness label is
+ // the stable success contract and must arrive after the server response.
+ await expect(
+ card.getByRole('button', { name: `Registrar atualização em ${municipality.name} — hoje` }),
+ ).toBeVisible()
+
+ // Reopen: the chrome is rebuilt fresh, and the custom footer comes back.
+ await card.getByRole('button', { name: /^Registrar atualização em/ }).click()
+ await expect(sheet).toBeVisible()
+ await sheet.getByRole('button', { name: 'Cancelar' }).click()
+ await expect(sheet).toBeHidden()
+
+ // E14's level sheet is the same custom-footer mechanism — the other caller
+ // that shares the crash. It must open and close without one too.
+ await card.getByRole('button', { name: /^Nível de envolvimento de/ }).click()
+ const levelSheet = page.getByRole('dialog', { name: 'Registrar nível de envolvimento' })
+ await expect(levelSheet).toBeVisible()
+ await expect(levelSheet.getByRole('button', { name: 'Registrar movimento' })).toBeVisible()
+ await levelSheet.getByRole('button', { name: 'Cancelar' }).click()
+ await expect(levelSheet).toBeHidden()
+ })
})
/**
diff --git a/tests/unit/campaignCellEditOverlay.unit.spec.ts b/tests/unit/campaignCellEditOverlay.unit.spec.ts
index 631feaf6..243a881d 100644
--- a/tests/unit/campaignCellEditOverlay.unit.spec.ts
+++ b/tests/unit/campaignCellEditOverlay.unit.spec.ts
@@ -9,6 +9,7 @@ import { MunicipalityListLevelControl } from '@/components/campaign/municipality
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityListUpdateControl } from '@/components/campaign/municipality/MunicipalityListUpdateControl'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
+import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { TooltipProvider } from '@/components/ui/tooltip'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
@@ -338,6 +339,148 @@ describe('campaign cell edit overlay', () => {
await waitFor(() => expect(formAction).toHaveBeenCalledTimes(1))
})
+ /**
+ * C109. The cases above render each cell WITHOUT the provider — they exercise
+ * the per-cell Drawer fallback, which never hit the crash. On the real mobile
+ * list a `CampaignListSheetProvider` wraps the cards, the overlay publishes
+ * into its single Drawer, and the custom footer used to be portaled from the
+ * cell: `createPortal` keeps the SOURCE tree's React context, so the
+ * `DrawerCloseButton` inside the footer resolved `useDialogRootContext()`
+ * as undefined and the sheet never rendered. These cases pin the shared path:
+ * the footer must render INSIDE the host's Drawer (context present), close
+ * via "Cancelar", and come back fresh on a reopen.
+ */
+ describe('shared sheet host path (B42 provider)', () => {
+ it('opens the signal sheet with its custom footer inside the shared Drawer, and Cancelar closes it', async () => {
+ render(
+ createElement(
+ CampaignListSheetProvider,
+ null,
+ // `createElement`'s typing puts a required `children` in props, so
+ // the rest-argument form this rule prefers does not type-check here.
+ // eslint-disable-next-line react/no-children-prop
+ createElement(MunicipalityListUpdateControl, {
+ municipalityID: 1,
+ municipalitySlug: 'feira-de-santana',
+ municipalityName: MUNICIPALITY_NAME,
+ lastSignalAt: null,
+ variant: 'sheet',
+ formAction: async () => ({}),
+ isStaff: true,
+ children: createElement('span', null, 'Sem sinal'),
+ }),
+ ),
+ )
+
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
+ }),
+ )
+
+ const dialog = await screen.findByRole('dialog')
+ const footer = dialog.querySelector('[data-slot="drawer-footer"]')
+ // The custom footer replaces the default "Fechar" — the two buttons the
+ // phone thumb expects, rendered in the host's Drawer context.
+ expect(footer?.textContent).toContain('Registrar atualização')
+ expect(footer?.textContent).toContain('Cancelar')
+ expect(footer?.textContent).not.toContain('Fechar')
+
+ fireEvent.click(within(footer as HTMLElement).getByRole('button', { name: 'Cancelar' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
+ })
+
+ /**
+ * F5 across the provider path: the form body is portaled into the shared
+ * Drawer and the submit button lives in the host-rendered footer — the
+ * standard `form` attribute has to reach across that boundary for the
+ * association to survive. The fallback-path twin above pins the same
+ * contract in the per-cell Drawer.
+ */
+ it('submits the signal form from the custom footer across the shared Drawer', async () => {
+ const formAction = vi.fn(async () => ({}))
+
+ render(
+ createElement(
+ CampaignListSheetProvider,
+ null,
+ // eslint-disable-next-line react/no-children-prop
+ createElement(MunicipalityListUpdateControl, {
+ municipalityID: 1,
+ municipalitySlug: 'feira-de-santana',
+ municipalityName: MUNICIPALITY_NAME,
+ lastSignalAt: null,
+ variant: 'sheet',
+ formAction,
+ isStaff: true,
+ children: createElement('span', null, 'Sem sinal'),
+ }),
+ ),
+ )
+
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
+ }),
+ )
+
+ const dialog = await screen.findByRole('dialog')
+ fireEvent.click(
+ within(screen.getByLabelText('Polaridade')).getByRole('radio', { name: 'Boa' }),
+ )
+ fireEvent.change(screen.getByLabelText('Texto'), {
+ target: { value: 'Atualização registrada no campo.' },
+ })
+
+ const submit = dialog.querySelector('[data-slot="drawer-footer"] button')
+ expect(submit?.textContent).toContain('Registrar atualização')
+ expect(submit?.getAttribute('form')).toBe(
+ dialog.querySelector('form')?.getAttribute('id') ?? null,
+ )
+
+ fireEvent.click(submit as HTMLButtonElement)
+
+ await waitFor(() => expect(formAction).toHaveBeenCalledTimes(1))
+ })
+
+ it('opens the engagement-level sheet, closes, and reopens with a fresh custom footer', async () => {
+ render(
+ createElement(
+ CampaignListSheetProvider,
+ null,
+ createElement(MunicipalityListLevelControl, {
+ municipalityID: 1,
+ municipalityName: MUNICIPALITY_NAME,
+ level: 'n3',
+ levelNote: 'Rede montada e giro agendado',
+ levelChangedAt: '2026-07-01T12:00:00.000Z',
+ variant: 'sheet',
+ }),
+ ),
+ )
+
+ const trigger = screen.getByRole('button', {
+ name: `Nível de envolvimento de ${MUNICIPALITY_NAME}: N3 · Rede + agenda`,
+ })
+
+ fireEvent.click(trigger)
+ let dialog = await screen.findByRole('dialog')
+ let footer = dialog.querySelector('[data-slot="drawer-footer"]')
+ expect(footer?.textContent).toContain('Registrar movimento')
+ expect(footer?.textContent).toContain('Cancelar')
+ expect(footer?.textContent).not.toContain('Fechar')
+
+ fireEvent.click(within(footer as HTMLElement).getByRole('button', { name: 'Cancelar' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
+
+ // Reopening mounts a fresh chrome — the custom footer must come back.
+ fireEvent.click(trigger)
+ dialog = await screen.findByRole('dialog')
+ footer = dialog.querySelector('[data-slot="drawer-footer"]')
+ expect(footer?.textContent).toContain('Registrar movimento')
+ })
+ })
+
/**
* B34's cell is the one caller that is a sheet at EVERY viewport: it picks its
* surface by `pointer:`, not by width, so it has no `variant` to sweep and