From 4485dfb3a9d47c5f2e41f39b615d43f59180a732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:36:37 +0100 Subject: [PATCH] fix(forms): close episode, mood and dose dialogs before the refetch The illness episode hooks returned the refresh of the illness reads and the dashboard from onSuccess, so every episode sheet kept spinning until those had refetched. The mood edit dialog, the mood and measurement bulk deletes and their Undo toasts did the same with an await, and so did the dose edit dialog, the log-intake dialog and the ledger's add dialog. They now answer and close as soon as the write lands and start the refresh without waiting for it. A scheduled dose lands on its slot, so a card that is still stale for a moment only repeats it. The take and skip buttons on a medication card keep waiting: nothing marks the dose in the cache meanwhile, so releasing them early would offer a Take for a dose already recorded. --- .../use-illness-save-settles.test.ts | 56 +++++++++++ src/components/illness/use-illness.ts | 9 +- ...se-measurement-list-deletes-settle.test.ts | 98 +++++++++++++++++++ .../list/use-measurement-list-data.ts | 12 ++- .../__tests__/use-medication-intake.test.ts | 36 ++++++- .../medications/dose-history-add-dialog.tsx | 14 ++- .../medications/intake-edit-dialog.tsx | 10 +- .../medications/use-medication-intake.ts | 5 +- src/components/mood/mood-list.tsx | 16 +-- 9 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 src/components/illness/__tests__/use-illness-save-settles.test.ts create mode 100644 src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts diff --git a/src/components/illness/__tests__/use-illness-save-settles.test.ts b/src/components/illness/__tests__/use-illness-save-settles.test.ts new file mode 100644 index 000000000..d5212fe30 --- /dev/null +++ b/src/components/illness/__tests__/use-illness-save-settles.test.ts @@ -0,0 +1,56 @@ +/** + * An episode save settles once the server stored it. + * + * Every episode hook's `onSuccess` used to return the refresh of the illness + * reads and the dashboard. A mutation waits for what `onSuccess` returns, so + * the episode sheet kept its spinner until each of those reads had refetched. + */ +import { describe, expect, it, vi } from "vitest"; + +const client = vi.hoisted(() => ({ + // A refetch that never answers, as on a slow connection. + invalidateQueries: vi.fn(() => new Promise(() => {})), + refetchQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), +})); + +vi.mock("@tanstack/react-query", () => ({ + // The hook's options are what is under test; return them as they are. + useQuery: (options: unknown) => options, + useMutation: (options: unknown) => options, + useQueryClient: () => client, +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiGetList: vi.fn(), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), +})); + +import { + useCreateEpisode, + useDeleteEpisode, + useResolveEpisode, + useRestoreEpisode, + useUpdateEpisode, + useUpsertDayLog, +} from "../use-illness"; + +describe("episode saves", () => { + it.each([ + ["create", () => useCreateEpisode()], + ["update", () => useUpdateEpisode()], + ["resolve", () => useResolveEpisode()], + ["delete", () => useDeleteEpisode()], + ["restore", () => useRestoreEpisode()], + ["day log", () => useUpsertDayLog("e1")], + ])("%s starts the refresh and does not wait for it", (_, hook) => { + client.invalidateQueries.mockClear(); + const options = hook() as unknown as { onSuccess: () => unknown }; + + expect(options.onSuccess()).toBeUndefined(); + expect(client.invalidateQueries).toHaveBeenCalled(); + }); +}); diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 562ba032b..f333c04ea 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -152,9 +152,12 @@ function useInvalidateIllness() { // the Today digest — both unmounted from here, so the bundle invalidation // is paired with the forced inactive refetch. The write routes hard-evict // the server analytics bucket, so the refetch returns post-write context. - return async () => { - await invalidateKeys(qc, illnessDependentKeys); - await refetchInactiveDailyReads(qc); + // Not returned: a mutation waits for what `onSuccess` returns, and the + // sheet would stay open until every read had refetched. + return () => { + void invalidateKeys(qc, illnessDependentKeys).then(() => + refetchInactiveDailyReads(qc), + ); }; } diff --git a/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts b/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts new file mode 100644 index 000000000..2b62ccaba --- /dev/null +++ b/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts @@ -0,0 +1,98 @@ +/** + * The list's bulk delete and its Undo answer once the server has done it. + * + * Both used to wait for every read a measurement touches (charts, insights, + * the dashboard) before the selection bar cleared or the toast showed, so on + * a slow connection a delete looked like it had not happened. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + client: { + // A refetch that never answers, as on a slow connection. + invalidateQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), + }, + toastSuccess: vi.fn(), + apiPost: vi.fn(async () => ({ deleted: 2 })), +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (fn: T) => fn, + useEffect: () => {}, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: false, isError: false }), + // The hook's options are what is under test; return them as they are. + useMutation: (options: unknown) => options, + useQueryClient: () => h.client, +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiDelete: vi.fn(), + apiPost: h.apiPost, +})); + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +vi.mock("sonner", () => ({ toast: { success: h.toastSuccess } })); + +import type { MeasurementListFilters } from "../use-measurement-list-filters"; +import { useMeasurementListData } from "../use-measurement-list-data"; + +function useBulkDelete(clearSelection = vi.fn()) { + const filters = { + typeFilter: "ALL", + selectedIds: new Set(), + setSelectedIds: vi.fn(), + clearSelection, + } as unknown as MeasurementListFilters; + const data = useMeasurementListData({ + filters, + enabled: true, + lockedType: undefined, + }); + return data.bulkDeleteMutation as unknown as { + onSuccess: (deleted: number, ids: string[]) => unknown; + }; +} + +describe("measurement list deletes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clears the selection and says so without waiting for the refetch", () => { + const clearSelection = vi.fn(); + const bulk = useBulkDelete(clearSelection); + + expect(bulk.onSuccess(2, ["a", "b"])).toBeUndefined(); + expect(clearSelection).toHaveBeenCalled(); + expect(h.toastSuccess).toHaveBeenCalledWith( + "measurements.bulkDeleteSuccess", + expect.anything(), + ); + }); + + it("confirms the Undo once the rows are back, before the refetch", async () => { + useBulkDelete().onSuccess(2, ["a", "b"]); + const undo = h.toastSuccess.mock.calls[0]![1] as { + action: { onClick: () => void }; + }; + + undo.action.onClick(); + await vi.waitFor(() => + expect(h.toastSuccess).toHaveBeenLastCalledWith( + "measurements.restoredToast", + ), + ); + expect(h.apiPost).toHaveBeenLastCalledWith("/api/measurements/restore", { + ids: ["a", "b"], + }); + }); +}); diff --git a/src/components/measurements/list/use-measurement-list-data.ts b/src/components/measurements/list/use-measurement-list-data.ts index 81c2db1e9..472bdf88b 100644 --- a/src/components/measurements/list/use-measurement-list-data.ts +++ b/src/components/measurements/list/use-measurement-list-data.ts @@ -114,9 +114,9 @@ export function useMeasurementListData({ async (ids: string[]) => { try { await apiPost("/api/measurements/restore", { ids }); - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast(t("measurements.restoreError"), { cause: failure }); } @@ -154,9 +154,11 @@ export function useMeasurementListData({ ); return data.deleted; }, - onSuccess: async (deleted, ids) => { - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); + // The refresh is started, not returned: the selection bar and the toast + // answer as soon as the rows are gone. + onSuccess: (deleted, ids) => { + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( deleted === 1 diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index 7d8d500d3..0e323f312 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -366,7 +366,41 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => { takenAt: "2026-06-01T08:30:00.000Z", }); expect(body).not.toHaveProperty("scheduledFor"); - expect(queryClient.invalidateQueries).toHaveBeenCalled(); + // Started, not awaited (the case below). + await vi.waitFor(() => + expect(queryClient.invalidateQueries).toHaveBeenCalled(), + ); + expect(toast.success).toHaveBeenCalledWith( + "medications.intakeToastTaken:Mounjaro", + ); + }); + + it("answers once the dose is stored, without waiting for the refetches", async () => { + // The dialog closes on this answer; on a slow connection the reads it + // refreshes can take seconds. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers(), + json: vi.fn(), + text: vi.fn().mockResolvedValue(""), + }), + ); + const queryClient = { + invalidateQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), + } as unknown as QueryClient; + + await expect( + runLogIntake({ + medication, + skipped: false, + takenAt: "2026-06-01T08:30:00.000Z", + t, + queryClient, + }), + ).resolves.toBe(true); expect(toast.success).toHaveBeenCalledWith( "medications.intakeToastTaken:Mounjaro", ); diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 04789ed39..2f935152f 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -193,16 +193,14 @@ export function LedgerAddDialog({ } else { toast.success(message); } - await invalidateKeys(queryClient, [ - ...medicationDependentKeys, - ledgerKey, - ]); - // A ledger add can land a today-dated dose, so clear the unmounted - // Today hero + dashboard too (v1.32.19 — same class as the ledger's - // own log/edit/delete sites). - await refetchInactiveDailyReads(queryClient); setNudgeOpen(false); onOpenChange(false); + // Started, not awaited: the dialog closes once the dose is stored. A + // ledger add can land a today-dated dose, so clear the unmounted Today + // hero + dashboard too (same class as the ledger's own log/edit/delete + // sites). + void invalidateKeys(queryClient, [...medicationDependentKeys, ledgerKey]); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast( t("medications.intakeToastFailed", { name: medicationName }), diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index 48da1b6f4..7c55e6309 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -130,12 +130,14 @@ function IntakeEditDialogBody({ body.takenAt = null; } await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body); - // Editing today's dose (un-skip, retime) changes its due/taken state — - // route through the blessed helper so the Today hero + dashboard clear - // at once even when they are unmounted behind the detail page. - await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); onClose(); + // Editing today's dose (un-skip, retime) changes its due/taken state — + // route through the blessed helper so the Today hero + dashboard clear + // at once even when they are unmounted behind the detail page. Started, + // not awaited: the edit names one row, so nothing here can be sent twice + // while the reads catch up. + void invalidateMedicationReads(queryClient); } catch (failure) { showErrorToast(t("medications.detail.intake.edit.failed"), { cause: failure, diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index 1bb3c72be..33f5d8446 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -281,7 +281,10 @@ export async function runLogIntake(deps: { body, ); const eventId = created?.id; - await invalidateMedicationReads(queryClient); + // Started, not awaited: the dialog closes on this answer. A scheduled + // dose lands on its slot, so a stale card tapped meanwhile only repeats + // it. + void invalidateMedicationReads(queryClient); const message = t( skipped ? "medications.intakeToastSkipped" diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index c9cc771d0..c811157e7 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -383,9 +383,9 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { async (ids: string[]) => { try { await apiPost("/api/mood-entries/restore", { ids }); - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast(t("mood.restoreError"), { cause: failure }); } @@ -425,9 +425,9 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { ); return data.deleted; }, - onSuccess: async (deleted, ids) => { - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); + onSuccess: (deleted, ids) => { + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( deleted === 1 @@ -506,12 +506,12 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { moodLoggedAt, }); }, - onSuccess: async (saved) => { + onSuccess: (saved) => { // A tag picked in the dialog may have been archived or deleted // elsewhere since it opened; the server names what it did not store. if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved")); - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); setEditSeed({ mood: editMood, tagsInput: editTagsInput,