fix(forms): close the sheet as soon as a save lands

Saving a weight, a mood, a workout, a measurement edit or a quick-add
dose emptied the form and kept the button spinning until every read the
write touches had refetched (snapshot, digest, charts, insights). On a
slow connection that looked like nothing had been saved. The vaccination,
visit, address-book, symptom and check-up hooks did the same through an
onSuccess that returned the refetch, which a mutation waits for.

The forms now report and close first and start the refresh without
waiting for it; the hooks no longer return it. The quick-add loses a
second sweep of the inline compliance charts that the medication bundle
already covers, and its source pin follows.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:46:17 +01:00
1 parent 971d5d32cc
commit 4721914cac
13 files changed
+56 -33

No files matched your search

@@ -393,21 +393,22 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
);
it("routes the intake submit through the blessed invalidateMedicationReads (source pin)", () => {
expect(componentSrc).toContain(
"await invalidateMedicationReads(queryClient);",
);
expect(componentSrc).not.toContain(
"invalidateKeys(queryClient, medicationDependentKeys)",
);
// The submit handler pairs the blessed helper with the inline
// compliance-chart fan-out AFTER the intake POST landed.
// AFTER the intake POST landed, and after the sheet was told it is done:
// the save does not wait for the refetches before it closes.
expect(componentSrc).toMatch(
/async function handleSubmit\([\s\S]*?apiPost[\s\S]*?await invalidateMedicationReads\(queryClient\);[\s\S]*?complianceChartInline\(\)/,
/async function handleSubmit\([\s\S]*?apiPost[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/,
);
// The bundle carries every inline compliance chart, so no second sweep.
expect(medicationDependentKeys).toContainEqual(
queryKeys.complianceChartInline(),
);
});
it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => {
// The helper the submit path awaits — asserted directly, same shape as
// The helper the submit path calls — asserted directly, same shape as
// the take-all-due suite: the bundle fans out (N keys), then the two
// daily reads are invalidated with `refetchType: "inactive"`, which is
// what actually refetches them while unmounted.
@@ -235,13 +235,6 @@ export function MedicationIntakeQuickAdd({
);
const eventId = created?.id;
await invalidateMedicationReads(queryClient);
// Fan-out to every inline compliance chart key (one per medication)
// so the detail-page tile refreshes when the user re-enters it.
await queryClient.invalidateQueries({
queryKey: queryKeys.complianceChartInline(),
});
// v1.36.x — the shared decision, not a fourth copy of it. This surface
// was left out of the collapse that took the other three: it offered a
// delegate an Undo hitting `DELETE /api/medications/{id}/intake/{id}`,
@@ -272,6 +265,9 @@ export function MedicationIntakeQuickAdd({
toast.success(t("common.saved"));
}
onSuccess?.();
// The dose is stored; the sheet does not wait for the refetches. The
// bundle covers every inline compliance chart as well.
void invalidateMedicationReads(queryClient);
} catch (err) {
setError(
err instanceof ApiError && err.message
@@ -127,15 +127,15 @@ export function useMeasurementEdit({
}) => {
await apiPut(`/api/measurements/${id}`, { value, measuredAt, notes });
},
onSuccess: async () => {
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
onSuccess: () => {
setEditSeed({
value: editValue,
measuredAt: editMeasuredAt,
notes: editNotes,
});
dismissEdit();
void invalidateKeys(queryClient, measurementDependentKeys);
void refetchInactiveDailyReads(queryClient);
},
onError: (err) => {
// A 409 names its reason in `meta.errorCode`: the edit collides with
@@ -528,8 +528,6 @@ export function MeasurementForm({
setPulse("");
setPairedGlucose("");
setNotes("");
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
// v1.36.x — a delegate's receipt names the record. "Saved" alone is
// the one confirmation somebody acting for another person does not
// need, and the reading has just left their own history for good.
@@ -544,6 +542,10 @@ export function MeasurementForm({
: undefined,
);
onSuccess?.();
// The reading is stored; the sheet does not wait for every read that
// shows it to come back.
void invalidateKeys(queryClient, measurementDependentKeys);
void refetchInactiveDailyReads(queryClient);
} catch (err) {
setError(localizedApiError(err, t, "measurements.saveError"));
} finally {
+2 -2
View File
@@ -257,13 +257,13 @@ export function MoodForm({
if (tagKeys.length > 0) recordUse(tagKeys);
resetForm();
setMoreTagsOpen(false);
await invalidateKeys(queryClient, moodDependentKeys);
await refetchInactiveDailyReads(queryClient);
toast.success(t("common.saved"));
// The entry landed, but a tag picked here may have been archived or
// deleted elsewhere since the picker loaded; the server says so.
if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved"));
onSuccess?.();
void invalidateKeys(queryClient, moodDependentKeys);
void refetchInactiveDailyReads(queryClient);
} catch (err) {
setError(localizedApiError(err, t, "mood.saveError"));
} finally {
+3 -1
View File
@@ -137,9 +137,11 @@ export function invalidateSymptomReads(qc: QueryClient) {
]);
}
// Not returned, so a save reports as soon as it lands rather than after
// every read it touches has refetched.
function useInvalidateSymptoms() {
const qc = useQueryClient();
return () => invalidateSymptomReads(qc);
return () => void invalidateSymptomReads(qc);
}
export function useCreateSymptomDefinition() {
@@ -125,8 +125,10 @@ export function useVaccination(id: string | null) {
*/
export function useVaccinationMutations() {
const qc = useQueryClient();
// Not returned: a mutation would wait for every refetch before it settles,
// and the sheet would stay open with its spinner until then.
const invalidate = () =>
Promise.all([
void Promise.all([
invalidateKeys(qc, vaccinationDependentKeys),
invalidateReminderReads(qc),
]);
@@ -105,6 +105,22 @@ describe("saveManualWorkout — the wire", () => {
expect(keys).toContain(JSON.stringify(queryKeys.timelineRoot()));
});
it("returns once the entry is stored, without waiting for the refetches", async () => {
// The sheet closes on this answer; on a slow connection the reads it
// refreshes can take seconds, and the person would watch a spinner.
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "inserted" }] }),
);
const client = {
...fakeClient(),
invalidateQueries: vi.fn(() => new Promise(() => {})),
};
await expect(
saveManualWorkout(ENTRY, client as unknown as QueryClient),
).resolves.toBe("inserted");
});
it("treats a duplicate (the same form submitted twice) as saved", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "duplicate" }] }),
@@ -53,8 +53,8 @@ export async function deleteManualWorkout(
): Promise<void> {
await apiDelete(`/api/workouts/${encodeURIComponent(id)}`);
queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) });
await invalidateKeys(queryClient, workoutDependentKeys);
await refetchInactiveDailyReads(queryClient);
void invalidateKeys(queryClient, workoutDependentKeys);
void refetchInactiveDailyReads(queryClient);
}
/**
@@ -94,7 +94,8 @@ interface BatchResult {
export type ManualWorkoutSaveOutcome = "inserted" | "duplicate" | "updated";
/**
* Post the entry and refresh what shows workouts. A `duplicate` is a success:
* Post the entry and start refreshing what shows workouts; the caller reports
* the save without waiting for those reads. A `duplicate` is a success:
* the same form already landed this session (a second tap, a retry), so the
* row exists exactly once. A `skipped` entry was refused and throws.
*/
@@ -112,8 +113,8 @@ export async function saveManualWorkout(
// The form also opens from the capture picker on any page, where the
// Today hero and the dashboard snapshot are unmounted, so those two are
// refetched outright.
await invalidateKeys(queryClient, workoutDependentKeys);
await refetchInactiveDailyReads(queryClient);
void invalidateKeys(queryClient, workoutDependentKeys);
void refetchInactiveDailyReads(queryClient);
return status;
}