docs/solutions/logic-errors/2026-04-08-suggestion-delete-backward-must-mark-inline-voids.md
deleteBackward must mark inline voidsIn suggestion mode, pressing backspace after a mention first did nothing, and after the initial range fix it still behaved like a character delete instead of an inline-void delete.
That caused seven user-visible bugs around deletion suggestions: adjacent text could be marked for removal, the cursor could jump past an inline void's left edge, continued deletion could fork into a second suggestion card, expanded selections could stop at the inline void instead of consuming the whole selected range, replace-selection flows could loop forever, paragraph-boundary deletes could skip the red line-break suggestion shape entirely, and solitary block voids could be mislabeled as line breaks.
withSuggestion.spec.tsx failed because the mention node never received suggestion metadatabefore <anchor>text <mention> after<focus> text left text outside the remove suggestion and collapsed the cursor at the mention edgeeditor.api.string(range) as the only signal for whether a delete range had meaningful contentKeep the empty-string early exit only for ranges that are both string-empty and inline-node-free.
const inlineRange = reverse
? { anchor: pointTarget, focus: pointCurrent }
: { anchor: pointCurrent, focus: pointTarget };
const str = editor.api.string(inlineRange);
const hasInlineNode = editor.api.some({
at: inlineRange,
match: (n) => ElementApi.isElement(n) && editor.api.isInline(n),
});
if (str.length === 0 && !hasInlineNode) break;
Then special-case inline void deletion so it behaves like one semantic unit instead of a character loop:
const inlineVoidEntry = editor.api.void({
at: pointNext,
mode: 'highest',
});
if (inlineVoidEntry && editor.api.isInline(inlineVoidEntry[0])) {
editor.tf.setNodes(
{
[getSuggestionKey(id)]: { createdAt, id, type: 'remove', userId },
suggestion: true,
},
{ at: inlineVoidEntry[1] }
);
const beforeInlineVoid = editor.api.before(pointNext);
if (beforeInlineVoid) {
editor.tf.select(beforeInlineVoid);
}
break;
}
For expanded selections, continue the delete loop after marking the inline void when the original target point is still outside that inline element.
When deleting backward, keep walking from the inline void's left edge:
const beforeInlineElement = editor.api.before(inlineEntry[1]);
const targetIsInsideInlineElement =
PathApi.equals(inlineEntry[1], pointTarget.path) ||
PathApi.isAncestor(inlineEntry[1], pointTarget.path);
if (beforeInlineElement) {
editor.tf.select(beforeInlineElement);
if (!targetIsInsideInlineElement) {
continue;
}
}
break;
When deleting forward, advance past the inline void instead of reselecting its left edge:
const afterInlineElement = editor.api.after(inlineEntry[1]);
if (afterInlineElement) {
editor.tf.select(afterInlineElement);
if (!PointApi.equals(afterInlineElement, pointTarget)) {
continue;
}
}
Also teach findSuggestionProps to reuse remove metadata from adjacent inline suggestion elements when there is no adjacent text suggestion:
const getInlineElementEntry = (point: Point) =>
editor.api.above<TElement>({
at: point,
match: (node) =>
ElementApi.isElement(node) &&
editor.api.isInline(node) &&
!!api.suggestion.nodeId(node),
});
Use that fallback for nextPoint and prevPoint before generating a fresh suggestion id.
For paragraph-boundary deletes, keep the cross-block suggestion shape aligned with the existing merge semantics. Mergeable text blocks should still use the dedicated line-break suggestion, but solitary block voids should stay ordinary block removals:
const isPreviousBlockVoid =
editor.api.isVoid(previousAboveNode[0]) &&
!editor.api.isInline(previousAboveNode[0]);
editor.tf.setNodes(
{
[KEYS.suggestion]: {
id,
createdAt,
type: 'remove',
userId,
...(isPreviousBlockVoid ? {} : { isLineBreak: true }),
},
},
{ at: previousAboveNode[1] }
);
Add regression coverage with a mention-shaped inline void in:
packages/suggestion/src/lib/withSuggestion.spec.tsxpackages/suggestion/src/lib/queries/findSuggestionProps.spec.tsinsertText(...) runs over that same expanded inline-void rangeMention nodes are inline voids, so the delete range around them can be semantically deletable even when its string representation is empty.
Links still passed before the fix because their inline text made editor.api.string(range) non-empty. Mentions failed because the same range contained an inline node but no string content.
By checking for inline nodes before bailing out, the delete path no longer skips string-empty inline voids like mentions.
By then treating the inline void as a single deletion target, the transform avoids a second loop iteration that would otherwise spill remove marks into neighboring text nodes. Selecting editor.api.before(pointNext) places the cursor exactly at the mention's left edge.
Expanded selection deletes need one extra rule: stopping after the inline void is only correct when the original delete target lives inside that inline element. When the original selection started farther left, the transform must keep walking after marking the inline void so the remaining selected text becomes part of the same remove suggestion and the cursor collapses back to the true selection start.
The same ownership rule applies in the forward direction. If the loop reselects the inline void's left edge while the real target is farther right, the next iteration finds the same inline void again and never makes progress. Advancing to editor.api.after(inlineEntry[1]) breaks that cycle and lets replace-selection flows finish.
By reusing adjacent inline suggestion metadata in findSuggestionProps, the next backspace keeps extending the same remove suggestion instead of starting a new discussion card for the neighboring text.
By tagging only mergeable cross-block removes with isLineBreak: true, the existing acceptSuggestion and rejectSuggestion logic still takes the paragraph-merge path where it should. Block voids such as images or table-of-contents nodes stay ordinary block removals, so accepting them deletes the void instead of trying to merge through it.
These checks passed:
bun test packages/suggestion/src/lib/withSuggestion.spec.tsx
bun test packages/suggestion/src/lib/queries/findSuggestionProps.spec.ts
bun test apps/www/src/registry/ui/block-discussion-index.spec.tsx
pnpm install
pnpm turbo build --filter=./packages/suggestion --filter=./apps/www
pnpm turbo typecheck --filter=./packages/suggestion --filter=./apps/www
pnpm lint:fix
editor.api.string(range) as the only proxy for deletability when inline void or markable void nodes are involveddocs/solutions/logic-errors/2026-04-05-reject-suggestion-must-clear-inline-element-metadata.md