The Send button worked.
Enter didn't.
One config object, two ways to send a message, and a callback that stopped being true after the first render.
Here is a bug report we never received: “Sometimes when I reply to someone, it posts as a normal message instead.”
We never received it because nobody could have written it. The reply posts. It arrives in the room. It looks exactly like a message you meant to send. The only thing missing is the thing you were replying to — and by the time you notice the conversation has moved on, and you assume you forgot to hit Reply.
We found it during a design review of our Crews feature, reading the composer code for a completely different reason.
Two doors into one room
The composer has two ways to send. There is a button, and there is the Enter key. Both call the same function.
Attach a photo and press Enter and the photo goes the same way. Share a location, press Enter, and the location is gone. No error, no warning, no red banner. The server returns 200. The message is real. It is just less than what you wrote.
That asymmetry — the button is fine, the key is not — is the entire clue.
useEditor runs once
Our composer is a thin wrapper around TipTap. Here is the shape of it, lightly trimmed:
const editor = useEditor({
extensions: [ /* … */ ],
editorProps: {
handleKeyDown: (view, event) => {
// Submit on Enter (without Shift)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onSubmit?.(); // ← render #1's onSubmit. Forever.
return true;
}
return false;
},
},
});
useEditor builds a ProseMirror instance and memoizes it. That is the point of it
— you do not want a new editor on every keystroke. But it means the object you hand it,
editorProps and every function inside it, is captured at construction and kept
for the lifetime of the component.
So handleKeyDown holds a closure over the onSubmit that existed on
the first render. And on the first render, sendMessage had closed over:
-
First render
replyTois null.pendingImageis null.pendingLocationis null.isSendingis false. The editor is constructed and swallows this version ofsendMessagewhole. -
You click Reply
setReplyTo(message)runs. React re-renders. A newsendMessageis created, closing over the reply target. The Send button'sonClickprop is rebuilt with it — JSX props are recreated every render, which is exactly why the button was never broken. -
The editor does not care
handleKeyDownis still the function from step 1, still holding thesendMessagefrom step 1, still convinced thatreplyTois null. - You press Enter A message is sent with no reply target, no attachments, and no regard for whether another send is already in flight. It succeeds. Nobody is told anything.
Why nothing caught it
This is the part worth sitting with, because the bug survived every guard we had.
TypeScript saw nothing wrong, because nothing was wrong, typewise. A stale
() => void has precisely the same type as a fresh one. Staleness is not a
property of a value; it is a property of when you got it, and types do not carry
time.
The linter saw nothing wrong. react-hooks/exhaustive-deps is
the rule that exists for this class of bug, and it only inspects hooks it has been told about
— useEffect, useMemo, useCallback, plus anything you
list in additionalHooks. useEditor is not on that list, so its
captured closure is invisible to the one tool designed to notice captured closures.
Nothing threw. There is no error state to log, no exception to trap, no failed request in the network tab. The system reports success because, from its point of view, it succeeded. It sent the message it was given. It was given the wrong message.
And we had no test on the path. That one is on us.
The failure mode here is silent partial data loss on the happy path — the worst kind there is, because every signal you have says everything is fine. Crashes get fixed in a day. This shipped, and sat there.
The obvious fix is a worse bug
The reflex is to add a dependency array. TipTap accepts one:
const editor = useEditor({ /* … */ }, [onSubmit]);
Do that and the editor is torn down and rebuilt whenever onSubmit changes
identity. Which is: on every render. Which is: on every keystroke, because the composer holds
the draft in state.
Rebuilding a ProseMirror view destroys the caret, the selection and the undo history. You would be resetting the user's cursor to the start of the box while they are mid-sentence, several times a second. You would have traded a bug nobody can see for one nobody can use.
Which points at the actual shape of the problem, and it is a nice one:
The instance must be stable. The behaviour must be current.
Those are two different lifetimes, and a config object passed once conflates them into one.
Separating the two lifetimes
A ref is a stable box whose contents are allowed to change. That is exactly the seam we need: the editor keeps a permanent handle on the box, and we refresh what is inside it after every render.
const onSubmitRef = useRef(onSubmit);
const onChangeRef = useRef(onChange);
useEffect(() => {
onSubmitRef.current = onSubmit;
onChangeRef.current = onChange;
}, [onSubmit, onChange]);
const editor = useEditor({
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onSubmitRef.current?.(); // ← read at call time, not construction
return true;
}
return false;
},
},
});
The handler is still the same function object it always was. It just no longer holds an
opinion about what onSubmit is — it asks, at the moment the key is pressed.
One detail worth not getting wrong: the assignment goes in an effect, not in the render body.
Writing onSubmitRef.current = onSubmit during render is a mutation during
render, and React does not promise to keep the work from a render it decides to throw away.
Under StrictMode or a concurrent re-render you can end up having published a callback from a
render that never committed. The effect runs after commit, which is the only moment the value
is known to be real.
The neighbour we found while we were in there
Rewriting that if meant looking hard at it, and the condition had a second
problem that matters more for us than it would for most teams:
// before
if (event.key === 'Enter' && !event.shiftKey)
// after
if (event.key === 'Enter' && !event.shiftKey
&& !event.isComposing && event.keyCode !== 229)
When an input method editor is open — Sinhala, Tamil, Japanese, or simply a phone keyboard showing a word suggestion — Enter does not mean “done”. It means “commit the candidate I am currently choosing”. Sending on that keystroke truncates a sentence the user is still in the middle of writing.
For a Sri Lanka travel product where a large share of typing happens in Sinhala and Tamil on
a phone, that is not an edge case; it is Tuesday. The keyCode === 229 check
alongside isComposing is legacy belt-and-braces — browsers have historically
disagreed about when isComposing is set, and 229 is the sentinel they all emit
while a composition is active.
The general shape
Once you have the tell, you see it everywhere. Any API that takes a config object once and keeps it will capture your callbacks along with it:
| Where | What gets frozen |
|---|---|
| useEditor (TipTap, Lexical) | editorProps, onUpdate |
| Map instances built in an effect | event handlers passed at construction |
| Chart instances with a config object | onClick, tooltip formatters |
| addEventListener in a mount effect | the listener itself |
| IntersectionObserver, ResizeObserver | the observer callback |
| A WebSocket's onmessage, assigned at connect | everything it reads |
The common shape: an instance whose lifetime is longer than a render, holding a function whose correctness is only one render long.
The rule we now apply, which is short enough to actually remember:
If a long-lived instance takes a callback that reads component state, route it through a ref.
If the callback only forwards to a setState or a dispatch, capture
it directly — those identities are already stable, and the indirection would be noise.
What we left alone, on purpose
We have a second TipTap wrapper elsewhere in the codebase, for articles rather than chat. It
has the identical shape: an onUpdate captured once at construction. It has never
misbehaved, because its onChange does nothing but forward to a state setter, and
state setters are stable across renders.
It is the same loaded gun with nothing in the chamber. We did not “fix” it — changing working code to match a pattern is how you introduce the next bug — but we wrote it down, because the day someone makes that callback read state is the day it starts eating keystrokes.
We also did something small and slightly defensive. The fix looks like ceremony. Two refs and an effect, to call a function you could obviously just call. Ceremony gets deleted by whoever tidies up next, so the comment above it opens with a sentence aimed squarely at that person:
/**
* THE CALLBACKS ARE HELD IN REFS, AND THIS IS NOT A STYLE CHOICE.
*
* `useEditor` runs once. Every handler inside `editorProps` therefore
* closes over the callbacks from the FIRST render and keeps them for
* the life of the component. …
*/
And the verification suite for this feature grew a line that fails the build if the indirection disappears:
check(
"REGRESSION: the editor calls the CURRENT onSubmit, not the first render's",
/onSubmitRef\.current\?\.\(\)/.test(editorSrc),
true
);
A comment explains a decision to someone who is reading. A check enforces it against someone who is not.
The bugs that survive longest are not the ones that crash. They are the ones that return 200.
A senior backend engineer to own the API and the data platform underneath all of this — plus six months of paid applied ML research inside SerendAI.
See the two roles →