Someone built a small clinical app with AI coding tools. You type a child's weight and length, and it returns Z-scores against the growth reference. It worked. Then users started reporting that saved values came back slightly higher than what they had entered.
Small drift. A few tenths, sometimes a whole unit. The kind of number that looks like floating point, or a unit conversion, or a rounding rule applied twice.
Three days went into finding it. Here is where they went, because the wrong turns are the useful part.
Three clean layers
The obvious suspects were checked in the obvious order.
Rounding. The display rounds to one decimal, the store keeps full precision. Read back, compared, no discrepancy.
The conversion before the write. Values pass through a normalisation step on the way to storage. It was checked against its own inputs and outputs. Clean.
Field binding. The classic: weight bound to the length field, or two inputs sharing one piece of state. Also clean.
All three were clean, which is exactly why the search kept circling them. When a layer is innocent you do not cross it off, you assume you checked it badly and go around again.
The value was already wrong
The mistake was in the framing, not in any of those checks. Every one of them asked "what happens to the number on its way to storage". Nobody asked what the number was when the save handler received it.
It was already wrong.
An <input type="number"> that has focus consumes the wheel event, and the browser increments or decrements the value by one step per notch. This is standard, specified behaviour and it has been in browsers forever.
Now look at the layout in the screenshot. The results table sits directly below the form. That is a perfectly reasonable design — you enter measurements, you read the interpretation underneath. It also means that after typing a weight, the natural next action is to scroll down to read the Z-scores, and the cursor is still sitting in the weight field.
Every scroll edits the value that was just typed.
The drift stays small because a couple of notches is a couple of steps, which is why it reads like a rounding artefact instead of an input bug. And it does not reproduce when a developer tests it, because a developer types a value, tabs out, and clicks the button. Nobody scrolls with the cursor parked in the field except a real user reading a result that happens to be below the fold.
The fix is one line
Drop focus when the wheel fires:
<input
type="number"
onWheel={(e) => e.currentTarget.blur()}
...
/>
Or stop using type="number" for a value that is not really a spinner:
<input
type="text"
inputMode="decimal"
...
/>
The second keeps the numeric keypad on mobile, which is usually the only reason type="number" was chosen in the first place. It also gives up the browser's built-in numeric validation, so whatever validation you were leaning on has to become explicit.
Pick either. The interesting part is not the fix.
Nobody reviews code nobody asked for
The person debugging this reviewed their code carefully. They just reviewed the code they had asked for.
Scroll-to-increment was never requested. It is not a line anyone wrote. It arrived as a property of a default that got selected while generating a form — defensible in isolation, never specified, never reviewed, and invisible until a real hand touches a real mouse.
This is the failure mode I keep meeting in applications assembled with AI coding tools, and it is not "the AI writes buggy code". The generated code was fine. Individually every choice in it was reasonable. The gap is that a specification never existed, so there is no document against which any of it is wrong, and no reviewer whose job it was to notice a behaviour nobody ordered.
The bug report language made it worse, as it usually does. "The saved value changed" names a layer. It sends you to persistence, and persistence is where three days went. A report that said "the number changes while I am reading the results" would have been solved in ten minutes — but users do not report mechanisms, they report the last thing they saw.
The part that generalises
When you get a report that a stored value disagrees with what the user believes they entered, the search space is larger than the write path. Something upstream may be editing it: a wheel event, an autofill, a controlled component reformatting on re-render, an event handler firing on a parent.
A cheap way to cut the search in half: log the value at the moment the handler receives it. If it is already wrong there, every layer below is innocent and you stop re-reading them.
This is the difference between a prototype that works and a product that survives its users. The code was fine. The product was not.
I am curious how common the split is: when a stored value disagreed with what your users thought they entered, how often did it turn out to be the write path, and how often was it something upstream of it?

Top comments (0)