Skip to content

fix(editor): don't let a title save echo drop typed characters - #2485

Open
julien-f wants to merge 1 commit into
docmost:mainfrom
julien-f:fix/title-editor-stale-echo
Open

fix(editor): don't let a title save echo drop typed characters#2485
julien-f wants to merge 1 commit into
docmost:mainfrom
julien-f:fix/title-editor-stale-echo

Conversation

@julien-f

@julien-f julien-f commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Typing a page title can silently drop characters and scramble the result.

This happens very often on our company instance, which has many users and a large number of pages — to the point that renaming a page is unreliable: characters vanish while typing, the title reverts to an older value mid-keystroke, and you end up having to fix the title up afterwards. It is much more likely the faster you type, and the higher the latency between the browser and the server.

Cause

The reconcile effect in TitleEditor treats the React Query cache as the source of truth and replaces the editor content whenever the cached title differs from it:

useEffect(() => {
  if (titleEditor && !titleEditor.isDestroyed && title !== titleEditor.getText()) {
    titleEditor.commands.setContent(title);
  }
}, [pageId, title, titleEditor]);

saveTitle already tries to avoid applying a stale response:

if (page.title !== titleEditor.getText()) return;
updatePageData(page);

but that check runs synchronously inside the mutation's .then(), while the effect only runs after React re-renders. A keystroke landing in that gap is destroyed:

T+0ms   save response arrives with title "foo bar"
        guard: response === editor text  -> passes -> updatePageData()
T+15ms  user types "x"                   -> editor is now one char ahead
T+20ms  effect: title !== getText()      -> setContent(title) -> "x" is gone

setContent also resets the selection, so the caret jumps to the start and subsequent keystrokes are inserted in the wrong place. That is what turns a single lost character into a visibly mangled title.

Higher latency means more save cycles per title, and therefore more of these windows — which is why a busy instance with real network latency hits it constantly while a local dev setup essentially never does.

Fix

Track the title we last pushed to the server, and skip the reconcile when the incoming value is our own echo — in that window the echo is always the stale side. Genuine remote renames and actual page switches still replace the editor content exactly as before.

Reproduction

Deterministic, by holding the /api/pages/update response and releasing it a fixed offset before the next keystroke (type d, wait for the debounced save to fire, release the response, then type X after offset ms; expected result …dX):

offset before after
10 ms ok ok
12 ms ctrl1dXctrl1d ok
15 ms ok ok
18 ms ctrl3dXctrl3d ok
20 ms ctrl4dXctrl4d ok

3 of 5 offsets lose the character before the change, 0 of 8 after (the previously failing offsets were re-run twice). The harness was validated by stashing the fix and confirming it still reproduces.

Regressions checked

  • Remote rename still adopted — renamed from a second tab through the UI; the first tab picks up the new title both when unfocused and when its title editor is focused but idle.
  • Page switching still syncs — 5 in-app navigations including revisiting an earlier page; every title rendered correctly.
  • Out-of-order save responses — still handled by the existing page.title !== titleEditor.getText() guard.
  • Typing latency in the title measured unchanged (p50/p90 16 ms, one frame) on both a small page and a 1536-block / 200 KB page with an active collab session.
  • tsc --noEmit clean.

Note: I deliberately used "is this our own echo?" rather than "is the editor focused?". A focus check would also block genuine remote renames while the cursor sits idle in the title, with no way to reconcile afterwards, since the effect would not re-run on blur.

🤖 Generated with Claude Code

The reconcile effect in TitleEditor treats the React Query cache as the
source of truth and replaces the editor content whenever the cached title
differs from it. saveTitle already guards against applying a stale
response, but that check runs synchronously in the mutation's `.then()`,
while the effect only runs after React re-renders. A keystroke landing in
that gap is destroyed:

  T+0ms   save response arrives with title "foo bar"
          guard: response === editor text -> passes -> updatePageData()
  T+15ms  user types "x"                  -> editor is one char ahead
  T+20ms  effect: title !== getText()     -> setContent(title), "x" is gone

setContent also resets the selection, so the caret jumps and subsequent
keystrokes are inserted in the wrong place, which is what turns a single
lost character into a visibly mangled title.

Track the title we last pushed to the server and skip the reconcile when
the incoming value is our own echo, since in that window the echo is
always the stale side. Genuine remote renames and page switches still
replace the editor content as before.

Reproduced deterministically by holding the /pages/update response and
releasing it a fixed offset before a keystroke: 3 of 5 offsets in the
10-20ms range lose the character before this change, 0 of 8 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@salihudickson salihudickson left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this up.

I think this works. But updating the title content immediately an update comes in also causes a similar issue.

Perhaps we can also check for local changes and only apply the update if none. @Philipinho, thoughts?

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.

2 participants