From 1c865bd822ec98c55db4c0927bb1735038e8535b Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 23 Aug 2026 12:54:45 -0300 Subject: [PATCH] =?UTF-8?q?refactor(campaign):=20E1=20simplify=20=E2=80=94?= =?UTF-8?q?=20pin=20loading/retry/stale-quirk=20no=20spec=20e=20tipo=20loc?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...air-hook-busca-assincrona-debounce-impl.md | 6 +-- src/app/(payload)/admin/importMap.js | 2 - src/hooks/useAsyncSearchOptions.ts | 6 ++- tests/unit/useAsyncSearchOptions.unit.spec.ts | 46 +++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/plans/extrair-hook-busca-assincrona-debounce-impl.md b/docs/plans/extrair-hook-busca-assincrona-debounce-impl.md index 32192a0a..74d968f0 100644 --- a/docs/plans/extrair-hook-busca-assincrona-debounce-impl.md +++ b/docs/plans/extrair-hook-busca-assincrona-debounce-impl.md @@ -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).** 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". **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.** Opções: A) módulo exporta o hook + `ASYNC_SEARCH_DEBOUNCE_MS` + tipo `UseAsyncSearchOptionsResult`; 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` 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. ### Componentes / mudanças -- **`useAsyncSearchOptions` + `ASYNC_SEARCH_DEBOUNCE_MS` + `UseAsyncSearchOptionsResult`** (`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` **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. - **`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`. diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 097d2c4e..ed8c41ef 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -36,7 +36,6 @@ import { ImportPreview as ImportPreview_cdf7e044479f899a31f804427d568b36 } from import { ImportSaveButton as ImportSaveButton_cdf7e044479f899a31f804427d568b36 } from '@payloadcms/plugin-import-export/rsc' import { InstagramSyncStatusPanel as InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da } from '../../../components/admin/InstagramSyncStatusPanel' 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 { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' @@ -79,7 +78,6 @@ export const importMap = { "@payloadcms/plugin-import-export/rsc#ImportSaveButton": ImportSaveButton_cdf7e044479f899a31f804427d568b36, "./components/admin/InstagramSyncStatusPanel#InstagramSyncStatusPanel": InstagramSyncStatusPanel_90dd38ffecf41d93e389c788f98f48da, "./components/admin/InstagramPostExclusionPicker#InstagramPostExclusionPicker": InstagramPostExclusionPicker_ba03418db242c21470543bbeb06827a0, - "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@payloadcms/plugin-import-export/rsc#ImportExportProvider": ImportExportProvider_cdf7e044479f899a31f804427d568b36, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/src/hooks/useAsyncSearchOptions.ts b/src/hooks/useAsyncSearchOptions.ts index dcb6c05e..817a1eb8 100644 --- a/src/hooks/useAsyncSearchOptions.ts +++ b/src/hooks/useAsyncSearchOptions.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' export const ASYNC_SEARCH_DEBOUNCE_MS = 250 -export type UseAsyncSearchOptionsResult = { +type UseAsyncSearchOptionsResult = { options: T[] loading: boolean failed: boolean @@ -20,6 +20,10 @@ type UseAsyncSearchOptionsArgs = { * 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 * 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({ open, diff --git a/tests/unit/useAsyncSearchOptions.unit.spec.ts b/tests/unit/useAsyncSearchOptions.unit.spec.ts index a7b9f716..c5559a04 100644 --- a/tests/unit/useAsyncSearchOptions.unit.spec.ts +++ b/tests/unit/useAsyncSearchOptions.unit.spec.ts @@ -107,6 +107,52 @@ describe('useAsyncSearchOptions', () => { 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(() => {})) + + 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((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 () => { const { search, view } = renderOptions({ query: 'ca' })