Selection-based DOM transforms must not assume a fixed depth
TLDR
Any editor feature that operates on "whatever the user selected" is tempting to build against one assumed container depth: the page, the section, the outer wrapper. Real content is rarely that flat. The fix is to derive the operating scope from the selection's actual common ancestor, not from a guess about where content usually lives.
The problem class
Rich-text and structured-document editors all eventually need a feature shaped like: select some content, apply a transform to the set of blocks in that selection. Split into columns. Group into a list. Wrap in a container. Reorder.
The naive implementation reaches for the nearest well-known container, the page, the section, the article, and walks its direct children looking for whatever the user selected. This works in every test you write yourself, because you write test content that matches your mental model of the document.
It breaks the moment real content doesn't match that model, and the failure is silent: the feature just does nothing, with no error to chase.
Why it fails
A DOM tree's actual nesting depth is a function of how the content was produced, not a property you get to assume. Hand-authored HTML is often flat. Content produced by an extraction pipeline, a converter, or another editor is usually not. It carries wrapper elements for styling, positioning metadata, or grouping that has nothing to do with the semantic blocks a user thinks they're selecting.
I ran into this on a column-split control for a PDF-to-HTML editor: select paragraphs, choose a column count, the selected blocks redistribute into side-by-side columns. My first pass walked the page section's direct children, filtered to the ones the selection touched, and split those into groups. Straightforward, I thought.
It worked on every paragraph I typed by hand. It did nothing on a real PDF's cover page: a block of "Title of Each Class / Trading Symbol(s) / Name of Each Exchange" that the extraction pipeline had wrapped two levels deeper than my test content.
.pdf-page-content > .pdf-zone > .pdf-region > .f2.ta-l > <p> (x3)
The three paragraphs the user actually selected were never children of the page section. They were children of a positioning wrapper two levels down. My candidate list was always empty, and nothing told me that.
The better model
A Range object already knows the true shared parent of a selection. That's what commonAncestorContainer is. For a selection spanning three sibling <p> elements, the common ancestor is their actual parent, whatever that happens to be, without needing to know or guess the document's wrapper conventions.
function applyColumnSplit(cols) {
const range = savedSelectionRange;
let node = range.commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
// node's own children are the split candidates, whatever // container that turns out to be. let container = node.nodeType === Node.ELEMENT_NODE ? node : fallbackSurface; let candidates = [...container.children].filter(notLabel); let selected = candidates.filter(el => range.intersectsNode(el));
// Selection collapsed to one leaf with no sibling breadth? // Walk up one level and retry before giving up. if (selected.length < 2 && container.parentElement) { container = container.parentElement; candidates = [...container.children].filter(notLabel); selected = candidates.filter(el => range.intersectsNode(el)); }
if (selected.length < 2) return showReasonToast(selected.length); // ...split selected into N groups }
The one-level-up retry covers the case where the selection collapses onto a single leaf with no selected siblings at that exact depth (a caret sitting inside one paragraph, say) without hardcoding how many levels to climb in the general case.
Evidence
Tested against an actual SEC 10-K filing (a 209-page annual report). The three-line registrant block, nested inside a region wrapper the way real extracted content always is, split into three side-by-side columns correctly on the first working pass. The old implementation, run against the same selection, produced zero candidates and did nothing.
The tradeoff
This approach gives up predictability for correctness, and I'm still not entirely settled on the trade. You no longer know in advance which DOM level your transform operates on, since it depends on what got selected. That makes the feature harder to reason about from the outside and slightly harder to test, because "select this content" and "the transform applies at this depth" are no longer the same fact.
You also inherit an edge case: if a selection's common ancestor is deep and narrow (say, a single <strong> inside one paragraph), the one-level-up retry might still land somewhere that isn't semantically the user's intent. In practice this hasn't come up, but it's a real gap the fixed-depth version didn't have to worry about, because it was wrong in a much bigger, much more common way instead.
When the fixed-depth version is fine
If you control 100% of the content your editor will ever see, a schema you designed, enforced at every input boundary, with no import/paste/extraction path, a fixed assumed depth is simpler and the failure mode (silent no-op on malformed content) can't happen because malformed content can't get in.
The moment any content arrives from outside your own authoring flow, that assumption stops holding, and the selection itself is the only reliable source of truth for where it actually lives.