fix(desktop): survive an unmounted Tiptap view in the formatting tray - #6754
fix(desktop): survive an unmounted Tiptap view in the formatting tray#6754micspiral wants to merge 1 commit into
Conversation
Clicking into a channel could leave the pane blank, with the console
showing:
[tiptap error]: The editor view is not available.
Cannot access view['dom']. The editor may not be mounted yet.
That string is a *thrown* Error, not a warning. Tiptap v3 returns a Proxy
from `editor.view` before the ProseMirror view is mounted, and the Proxy's
`get` trap throws for any key absent from its small target — including
`dom`.
`SelectionFormattingTray` read `editor.view.dom` directly inside a
`React.useEffect`. That effect runs on the same commit that mounts
`EditorContent`, so on a freshly mounted composer the view can still be
absent; the throw escaped the effect and React tore down the surrounding
subtree, which is why navigating into a channel appeared to do nothing.
Guard the access and retry on the next frame until the view exists,
matching the existing convention in the sibling hook
`useComposerSpoilerParticles`, which already wraps the same access in
try/catch with a rAF retry. Subscriptions now attach once the view is
real, and teardown cancels a pending retry as well as detaching
listeners.
Added `SelectionFormattingTrayUnmountedView.test.mjs`, which renders the
real component against a fake editor whose `view` reproduces Tiptap's
throwing Proxy (on a prototype getter, as the real `Editor` has it) and
then mounts on a later frame. Verified the two new mount tests fail
against `origin/main` with the exact error string above and pass with
this change.
Testing: `pnpm check`, `pnpm typecheck`, and `pnpm test`
(5455 passing, 0 failing).
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
|
Closing — wasn't asked for. Fix stays on the local branch until requested. |
|
@wesbillman Could you please review this standalone replacement for the Tiptap view-mount regression from #6722? It uses guarded |
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Blocking lifecycle issue: the new attach() is one-shot and never follows Tiptap's mount / unmount lifecycle. After the first successful probe, the effect remains bound to that view's DOM for as long as the editor prop identity is unchanged. Tiptap explicitly allows an Editor to unmount and later mount a replacement EditorView; those operations emit lifecycle events but do not replace the editor object.
On that transition, the old DOM keeps the tray handlers, the replacement DOM receives none, and the still-registered editor/window callbacks can run updatePosition(), which reads editor.view.domAtPos / coordsAtPos after the view has detached and can throw the same unavailable-view error again. I reproduced both failures against 19d9a917: mount → attach → unmount/remount leaves zero contextmenu handlers on the replacement view, and mount → detach → selectionUpdate throws from getSelectionRect.
Please track the mounted view generation (or subscribe to mount / unmount), detach from the old view, attach to the replacement, and ensure geometry reads cannot escape when the view disappears between scheduling and execution. Add coverage for detach-after-attach and remount; the current fake only covers initial unavailability and React component teardown.
Summary
Clicking into a channel could leave the pane blank, with the console showing:
That string is a thrown
Error, not a warning. Tiptap v3 returns a Proxy fromeditor.viewbefore the ProseMirror view is mounted, and the Proxy'sgettrap throws for any key absent from its small target — includingdom(@tiptap/core3.22.5).SelectionFormattingTrayreadeditor.view.domdirectly inside aReact.useEffect. That effect runs on the same commit that mountsEditorContent, so on a freshly mounted composer the view can still be absent; the throw escaped the effect and React tore down the surrounding subtree — which is why navigating into a channel appeared to do nothing.The unguarded access was introduced by #6683 (
2f13e30e8). The sibling hookuseComposerSpoilerParticles.tsperforms the same access and already wraps it intry/catchwith a rAF retry, so the codebase already treats this as throwable; #6683 did not.Changes
SelectionFormattingTray.tsx: guardeditor.view.domand retry on the next frame until the view exists, matching theuseComposerSpoilerParticlesconvention. Subscriptions attach once the view is real; teardown cancels a pending retry as well as detaching listeners.SelectionFormattingTrayUnmountedView.test.mjs: renders the real component against a fake editor whoseviewreproduces Tiptap's throwing Proxy — defined as a prototype getter, as the realEditorhas it, so React's dev-mode effect logging doesn't trip it — then mounts on a later frame.Testing
origin/main(restored viagit show origin/main:<path>) with the exact error string above, and pass with this change. The third test (teardown) passes either way by design.pnpm check,pnpm typecheck,pnpm test— 5455 passing, 0 failing.file-size-check,desktop-check,desktop-typecheck,desktop-test,branch-skew.Notes for review
cancelledflag pluscancelAnimationFrame, so it cannot outlive the effect.