joinBackward behavior

The joinBackward command works fine for nodes with a content expression of paragraph+ (one or more) but fails for paragraph (one).

Can anyone tell me the reason why the command should behave differently for both cases?

The way joinBackward proceeds on nodes like blockquotes is that it first merges the adjacent blockquotes (which is valid, because the content from two such nodes also fits a single node), and then, on another backspace press, merges the textblocks.

With a content: "paragraph" node, the initial join isn’t possible (two paragraphs wouldn’t be valid content for such a node), which disrupts the way joinBackward works, causing it to fall back to first lift the textblock with the selection out of its parent, and then move the selection to the previous node.

Patch tries to make this somewhat better. Does it help for you? Since you never specified what you mean by “fail”, I’m not sure what you expect to happen in this situation.

Hey thanks for the patch but it still doesn’t work for me. But that’s because I think I misunderstood what the command is for.

I thought it is for transforming this


into this when the cursor is at the start of “two” and pressing “backspace”:


But actually it should do that, right?


But that’s not allowed in my schema because I want always just one paragraph in my list item. I try to find another solution.

I think I misunderstood what the command is for.

Then, given that I made the effort to reproduce your case and write a patch to improve the behavior, put a bit of work into trying to understand how it works.

When you say “still doesn’t work for me”, do you mean it doesn’t do anything in your setup, or that it takes two presses (first lifts the paragraph out of the block, second joins it onto the paragraph) before? I guess if your schema doesn’t allow paragraphs at the level of the single-paragraph blocks, it might indeed do nothing. (That would have been useful information to provide too.)

Okay here are some more information about my case: I try to implement a task list with task items. Within a task item there can only be one paragraph.

So basically this my schema:

doc: {
  content: 'taskList',
taskList: {
  content: 'taskItem+',
taskItem: {
  content: 'paragraph',

So when I try to join two task items, I would expect that the content of the paragraphs are joined together. But this happens instead:


I’m using the default backspace handler: chainCommands(deleteSelection, joinBackward, selectNodeBackward). When pressing backspace joinBackward returns false and the previous node gets selected. On another backspace the node is deleted.

It works when I change my schema to this so no paragraph is used at all:

taskItem: {
  content: 'inline*',

But at first I expected joinBackward to behave differently when using a single block node as content.

I see. I recommend adding a custom command for this for the time being, since I don’t have time to look for a further solution in joinBackward right now.

I’m also seeing undesired behaviour on Backspace when working with a schema restricting listItem contents to a single paragraph.

As I understand it, this stems from the logic in joinBackward which tests whether the contents of node B can be appended to the contents of node A (using node.canAppend()). If the schema only allows a single paragraph then the test for <li><p>one</p><p>two</p></li> will rightly fail.

I believe the expected behaviour in this case would be to merge the contents of the node’s child with the last child of the previous node. Note that this would only be allowed in the case when the node contains a single child. A sample function to test would be as follows:

// Test whether the given node's content could be merged with the content of this node.
function nodesCanMerge (a, b) {
  return a && b && !a.isLeaf &&
    a.childCount > 0 &&
    b.childCount === 1 &&

The final step would be to move the child contents and then delete the node:

if (nodesCanMerge(before, after)) {
  // move contents of node's child inside last child of previous node
  if (dispatch) {
      .delete($pos.pos, $pos.pos + after.nodeSize)
      .insert($pos.pos - 2, after.firstChild.content)
      .setSelection(Selection.findFrom($pos.pos - 2), 1))
  return true;

@marijn Do you see any issues with this approach? Would you be happy to update joinBackward with this additional logic? I can create a PR if that is helpful.

Could you see if this patch solves your issue?

(The code you proposed, by deleting and then re-inserting the content of the text, would work poorly with collaborative editing and other things that involve mapping positions. But fortunately there was existing code that almost did what you needed here, and used ReplaceAroundStep to avoid this problem.)

Thanks @marijn. That patch works perfectly.