document.execCommand('indent') silently does nothing outside a list
TLDR: document.execCommand('indent') only has defined behavior inside <li> elements, where it nests a new <ul>/<ol>. On a plain paragraph, most browsers just do nothing: no error, no return value indicating failure, no visual change. The fix is to detect the context first and fall back to inserting a literal character for the non-list case.
The bug class
execCommand is a grab-bag of rich-text operations inherited from early contenteditable implementations, and its behavior is defined per-command, per-context, with no consistent way to ask "will this actually do anything if I call it right now." Some commands, like bold or italic, apply cleanly to any selection. Others, like indent, only have a well-defined transform for one specific structural context: a list item, where "indent" unambiguously means "nest one level deeper."
Call indent on a selection that isn't inside a list, and there's no block for it to nest into. The command doesn't throw. It doesn't return false in any browser I tested. It just returns, having done nothing, and the calling code has no signal that anything went wrong.
This is a bug class any time you wire a UI control directly to an execCommand name and assume the command's behavior generalizes past the one context its browser implementation actually handles.
Why the API produces it
execCommand predates any serious specification effort around contenteditable. It's a set of commands each browser vendor implemented against their own DOM internals, later loosely standardized after the fact. "Indent" only has one unambiguous DOM transform: list nesting. Outside a list, "indent a paragraph" isn't a single well-defined operation. Add margin? Insert a blockquote? A leading character? The spec doesn't pick one, and unpicked behavior in this API family defaults to no-op rather than an error.
The result is an API surface where success and "not applicable here" look identical from the caller's side. You only find out by testing every context the button might be clicked in, and it's easy to only test the one you had in mind when you wired it up, which for an "indent" button is naturally a list. That's exactly what I did the first time.
The fix
Detect the context explicitly before deciding how to indent, and use a manual DOM approach for the case execCommand doesn't cover:
function increaseIndent() {
const node = closestElementFromSelection();
if (node?.closest('li')) { // execCommand has real, well-defined behavior here. document.execCommand('indent'); return; }
// Outside a list, there's nothing for execCommand to indent into. // Insert a literal tab character instead (the same byte a TSV // parser would split a column on), wrapped so it actually renders // as a gap instead of collapsing like normal HTML whitespace. const range = getCurrentRange(); const tabSpan = document.createElement('span'); tabSpan.className = 'tab-char'; tabSpan.textContent = '\t'; range.collapse(true); range.insertNode(tabSpan); }
.tab-char {
white-space: pre; / \t collapses to one space otherwise /
}
The white-space: pre detail matters as much as the context check. A raw \t text node, inserted anywhere in normal HTML flow, renders as a single collapsed space. HTML's default whitespace handling treats tabs, newlines, and repeated spaces as equivalent to one space. Scoping white-space: pre to just the inserted span preserves the tab's width without switching the surrounding paragraph to preformatted text and breaking its normal line-wrapping.
Preventing the class
Two checks generalize past this one command. First: when docs describe an API's behavior "in the context of X," assume it's undefined or no-op outside X until tested, don't assume graceful degradation. Second: for any UI control wired to execCommand, click it in every structural context a cursor could plausibly be in (paragraph, heading, list item, table cell, empty document) before shipping. The command's silence gives you no other way to find the gaps.
The lesson
An API that returns successfully without describing what it did is not more forgiving than one that throws. It's just failing somewhere you have to go looking for it instead of somewhere it finds you.