Skip to content

fix: cancel the pending animation frame on teardown - #47

Merged
david-chau merged 1 commit into
mainfrom
fix/cancel-the-pending-frame
Aug 3, 2026
Merged

fix: cancel the pending animation frame on teardown#47
david-chau merged 1 commit into
mainfrom
fix/cancel-the-pending-frame

Conversation

@david-chau

Copy link
Copy Markdown
Owner

Full leak audit of the shipped code. No leaks found. One loose end, fixed
here, which is not a leak.

The loose end

useRejectionFlash schedules two callbacks on a refused keystroke — a
requestAnimationFrame to turn the flag on, a setTimeout to turn it off. The
timer was cleared on unmount and before rescheduling. The frame was neither.

Verified rather than assumed — stubbing rAF and firing the pending callback
after unmount shows it running and setting state on a component that is gone.
React 18 makes that a silent no-op, which is why nothing ever complained.

Retention is one frame, so it never accumulates. But leaving one of two
scheduled callbacks uncancelled is the asymmetry that becomes a real leak the
moment someone moves it to a longer queue.

What the audit checked, and found clean

vector result
Event listeners one — the combobox pointerdown, added and removed in the same effect. 500 added / 500 removed over 500 cycles
Timers one, cleared on unmount and before rescheduling
Animation frames one — the finding above
Observers / intervals none
Promises / async none at all in shipped code
Module-level state one boolean (flagSupport)
Undo history capped at HISTORY_LIMIT on all four mutation sites; after 22,000 edits holds one snapshot
Reducer under load 20,000 edits → −0.00 MB
Component under load 20,000 edits → +0.01 MB

A correction to my own measurement

An earlier soak suggested +17.7 MB and looked like a real leak. It was my test,
not the library: I compared against an uncontrolled input that never
re-rendered, so React's re-render cost was being charged to this component.

With a fair control — both controlled, same re-render count:

plain controlled React input   +4.53 MB
FinancialInput                 +0.01 MB

The component is flat, and lighter under load than a plain controlled input.

Verification

1301 unit tests, coverage 100% × 4, bundle 3253 B / 3600 B.

🤖 Generated with Claude Code

A leak audit across the whole library found one loose end, and it is not a
leak.

useRejectionFlash schedules two callbacks when a keystroke is refused: a
requestAnimationFrame to turn the flag on, and a setTimeout to turn it off
again. The timer was cleared on unmount and before rescheduling. The frame was
neither.

Verified rather than assumed: stubbing rAF and firing the pending callback
after unmount shows it running and setting state on a component that is gone.
React 18 makes that a silent no-op, which is why nothing had ever complained.
The retention is a single frame, so it never accumulates — but leaving one of
two scheduled callbacks uncancelled is the asymmetry that becomes a real leak
as soon as somebody moves it to a longer queue.

What the rest of the audit found: nothing. The only listener is the combobox's
pointerdown, added and removed in the same effect and measured at 500 added and
500 removed over 500 cycles. The undo history is capped at HISTORY_LIMIT on all
four of its mutation sites, and after 22,000 edits holds one snapshot. There is
one piece of module-level state, a boolean. No observers, no intervals, and no
promises anywhere in the shipped code.

Under sustained editing the component is flat: 20,000 edits move the heap by
0.01 MB, where a plain controlled React input under the same load moves it by
4.53 MB. An earlier measurement suggesting otherwise was mine, not the
library's — it compared against an uncontrolled input that never re-rendered,
so it charged React's re-render cost to this component.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@david-chau
david-chau merged commit 9fc9616 into main Aug 3, 2026
8 checks passed
@david-chau
david-chau deleted the fix/cancel-the-pending-frame branch August 3, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant