[Bug]: Using clearNodes in the can() command will return incorrect results #5721
Labels
Category: Open Source
The issue or pull reuqest is related to the open source packages of Tiptap.
Type: Bug
The issue or pullrequest is related to a bug
Affected Packages
core, extension-bullet-list
Version(s)
2.8.0
Bug Description
My English isn't very good, so I use ChatGPT to help with translations. If there are any inaccuracies in my expressions, please let me know!
The
can()
command returns false when the command can actually be executed.2024-10-12.10.37.46.mov
I tried debugging and fixing this issue, but in the end, it seems like it's actually something that needs to be handled by tiptap.
I have some hypotheses, but I can't confirm if they are correct. They might introduce new issues or cause breaking changes.
There are several related issues in tiptap's issues section, which we will mention in the following explanation.
——————————————————————————————————————————————————————————————————
The most immediate issue occurs in the
clearNodes
commandIn the screen recording, we see that
editor.can().toggleHeading({ level: 1 })
andeditor.commands.toggleHeading({ level: 1 })
return different values, so let's first take a look at what's happening inside thetoggleHeading
command.toggleNode
callssetNode
, so let's directly take a look at setNode.The original code comments use
//
.The comments below are my own additions, marked with
////
for clarity.We made two markers, A and B. The state at marker A is referred to as stateA, and the state at marker B is referred to as stateB (updatedState). As expected, stateA and stateB should be different.
After switching the list item node to a regular paragraph node,
can().toggleHeading()
still returnsfalse
.This means that
clearNodes
did not execute as expected. Let's take a look at what happens insideclearNodes
.When the
dispatch
value is falsy (undefined
), it will returntrue
immediately.can()
,chain()
, andcommands
all come fromCommandManager
. When executing a command,dispatch
will be a function. However, ifcan()
is used to check whether a command is allowed to be executed,dispatch
will beundefined
.This means that when
editor.can().toggleHeading({ level: 1 })
is executed,clearNodes
does not make any changes totr
. Therefore, stateA and stateB are the same. At markers A and B, both the command and the parameters are identical. As a result, the outcome at marker B must also be the same as at marker A, which isfalse
.In other words,
clearNodes
did not perform any actions, leading to an incorrect final result for thecan()
command.Therefore, we should remove the
dispatch
check inclearNodes
and always allowtr
to update in order to obtain a new state.toggleBulletList
andtoggleOrderedList
encounter the same issue, which is present in thetoggleList
code.tiptap/packages/core/src/commands/toggleList.ts
Lines 122 to 128 in 4efd227
——————————————————————————————————————————————————————————————————
Directly removing the
dispatch
check is not the correct solution.Removing the
dispatch
check will introduce another issue, which are related to issue #3025.This issue is related to this segment of code in
clearNodes
:tiptap/packages/core/src/commands/clearNodes.ts
Lines 42 to 46 in 4efd227
Typically, we use paragraph nodes as the default node for the document, which is why tiptap sets the priority of paragraph nodes to 1000, while the default value of node priority is 100.
A paragraph node is a type of TextBlock node.
However, if
defaultType
is not aTextBlock
node, thensetNodeMarkType
will throw an error when checkingproseMirrorNode.validContent
. This is the issue exemplified in issue #3025, which can be found in the provided codesandbox. It has a custom node calledmycontainer
, which has a higher priority than paragraph nodes, making it the default node for the document. This custom node only allowsmyinline
(aTextBlock
node) as its child nodes.If the
node
in the codenode.type.isTextblock
is a paragraph (with inline child nodes), switching tomycontainer
will result in an error.Therefore, the code in
clearNodes
should include an additional check to ensure thatdefaultType
is also aTextBlock
before callingtr.setNodeMarkType
.Alternatively, we could attempt to find the first descendant node of
defaultType
that is of typeTextBlock
, recreate the node, and usetr.replaceRangeWith
to switch the node.I'm not sure if this approach is correct, but we definitely need to fix this issue.
——————————————————————————————————————————————————————————————————
Each command executed with
can()
should be wrapped in atry-catch
block. If an exception occurs, the subsequent command checks should be terminated (inchainCommand
), andfalse
should be returned.For issue #3025, it implemented a 'wrong' fix #3026.
This fix changes the
shouldDispatch
value fromundefined
(equivalent totrue
) tofalse
insideCommandManager.createCan
. After this fix was merged, it indeed resolved the issue ofcan().toggleBulletList()
throwing an exception. However, executingcommands.toggleBulletList()
still throws an exception.The root cause of this exception is triggered by the
clearNodes
command. WhenshouldDispatch
is changed tofalse
, thedispatch
incommandProps
becomesundefined
, which then hits the conditionif (!dispatch)
insideclearNodes
. As a result,can().toggleBulletList()
does not throw an error and returnstrue
, but the command still cannot be executed correctly.There are many commands that may produce exceptions, as we cannot guarantee that the code we write is free of bugs. However, we must ensure that executing can() does not throw exceptions. This is because we typically call
can()
during the render phase to determine whether the corresponding toolbar buttons should be disabled. For example, in React, if an exception is thrown during the render phase, it can cause the application to 'crash,' resulting in a poor user experience.Therefore, if the execution of a command throw an exception, then
can()
should always returnfalse
when checking that command. This is because executing that command can never yield any effect.In order to provide a better development experience, when an exception is caught, we should use
console
to notify the developer. Or emit a "commandError" event.——————————————————————————————————————————————————————————————————
For
chainCommand
,shouldDispatch
should always be set totrue
to ensure thatcommandProps.dispatch
is a function(a truth value) rather thanundefined
(a false value).Let's think about how we define a command. I will use pseudocode to illustrate.
Taking the previously mentioned
clearNodes
as an example, the editor should be allowed to perform this operation in any state. This is also why we can directly returntrue
whendispatch
isundefined
, skipping the subsequent operations ontr
. As a result, executingeditor.can().clearNodes()
will always returntrue
.However, in
chainCommand
, we cannot skip the operations ontr
.Because in
chainCommand
, we record multiple command operations sequentially on a singletr
. The later commands may depend on the earlier commands.For example, in the initial case of
stateA
andstateB
,stateB
depends on the state derived after executingclearNodes
onstateA
. However, when executingclearNodes
, we skipped the operations ontr
, resulting in no changes tostateA
. Consequently,stateB
is identical tostateA
, leading to an incorrect result.We may need to revert the code involved in this fix #3026.
——————————————————————————————————————————————————————————————————
Ultimately, regardless of the solution, the issues with
clearNodes
should be addressed:tr
.Browser Used
Chrome
Code Example URL
No response
Expected Behavior
The execution of
editor.can()
returns the correct result.Additional Context (Optional)
No response
Dependency Updates
The text was updated successfully, but these errors were encountered: