refactor(campaign): E1 simplify — pin loading/retry/stale-quirk no spec e tipo local

Review estrutural + qualidade (sem P1): spec ganha 3 pins do contrato mais
sutil (loading em voo, retry pós-falha, quirk gate→not-ready com resposta
stale — comportamento herdado dos 3 originais, preservado); UseAsyncSearchOptionsResult
vira tipo local (export especulativo, knip types:error); docstring documenta
o quirk e o trim; plano registra o ajuste de execução.
This commit is contained in:
Francisco Solla 2026-08-23 12:54:45 -03:00
parent 9a66a23cf6
commit 1c865bd822
4 changed files with 54 additions and 6 deletions

View file

@ -46,7 +46,7 @@ Alternativas rejeitadas: A — nenhum call site usa `id: string`; constraint err
**D3 — Gate de query mínima (muda API pública do hook — registrar).** **D3 — Gate de query mínima (muda API pública do hook — registrar).**
Opções: A) prop opcional `isQueryReady?: (query: string) => boolean`, default `() => true`, lida via ref (padrão `isQueryReadyRef` preservado) e checada **dentro do efeito, antes do timeout**, com reset `options/loading/failed` quando false | B) hook sempre busca; gate fica fora (cada componente pré-filtra) | C) unificar: o hook aplica `isContactSearchQueryReady` por default. Opções: A) prop opcional `isQueryReady?: (query: string) => boolean`, default `() => true`, lida via ref (padrão `isQueryReadyRef` preservado) e checada **dentro do efeito, antes do timeout**, com reset `options/loading/failed` quando false | B) hook sempre busca; gate fica fora (cada componente pré-filtra) | C) unificar: o hook aplica `isContactSearchQueryReady` por default.
Recomendação: **A** — reproduz borda a borda os 3 comportamentos: `DemandResponsibleMultiSelect` busca sempre (default `() => true`; query vazia → server devolve sugestões de assessores do município — fill-in C143), `ResponsibleMultiSelect` reseta com `isContactSearchQueryReady` (L80-85), `AsyncSearchCombobox` com gate configurável (`DemandFields` município, `ContactCombobox` contato). O reset in-effect (options=[], loading=false, failed=false) é o mesmo código nos 2 gated e mora no hook; o guard `requestId` em then/catch/finally e o `cleanup` `clearTimeout` também. Trim: o hook aplica `query.trim()` internamente (os 3 trimmam hoje); server actions seguem trimmando defensivamente — intocadas. Recomendação: **A** — reproduz borda a borda os 3 comportamentos: `DemandResponsibleMultiSelect` busca sempre (default `() => true`; query vazia → server devolve sugestões de assessores do município — fill-in C143), `ResponsibleMultiSelect` reseta com `isContactSearchQueryReady` (L80-85), `AsyncSearchCombobox` com gate configurável (`DemandFields` município, `ContactCombobox` contato). O reset in-effect (options=[], loading=false, failed=false) é o mesmo código nos 2 gated e mora no hook; o guard `requestId` em then/catch/finally e o `cleanup` `clearTimeout` também. Trim: o hook aplica `query.trim()` internamente (os 3 trimmam hoje); server actions seguem trimmando defensivamente — intocadas. **Quirk preservado (pós-`/simplify` pinado por teste):** o branch de gate não-pronto **não invalida** o requestId em voo — herdado fielmente dos 3 originais, uma resposta stale ainda aterrissa depois do reset; o teste `stale in-flight response lands after the query turns not-ready` pinça o comportamento atual (defer com gatilho: um consumidor que exija invalidação real).
Alternativas rejeitadas: B — o reset + guard vazaria para cada componente, recriando a duplicação que a entrega elimina; C — unificar mudaria comportamento (Demand ganharia um gate que não tem hoje) e a intenção é explícita: "a semântica de query vazia difere e o hook não pode unificá-la". Alternativas rejeitadas: B — o reset + guard vazaria para cada componente, recriando a duplicação que a entrega elimina; C — unificar mudaria comportamento (Demand ganharia um gate que não tem hoje) e a intenção é explícita: "a semântica de query vazia difere e o hook não pode unificá-la".
**D4 — Fronteira hook × componente (o que fica em cada um).** **D4 — Fronteira hook × componente (o que fica em cada um).**
@ -56,12 +56,12 @@ Alternativas rejeitadas: B — unificar a chave de filtro mudaria a semântica d
**D5 — Exports do módulo, "registro" e teste unitário.** **D5 — Exports do módulo, "registro" e teste unitário.**
Opções: A) módulo exporta o hook + `ASYNC_SEARCH_DEBOUNCE_MS` + tipo `UseAsyncSearchOptionsResult<T>`; teste unitário novo com `renderHook` + `vi.useFakeTimers` | B) sem tipo de retorno exportado; cobertura só pelos specs existentes dos consumidores. Opções: A) módulo exporta o hook + `ASYNC_SEARCH_DEBOUNCE_MS` + tipo `UseAsyncSearchOptionsResult<T>`; teste unitário novo com `renderHook` + `vi.useFakeTimers` | B) sem tipo de retorno exportado; cobertura só pelos specs existentes dos consumidores.
Recomendação: **A** — "registrar o hook com type `useAsyncSearchOptions`" aqui significa exportar o hook com sua assinatura tipada (parâmetros `{ open, query, search, isQueryReady? }` + retorno `{ options: T[]; loading: boolean; failed: boolean }`) — **não há pin/registro a atualizar**: `src/hooks/` não é pinado (o pin de `codebaseConventions.unit.spec.ts:389` cobre só top-level de `src/utilities/`), knip valida o export consumido, e hook não é componente Payload (sem import map). Teste: `tests/unit/useAsyncSearchOptions.unit.spec.tsx` no padrão de `campaignHomeSearchQuery.unit.spec.ts` (`renderHook` + `vi.useFakeTimers` + `vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS)`) — infra já existente (também `useMicTranscript`, `campaignCellAutosave`), **sem infra nova**. Como a `search` é injetada por prop (e não importada de módulo), o teste passa `vi.fn()` com resolves/rejects controlados — não há módulo server a mockar; o padrão de mock de `activityOverlay.unit.spec.tsx` (L20-23) continua valendo para os consumidores, que ficam intocados. Recomendação: **A (execução ajustada pós-`/simplify`)** — "registrar o hook com type `useAsyncSearchOptions`" aqui significa exportar o hook com sua assinatura tipada (parâmetros `{ open, query, search, isQueryReady? }` + retorno `{ options: T[]; loading: boolean; failed: boolean }`) — **não há pin/registro a atualizar**: `src/hooks/` não é pinado (o pin de `codebaseConventions.unit.spec.ts:389` cobre só top-level de `src/utilities/`), knip valida o export consumido, e hook não é componente Payload (sem import map). **Ajuste:** o tipo `UseAsyncSearchOptionsResult<T>` ficou **local (não exportado)** — o revisor de qualidade apontou export especulativo com zero consumidores e o knip local (regra `types: error`) flagearia; a anotação de retorno interna o mantém como contrato documentado. Teste: `tests/unit/useAsyncSearchOptions.unit.spec.ts` (`.ts`, não `.tsx` — `renderHook` não exige JSX) no padrão de `campaignHomeSearchQuery.unit.spec.ts` (`renderHook` + `vi.useFakeTimers` + `vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS)`) — infra já existente (também `useMicTranscript`, `campaignCellAutosave`), **sem infra nova**. Como a `search` é injetada por prop (e não importada de módulo), o teste passa `vi.fn()` com resolves/rejects controlados — não há módulo server a mockar; o padrão de mock de `activityOverlay.unit.spec.tsx` (L20-23) continua valendo para os consumidores, que ficam intocados. Pós-`/simplify` o spec ganhou 3 casos pin (contratos mais sutis do hook): `loading === true` em voo, retry pós-falha, e o quirk gate→not-ready com resposta stale em voo (ver D3).
Alternativas rejeitadas: B — a mecânica requestId/gate/debounce é a parte mais sutil da extração (fonte de regressão silenciosa) e hoje só é coberta indiretamente por e2e; o teste direto do hook é barato e pinaria o contrato que os 3 componentes passarão a compartilhar. Alternativas rejeitadas: B — a mecânica requestId/gate/debounce é a parte mais sutil da extração (fonte de regressão silenciosa) e hoje só é coberta indiretamente por e2e; o teste direto do hook é barato e pinaria o contrato que os 3 componentes passarão a compartilhar.
### Componentes / mudanças ### Componentes / mudanças
- **`useAsyncSearchOptions` + `ASYNC_SEARCH_DEBOUNCE_MS` + `UseAsyncSearchOptionsResult<T>`** (`src/hooks/useAsyncSearchOptions.ts`, novo): estado `options/loading/failed`; refs `requestId`, `searchRef`, `isQueryReadyRef` (atribuídos no render, como hoje); efeito com `if (!open) return`; gate lido via ref **antes** do timeout (reset e return); `setTimeout(ASYNC_SEARCH_DEBOUNCE_MS)` → `setLoading(true)/setFailed(false)` → `search(query.trim())` com guards `requestId.current !== currentRequestId` em then/catch/finally; cleanup `clearTimeout`. Sem diretiva `'use client'` (folha client-safe, como `use-mobile.ts`). Deps do efeito: `[open, query]` — gate e search via ref, sem re-execução por identidade (padrão preservado). - **`useAsyncSearchOptions` + `ASYNC_SEARCH_DEBOUNCE_MS`** (`src/hooks/useAsyncSearchOptions.ts`, novo; tipo de retorno `UseAsyncSearchOptionsResult<T>` **local**, não exportado — knip `types: error`): estado `options/loading/failed`; refs `requestId`, `searchRef`, `isQueryReadyRef` (atribuídos no render, como hoje); efeito com `if (!open) return`; gate lido via ref **antes** do timeout (reset e return); `setTimeout(ASYNC_SEARCH_DEBOUNCE_MS)` → `setLoading(true)/setFailed(false)` → `search(query.trim())` com guards `requestId.current !== currentRequestId` em then/catch/finally; cleanup `clearTimeout`. Sem diretiva `'use client'` (folha client-safe, como `use-mobile.ts`). Deps do efeito: `[open, query]` — gate e search via ref, sem re-execução por identidade (padrão preservado).
- **`DemandResponsibleMultiSelect`** (`src/components/campaign/demand/DemandResponsibleMultiSelect.tsx`): remove estados/refs L61-69 e o efeito L71-94; `const { options, loading, failed } = useAsyncSearchOptions({ open, query, search })` (sem gate — busca sempre). Mantém: `selected`/chips/avatar/`(criador)`/`lockCreator`/hidden inputs, `visibleOptions` (filtro por `selectedIds`), `shortQuery` e headings "Sugestões do município"/"Assessores", mensagens de vazio. - **`DemandResponsibleMultiSelect`** (`src/components/campaign/demand/DemandResponsibleMultiSelect.tsx`): remove estados/refs L61-69 e o efeito L71-94; `const { options, loading, failed } = useAsyncSearchOptions({ open, query, search })` (sem gate — busca sempre). Mantém: `selected`/chips/avatar/`(criador)`/`lockCreator`/hidden inputs, `visibleOptions` (filtro por `selectedIds`), `shortQuery` e headings "Sugestões do município"/"Assessores", mensagens de vazio.
- **`ResponsibleMultiSelect`** (`src/components/campaign/shared/ResponsibleMultiSelect.tsx`): remove L67-76 e o efeito L78-107; hook com `isQueryReady={isContactSearchQueryReady}`. Mantém: `groups` por `typeLabel`, `atCapacity`, mensagem "Digite ao menos dois caracteres…" (o import de `isContactSearchQueryReady` permanece — usado no render, L224), hidden JSON, chips com `typeLabel`. - **`ResponsibleMultiSelect`** (`src/components/campaign/shared/ResponsibleMultiSelect.tsx`): remove L67-76 e o efeito L78-107; hook com `isQueryReady={isContactSearchQueryReady}`. Mantém: `groups` por `typeLabel`, `atCapacity`, mensagem "Digite ao menos dois caracteres…" (o import de `isContactSearchQueryReady` permanece — usado no render, L224), hidden JSON, chips com `typeLabel`.
- **`AsyncSearchCombobox`** (`src/components/campaign/shared/AsyncSearchCombobox.tsx`): remove estados/refs L49-60 e o efeito de busca L66-95; hook com `isQueryReady` (a prop continua no componente — usada no render do vazio, L189). **O efeito sync `selected←value` (L62-64) FICA no componente** (é contrato do combobox, não da busca). Mantém: `pinnedOptions`/`pinnedGroupHeading`, grupos "Seleção"/"Selecionado"/"Resultados", `choose()`, `emptyOptionLabel`, `queryTooShortMessage`. - **`AsyncSearchCombobox`** (`src/components/campaign/shared/AsyncSearchCombobox.tsx`): remove estados/refs L49-60 e o efeito de busca L66-95; hook com `isQueryReady` (a prop continua no componente — usada no render do vazio, L189). **O efeito sync `selected←value` (L62-64) FICA no componente** (é contrato do combobox, não da busca). Mantém: `pinnedOptions`/`pinnedGroupHeading`, grupos "Seleção"/"Selecionado"/"Resultados", `choose()`, `emptyOptionLabel`, `queryTooShortMessage`.

View file

@ -36,7 +36,6 @@ import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from
import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel' import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel'
import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker' import { InstagramPostExclusionPicker as InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0 } from '../../../components/admin/InstagramPostExclusionPicker'
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { ImportExportProvider as ImportExportProvider_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
@ -79,7 +78,6 @@ export const importMap = {
"@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36, "@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36,
"./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da, "./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da,
"./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0, "./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
"@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36, "@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
} }

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
export const ASYNC_SEARCH_DEBOUNCE_MS = 250 export const ASYNC_SEARCH_DEBOUNCE_MS = 250
export type UseAsyncSearchOptionsResult<T> = { type UseAsyncSearchOptionsResult<T> = {
options: T[] options: T[]
loading: boolean loading: boolean
failed: boolean failed: boolean
@ -20,6 +20,10 @@ type UseAsyncSearchOptionsArgs<T> = {
* out-of-order responses, loading/failed states, per-consumer query gate). * out-of-order responses, loading/failed states, per-consumer query gate).
* The server action is injected as a prop by the consumer, keeping this module * The server action is injected as a prop by the consumer, keeping this module
* client-safe; each consumer keeps its own filtering/grouping/pinned options. * client-safe; each consumer keeps its own filtering/grouping/pinned options.
* Contract quirks, preserved from the three original copies: a not-ready query
* resets options/loading/failed without invalidating an in-flight request
* (a stale response still lands after the reset), and the search receives the
* trimmed query.
*/ */
export function useAsyncSearchOptions<T extends { id: number }>({ export function useAsyncSearchOptions<T extends { id: number }>({
open, open,

View file

@ -107,6 +107,52 @@ describe('useAsyncSearchOptions', () => {
expect(view.result.current.options).toEqual([]) expect(view.result.current.options).toEqual([])
}) })
it('recovers from a failed search on the next successful one', async () => {
const { search, view } = renderOptions({ query: 'ca' })
search.mockRejectedValueOnce(new Error('boom'))
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
expect(view.result.current.failed).toBe(true)
view.rerender({ open: true, query: 'cairu' })
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
expect(view.result.current.failed).toBe(false)
expect(view.result.current.options).toEqual([{ id: 1, label: 'hit:cairu' }])
expect(view.result.current.loading).toBe(false)
})
it('keeps loading while the request is in flight', async () => {
const { search, view } = renderOptions({ query: 'ca' })
search.mockImplementationOnce(() => new Promise<TestOption[]>(() => {}))
act(() => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
expect(view.result.current.loading).toBe(true)
expect(view.result.current.failed).toBe(false)
})
it('lets a stale in-flight response land after the query turns not-ready (preserved quirk)', async () => {
const { search, view } = renderOptions({ query: 'ca', isQueryReady: (q) => q.length >= 2 })
let resolveFirst: (options: TestOption[]) => void = () => {}
search.mockImplementationOnce(
() => new Promise<TestOption[]>((resolve) => (resolveFirst = resolve)),
)
act(() => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
expect(view.result.current.loading).toBe(true)
view.rerender({ open: true, query: '', isQueryReady: (q) => q.length >= 2 })
expect(view.result.current.options).toEqual([])
expect(view.result.current.loading).toBe(false)
await act(async () => resolveFirst([{ id: 1, label: 'stale' }]))
expect(view.result.current.options).toEqual([{ id: 1, label: 'stale' }])
expect(view.result.current.loading).toBe(false)
})
it('discards an out-of-order response from a superseded request', async () => { it('discards an out-of-order response from a superseded request', async () => {
const { search, view } = renderOptions({ query: 'ca' }) const { search, view } = renderOptions({ query: 'ca' })