A curious case of ProseMirror inline nodeViews

Tuesday, October 15, 2024

Essential mini glossary:

  1. ProseMirror - a set of libraries for building rich text editors.
  2. Node - a ProseMirror document is a hierarchy of nodes, like the browser DOM. The document might be a "doc" with "paragraph" and "heading" as nodes, and "text" as a leaf node.
  3. NodeView - Another way to change how ProseMirror renders a node. Allows you to define a custom UI for a specific node.

Recently I was working on implementing a mentions feature in text fields at Chronicle. We use ProseMirror for all our text fields - excellent library - and we use @nytimes/react-prosemirror for the React component based nodeView integration.

After getting the bulk of the implementation done, I noticed an extremely weird issue.

Cursor, cursor - where do you land?

If you have created a mention with some existing text content before it, and then after creating you delete all the way from before the mention to the start of the line - it will insert a hard break at the last backspace keystroke.

WAT

That's surely some weird hot reload issue, I'll just reload.

...

It happens again.

WTF

WHAT IS THIS

GitHub maxxing (aaaaand discuss.prosemirror.net)

I frantically start mashing away at my poor keyboard. Within a minute I have a 10s of tabs open pointing to the ProseMirror forums, none of them even remotely match my issue, and I'm dreading that I have fallen into a contenteditable edge case.

The closest related forum post I found is the first discussion about inline nodes with content in ProseMirror itself. Funnily enough that did have the fix I needed, but it was impossible to connect it at the time.

Another related (ish) issue is this one about widget decorations, which matched the prize but not the participants.

At this point I've spent hours searching both ProseMirror forums and GitHub issues, and I gave up for the day and went to bed-

WAAAIIIT A GODDAMN SECOND. LINEAR HAS MENTIONS. I CAN DEVTOOL THEM. SURELY THEY HAVE SOLVED THIS.

A span (or two) of hope

I jump back up from bed and go to Linear. Find a comment field to destroy, and start typing.

At first I just mention myself (user mention), and it didn't have the issue. My heart drops. I check the elements panel, and everything seems the same as I have.

I do some thonking

Oh I can also mention issues, might as well try that.

An issue mention in Linear

I inspect the DOM.

Spans added by Linear before and after nodeViews

JAAAAACKPOOOOOT

I wrap my existing nodeView with those 2 spans containing ZeroWidthSpace, and yussss - the issue disappears.

Why exactly does this work? Since ProseMirror works on contenteditables it is likely that not having a proper position to land the cursor after deleting leads to ProseMirror (or, more likely, the browser) inserting a hard break. That is somewhere it can land.

These zero width spaces provide a similar functionality, but without any visual impact.

Thank you Linear. I go back to sleep.

The cause

I wake up and first thing I think of is why the hell did Linear's normal mentions not have this problem? What is different?

On comparing the DOM, I realised that the user mention did not have any flex children inside, while the issue mention, had its status logo + mention slug in a flex container.

So, as any person would, I googled an insane query.

Hmm I wonder what I'll get

Forum post ↗

Marijn Haverbeke, ProseMirror author says having flex inside inline nodes is a bad idea

Conclusion

Well, if you do need to have flex items inside your inline node(view)s, then now you know how to fix it. Have two spans surrounding your nodeView content, so that the browser can position your cursor there if there is a loss of content surrounding it.

Or else, if you can, just avoid it.

Bye.

Playground

Try the behaviour out in this embedded ProseMirror instance. The checbox adds/removes the extra spans needed (:has is goated)