OneCeylon logo
OneCeylon
Tech & Careers
NOTEBOOK Engineering · Research · Field notes

The notebook.

Where the engineers and researchers at OneCeylon write about what they are making. No hot takes, no roadmaps — just the work, and what we learned doing it.

ENGINEERING 25 August 2026 · 8 min read · By OneCeylon Engineering

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.

Crews · real-time chat · React + TipTap

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.

Clicking Send
@dilshan
the 6am train to Ella books out days ahead
did you book yours at the station or online?
Pressing Enter
reply target dropped
did you book yours at the station or online?
Same message, same function, two different results.

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:

  1. First render replyTo is null. pendingImage is null. pendingLocation is null. isSending is false. The editor is constructed and swallows this version of sendMessage whole.
  2. You click Reply setReplyTo(message) runs. React re-renders. A new sendMessage is created, closing over the reply target. The Send button's onClick prop is rebuilt with it — JSX props are recreated every render, which is exactly why the button was never broken.
  3. The editor does not care handleKeyDown is still the function from step 1, still holding the sendMessage from step 1, still convinced that replyTo is null.
  4. 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 effectevent handlers passed at construction
Chart instances with a config objectonClick, tooltip formatters
addEventListener in a mount effectthe listener itself
IntersectionObserver, ResizeObserverthe observer callback
A WebSocket's onmessage, assigned at connecteverything 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.

OneCeylon Engineering
Liked this?
We are hiring two people.

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 →
Also in the notebook
ENGINEERING August 2026 · 9 min read

Nobody ever got a push notification from us.

A green toggle, a real subscription, and a zero percent delivery rate. Another failure that returned 200.

RESEARCH April 2026 · 10 min read

Teaching SerendAI to read a Sri Lankan pharmacy sign at 9pm.

From 43% to 86% on real travel photos — and the preprocessing trick that finally moved the numbers.