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,