Keep the open preview editor mounted across offset-shifting remote CRDT changes - #618
Merged
Merged
Conversation
…te changes Fixes the GH #420 focus loss: an incoming CRDT change that shifted the active block's byte offsets rendered one frame against the stale editTarget.anchorR0, so the dispatcher's anchor match failed, the editor (rich tiptap surface or textarea) unmounted, and the P2.3b self-heal layout effect then mounted a fresh one — a focusout/focusin blip that dropped keystrokes, reset the caret to end-of-block, and (on the rich surface, which reseeds from the AST rather than editDraftRef) silently discarded the uncommitted draft. PreviewRoot now derives the re-anchored target during render (effectiveEditTarget: findReanchorCandidate over the freshly-parsed pool/content), feeds it to the context and editTargetRef, so the first post-change render already matches the shifted offsets and React reconciles the mounted editor in place. The self-heal effect still persists the re-anchor into state and still owns the DROP path (unchanged: a remote edit to the active block itself closes the editor). TDD: editor-survives-remote-shift.integration.test.tsx pins element identity + focus retention across forward and backward shifts for both surfaces (3 tests failed on the unfixed tree, all pass now). Verified end-to-end through q2 preview --allow-edit in Chrome: same ProseMirror instance, no focus events, caret and typed draft intact across two offset-shifting disk edits, and a subsequent Cmd-Enter commit landed on the correct paragraph. Follow-ups filed: bd-vrdglm6m (block insert/delete above still remounts via index keys), bd-yu5uj8am (merge instead of DROP for remote edits to the active block). Plan: claude-notes/plans/2026-08-26-gh420-editor-focus-crdt.md Closes #420. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #420 (braid bd-84ljmbaf).
Problem
With the rich-text editor open in
q2 preview --allow-edit(or hub-client), any remote CRDT change that shifts the active block's byte offsets — i.e. any length-changing edit anywhere earlier in the document — disturbed the editor:editDraftRefand only blips).Root cause
The first render after an external re-render still carried the stale
editTarget.anchorR0. The dispatcher's anchor match (editTarget.anchorR0 === resolved.sourceEntry.r[0]) then failed for every block, unmounting the editor for one frame; the P2.3b self-healuseLayoutEffectre-anchored one render too late and mounted a fresh editor. The re-anchor logic (findReanchorCandidate, content-first and symmetric) was fine — only its timing was wrong.Fix
PreviewRootnow derives the re-anchored target during render (effectiveEditTarget, auseMemorunningfindReanchorCandidateover the freshly-parsed pool/content) and feeds it to the context andeditTargetRef. The first post-change render already matches the shifted offsets, so React reconciles the mounted editor in place — same DOM node, focus never leaves, caret/selection/doc untouched. The self-heal effect still persists the re-anchor into state and still owns the DROP path (a remote edit to the active block itself closes the editor, unchanged).Testing
editor-survives-remote-shift.integration.test.tsx(realPreviewRoot, real pointer-event activation) pins element identity (toBe) anddocument.activeElementretention across forward and backward shifts, for the rich surface and the textarea (the textarea test also pins the dirty draft). Written first per TDD: 3 tests failed on the unfixed tree exactly on the remount assertions; all 4 pass with the fix.cargo xtask verify(including hub-clientbuild:all+test:ci): green.q2 preview --allow-editin Chrome — typed a draft into the rich editor, applied two offset-shifting edits to the file on disk (title grow, then shrink); the ProseMirror DOM instance survived both, zero focus events, caret and draft intact, and a subsequent Cmd-Enter commit landed on the correct paragraph with the remote edits preserved.Out of scope (follow-up strands filed)
key={i}shifts).Plan/diagnosis:
claude-notes/plans/2026-08-26-gh420-editor-focus-crdt.md.🤖 Generated with Claude Code