fix(forms): send one Idempotency-Key per opened create form

A mood, symptom, lab value, custom metric value, unscheduled dose,
allergy, vaccination, visit or measurement saved on a weak signal could
be stored twice: the POST committed, the answer missed the 15 s
timeout, the form said it could not save, and the second tap was a new
call with a new key, so the server ran it again.

useIdempotentPost keeps one key until a save lands and renews it for the
next entry, so the retry replays the first answer. The create forms and
the three create hooks post through it; the comment on the per-call key
in api-fetch now says what that key does and does not cover.

LabForm and MoodForm each gain the one line that mounts the key, so
their function-size baselines go up by one. The intake toast guard now
recognises a dose posted through the hook.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:02:40 +01:00
1 parent 121b23f4a2
commit f761500247
15 files changed
+172 -35

No files matched your search

@@ -0,0 +1,75 @@
/**
* A second tap on Save after the first answer was lost must not store the
* entry twice. The per-call key the transport mints changes with every
* call, so a create form sends one key until a save lands.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useIdempotentPost } from "../use-idempotent-post";
type Post = ReturnType<typeof useIdempotentPost>;
function openForm(): Post {
let post: Post | null = null;
function Probe() {
post = useIdempotentPost();
return null;
}
renderToStaticMarkup(<Probe />);
return post!;
}
const fetchMock = vi.fn();
function keyOf(call: number): string | null {
const init = fetchMock.mock.calls[call]![1] as RequestInit;
return new Headers(init.headers).get("Idempotency-Key");
}
const stored = () =>
new Response(JSON.stringify({ data: { id: "e1" }, error: null }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
beforeEach(() => vi.stubGlobal("fetch", fetchMock));
afterEach(() => {
fetchMock.mockReset();
vi.unstubAllGlobals();
});
describe("useIdempotentPost", () => {
it("sends the first key again when the first answer never arrived", async () => {
fetchMock
.mockRejectedValueOnce(new DOMException("timed out", "TimeoutError"))
.mockResolvedValueOnce(stored());
const post = openForm();
await expect(post("/api/mood-entries", { mood: "GOOD" })).rejects.toThrow();
await post("/api/mood-entries", { mood: "GOOD" });
expect(keyOf(0)).toBeTruthy();
expect(keyOf(1)).toBe(keyOf(0));
});
it("takes a new key for the next entry once a save landed", async () => {
fetchMock.mockResolvedValueOnce(stored()).mockResolvedValueOnce(stored());
const post = openForm();
await post("/api/mood-entries", { mood: "GOOD" });
await post("/api/mood-entries", { mood: "GOOD" });
expect(keyOf(1)).not.toBe(keyOf(0));
});
it("keeps a header the caller set", async () => {
fetchMock.mockResolvedValueOnce(stored());
const post = openForm();
await post("/api/x", {}, { headers: { Prefer: "respond-async" } });
const init = fetchMock.mock.calls[0]![1] as RequestInit;
expect(new Headers(init.headers).get("Prefer")).toBe("respond-async");
});
});
+5 -6
View File
@@ -262,12 +262,11 @@ export function apiGet<T = unknown>(
* Attach a per-call `Idempotency-Key` unless the caller already set one.
*
* Every `apiHandler` route accepts the header, and the write routes
* wrapped in `withIdempotency` dedupe on it — but almost no client call
* site sent one, so a transport-level replay of a create (a retried
* request, a re-sent form submission) could land a duplicate dose or
* vaccination and shift every derived series. Minting the key here
* protects every `apiPost` / `apiPatch` surface at once; a caller with a
* real natural key still wins by setting the header itself.
* wrapped in `withIdempotency` dedupe on it. A key minted per call covers a
* transport-level replay of that one request. A second tap on Save is a
* new call and gets a new key, so a create form sends one key for as long
* as it is open (`useIdempotentPost`); a caller that sets the header
* itself always wins.
*/
function withDefaultIdempotencyKey(init: RequestInit): RequestInit {
const headers = new Headers(init.headers);
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useRef } from "react";
import { apiPost } from "@/lib/api/api-fetch";
import { randomId } from "@/lib/random-id";
/**
* `apiPost` for a create form, under one `Idempotency-Key` until a save lands.
*
* The transport mints a fresh key per call, which covers a replayed request
* but not a second tap on Save: when the first answer never arrived (the 15 s
* timeout on a weak signal) the entry is already stored, and the retry is a
* new call with a new key, so the server stored it twice. Every submit
* through this function sends the same key until one succeeds, so the retry
* replays the first answer instead; a retry with a changed body is a
* different request and runs as one. The next entry gets a new key.
*/
export function useIdempotentPost() {
const key = useRef<string | null>(null);
return async function post<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<T> {
key.current ??= randomId();
const headers = new Headers(init?.headers);
headers.set("Idempotency-Key", key.current);
const result = await apiPost<T>(path, body, { ...init, headers });
key.current = null;
return result;
};
}