Users of techdiagrams.net started reporting something unsettling: text they typed on the canvas was disappearing. Sometimes a note, sometimes an arrow label. No error, no crash, no pattern anyone could name. Just — gone.
Bugs with no reproduction steps are the worst kind, so let me save you the two hours and show you the whole thing in two snippets.
The setup
Our canvas has an inline text editor: double-click an arrow, a little input appears, you type the label. It saves on blur — the classic pattern:
<input
autoFocus
defaultValue={editor.value}
onBlur={(e) => commitAndClose(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur(); // blur triggers the save
}}
/>Press Enter → blur fires → text saved. Click somewhere else → blur fires → text saved. This pattern is everywhere, and it works — until something else closes the editor.
The trap
Every click target on our canvas — components, arrows, empty space — began its pointer handling the same way:
const onPointerDown = (e: React.PointerEvent) => {
useUi.getState().setEditing(null); // close any open inline editor
// ...selection, dragging, etc.
};Looks harmless. It's the bug.
setEditing(null) unmounts the input component. And here's the part that isn't in most React mental models: an unmounted input never fires blur. The DOM element is removed from the tree before the browser's focus machinery gets a chance to run. React does not synthesize a farewell blur event for you.
So the sequence on "type a label, then click a component" was:
pointerdownon the componentsetEditing(null)→ React re-renders → the<input>is removed from the DOM- The browser would have fired
blur… on an element that no longer exists commitAndClosenever runs. The text evaporates.
No error anywhere, because nothing failed — every function did exactly what it was told. The save handler was simply attached to an element that stopped existing between two lines of code.
The fix
Force the blur before the state update that unmounts:
export function commitInlineEdit(): void {
const el = document.activeElement;
if (el instanceof HTMLElement && el.classList.contains("rename-input")) {
el.blur(); // dispatches blur SYNCHRONOUSLY → the save handler runs
}
useUi.getState().setEditing(null); // now unmounting is harmless
}The key property: HTMLElement.blur() dispatches its event synchronously. By the time the next line runs, the save has already happened. Then the unmount removes an input nobody is focused on, and nobody cares.
Every canvas pointer handler now calls commitInlineEdit() instead of touching the editing state directly. One shared function, one invariant: nothing may unmount the editor without saving it first.
The lesson that generalizes
"Save on blur" is a contract with the DOM. Unmounting breaks that contract silently — there is no warning, no error boundary, no strict-mode complaint. If any code path in your app can remove a focused input from the tree (a route change, a modal close, an escape-hatch state reset, a websocket message re-rendering a list), your blur handler is a lie waiting to happen.
Three ways to hold the invariant, pick per situation:
- Force-blur before unmount (our fix) — best when the closers are centralized.
- Save on change, debounced — best when partial saves are acceptable; blur becomes a no-op.
- Save in a cleanup effect —
useEffect(() => () => save(ref.current.value))— works, but be careful: by cleanup time, props you close over may already be stale.
We shipped option 1, and the "my text disappeared" reports stopped that day.
techdiagrams.net is a free architecture diagram editor where diagrams actually run — you can simulate crashes, timeouts and load against your design before building it. Try it, no login needed.