basecamp/trix · error · TypeError

a node selected for removal could not be detached from its t

Error message

a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place

What it means

During the DOM walk, when a node must be force-removed, DOMPurify calls remove(node) and then checks getParentNode(node). A detached, parentless root node cannot be removed via removeChild/remove (Element.remove() is a no-op without a parent), so DOMPurify cannot guarantee the node is actually detached from any live tree. Since the caller asked for in-place sanitization, returning a still-attached/unsafe node would be unsafe, so it throws this TypeError instead of returning anything.

Source

Thrown at action_text-trix/app/assets/javascripts/trix.js:2853

           .remove() is itself a spec no-op on a parentless node, so a recorded
           "removal" would otherwise hand the caller back an intact,
           payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or
           the style-with-element-child rule decided to kill). Fail closed by
           throwing — exactly as a clobbered root does at the IN_PLACE entry —
           rather than trying to "neutralize" the node via its own methods.
           Neutralizing would mean calling getAttributeNames()/removeAttribute()
           on the node, both of which a <form> root can clobber via a named child
           (and _isClobbered does not even probe getAttributeNames), so the
           neutralize step could itself be silently defeated, leaving the payload
           intact. A throw touches only the cached, clobber-safe remove() and
           getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)
           to every root-kill reason. REPORT-3.
                  This lives inside the catch, so it never fires for a normally-removed
           in-tree node: those have a parent, removeChild() succeeds, and the
           catch is not entered. Only a kept (parentless) root reaches here. */
        remove(node);
        if (!getParentNode(node)) {
          throw typeErrorCreate('a node selected for removal could not be detached from its tree ' + 'and cannot be safely returned; refusing to sanitize in place');
        }
      }
    };
    /**
     * _neutralizeRoot
     *
     * Fail-closed teardown of an in-place root after the sanitize walk aborts
     * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
     * custom element's reaction detaches a node so `_forceRemove`'s deliberate
     * parentless guard throws, or any other re-entrant engine mutation — would
     * otherwise leave the caller's *live* tree half-sanitized, with everything
     * after the abort point still carrying its handlers. There is no safe way
     * to resume the walk (the tree mutated under us), so we strip the root bare:
     * remove every child and every attribute, then let the caller's catch see
     * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
     * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
     *
     * @param root the in-place root to empty

View on GitHub (pinned to 4700401311)

Solutions

  1. Do not pass the problematic node as the in-place root; instead sanitize its serialized HTML: DOMPurify.sanitize(node.outerHTML) and rebuild the element from the result.
  2. Append the node to a temporary detached container with a parent before in-place sanitization, so removal can succeed.
  3. Check beforehand whether the root tag is allowed (ALLOWED_TAGS/ADD_TAGS) or the element is clobbered, and replace it before calling sanitize.
  4. Catch this TypeError and fall back to string-based sanitization.

Example fix

// before: detached root sanitized in place
DOMPurify.sanitize(el, { RETURN_DOM: true, IN_PLACE: true }); // throws if el must be removed
// after: sanitize via serialized HTML
const clean = DOMPurify.sanitize(el.outerHTML);
const safeEl = DOMPurify.sanitize(clean, { RETURN_DOM: true });
Defensive patterns

Strategy: validation

Validate before calling

if (root.parentNode === null && needsInPlaceSanitize) {
  // detached root: fall back to string sanitization
  const safeHtml = DOMPurify.sanitize(root.outerHTML);
}
// also pre-check the root tag is allowed:
const tag = root.tagName.toLowerCase();
if (!allowedTags.includes(tag)) { /* replace root before IN_PLACE sanitize */ }

Type guard

const isSafelyInPlaceSanitizable = (n) => n instanceof Node && n.parentNode !== null && typeof n.remove === 'function';

Try / catch

try {
  DOMPurify.sanitize(root, { IN_PLACE: true });
} catch (e) {
  if (String(e.message).includes('could not be detached')) {
    const safe = DOMPurify.sanitize(root.outerHTML); // fallback path
  } else { throw e; }
}

Prevention

When it happens

Trigger: Calling DOMPurify.sanitize(rootNode, { ... , RETURN_DOM: true } / IN_PLACE mode) where the root itself is selected for removal during the walk (root-kill) AND the node has no parent, so Element.prototype.remove() cannot detach it.

Common situations: Sanitizing a freshly created (not yet appended) element whose root element itself turns out to be forbidden/clobbered; passing a document fragment or detached element as the sanitize root in in-place mode; framework code that sanitizes a live node before mounting it.

Related errors


AI-assisted analysis of basecamp/trix@4700401311 (2026-09-02). Data as JSON: /api/errors/1180e690abbd48ca. Report an issue: GitHub.