From 417adb0d859526bce16796a6c838df16dc0a0b5a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 01:36:46 +0200 Subject: [PATCH] fix(ui): capture event values before deferring to mutate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Income this month" field could not be changed from 0. Typing zeros appeared to work; typing any non-zero snapped the field back to a single 0. mutate() defers its callback behind the store's serialization queue and an IndexedDB write. React re-renders and resets a controlled input's DOM value back to the last committed value well before that callback runs, so a handler reading event.target.value INSIDE the callback read the reset value and committed 0 — forever. Committing 0 matches what is displayed, which is why typing zeros looked fine. Six handlers had this shape (income, post name, post currency, standing-rule kind, base currency, rate service URL). Each now reads the DOM value synchronously and passes it in. Sites that already captured into a const first — the FX rate and the standing-rule amount — were never affected, which is why those fields worked. Purchase, split and plan editors were never affected either: they write to local component state synchronously, never through the deferred queue. Adds a source-level regression guard, because the trigger is real-browser render timing that happy-dom does not reproduce. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/eventCapture.test.ts | 43 ++++++++++++++++++++++ src/ui/routes/MonthRoute.tsx | 14 +++---- src/ui/routes/settings/CurrencySection.tsx | 16 ++++---- src/ui/routes/settings/PostsSection.tsx | 25 +++++++------ 4 files changed, 71 insertions(+), 27 deletions(-) create mode 100644 src/ui/eventCapture.test.ts diff --git a/src/ui/eventCapture.test.ts b/src/ui/eventCapture.test.ts new file mode 100644 index 0000000..d625104 --- /dev/null +++ b/src/ui/eventCapture.test.ts @@ -0,0 +1,43 @@ +import { test, expect } from "bun:test"; +import { Glob } from "bun"; + +/** + * Regression guard for a bug that froze the "Income this month" field at 0. + * + * `mutate()` defers its callback behind the store's serialization queue and an + * IndexedDB write. React re-renders and resets a controlled input's DOM value + * back to the last committed value long before that callback runs, so a handler + * that reads `event.target.value` INSIDE the callback reads the reset value — + * committing 0 forever, while typing appeared to do nothing. + * + * The value must be captured synchronously in the handler, then passed in. + * This scans source rather than behaviour because the trigger is real-browser + * render timing that happy-dom does not reproduce. + */ +test("no handler reads event.target.value inside a deferred mutate() callback", async () => { + const offenders: string[] = []; + + for await (const file of new Glob("src/ui/**/*.tsx").scan(".")) { + const source = await Bun.file(file).text(); + // Find each mutate( call and inspect its callback body. + const re = /mutate\(\s*\(?\s*(?:draft|data)\b/g; + let match: RegExpExecArray | null; + while ((match = re.exec(source)) !== null) { + // Walk to the end of the mutate(...) call by matching parentheses. + let depth = 0; + let i = source.indexOf("(", match.index); + const start = i; + for (; i < source.length; i++) { + if (source[i] === "(") depth++; + else if (source[i] === ")") { depth--; if (depth === 0) break; } + } + const body = source.slice(start, i + 1); + if (/\b(?:event|e)\.target\.value/.test(body)) { + const line = source.slice(0, match.index).split("\n").length; + offenders.push(`${file}:${line}`); + } + } + } + + expect(offenders).toEqual([]); +}); diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index c0d9ba1..cf0cf0b 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -49,14 +49,12 @@ export function MonthRoute() { step="0.01" className="font-money w-40" value={view.income} - onChange={(event) => - mutate((draft) => - setIncome(draft, monthId, { - amount: Number(event.target.value) || 0, - currency: base, - }), - ) - } + onChange={(event) => { + // Read the DOM value NOW: mutate() defers behind the write queue, + // by which time React has reset this input to the committed value. + const amount = Number(event.target.value) || 0; + mutate((draft) => setIncome(draft, monthId, { amount, currency: base })); + }} />
diff --git a/src/ui/routes/settings/CurrencySection.tsx b/src/ui/routes/settings/CurrencySection.tsx index 88903d9..e1509a8 100644 --- a/src/ui/routes/settings/CurrencySection.tsx +++ b/src/ui/routes/settings/CurrencySection.tsx @@ -44,9 +44,10 @@ export function CurrencySection() { id="base-currency" className="h-9 w-32 rounded border bg-background px-2 text-sm" value={base} - onChange={(event) => - mutate((draft) => setBaseCurrency(draft, event.target.value as Currency)) - } + onChange={(event) => { + const currency = event.target.value as Currency; + mutate((draft) => setBaseCurrency(draft, currency)); + }} > {CURRENCIES.map((currency) => (