Skip to content

6.1.3 - toString() throws TypeError: child._stringify is not a function after replaceWith()` with an array (regression in 6.1.3) #321

Description

@Tam2

In 6.1.3, serializing a selector throws TypeError: child._stringify is not a function when the AST contains a node that was inserted by passing an array to replaceWith() / insertBefore() / insertAfter() (e.g. pseudo.replaceWith(pseudo.nodes)). The exact same code works in 6.1.2 and every earlier 6.x release.

This is a backward-incompatible change shipped in a patch release. It silently breaks Tailwind CSS v3, which uses pseudo.replaceWith(pseudo.nodes) to expand its :merge() pseudo-class — the mechanism behind every group-* and peer-* variant. The result is that Tailwind emits zero group-hover / peer-* CSS, with no error surfaced to the user (Tailwind catches and skips the failing selector).

Environment

Minimal reproduction

const parser = require('postcss-selector-parser');

const ast = parser().astSync(':merge(.group):hover .foo');

// Expand the :merge() pseudo by replacing it with its children.
// Note: an array is passed to replaceWith (not spread) — this is what Tailwind does.
ast.walkPseudos((p) => {
  if (p.value === ':merge') {
    p.replaceWith(p.nodes);
  }
});

console.log(ast.toString());

6.1.2 output:

.group:hover .foo

6.1.3 output:

TypeError: child._stringify is not a function
    at .../dist/selectors/container.js:304:20
    at Array.map (<anonymous>)
    at Selector._stringify (.../dist/selectors/container.js:303:17)
    at Selector.toString (.../dist/selectors/container.js:300:17)

Root cause

When an array is passed to replaceWith() (or insertBefore/insertAfter), the parser stores that array as a single nested element inside container.nodes. This has always been the case — the insertion code is byte-identical between 6.1.2 and 6.1.3:

// node tree after the repro, in BOTH 6.1.2 and 6.1.3:
selector.nodes -> [ Array(1), Pseudo(':hover'), Combinator(' '), ClassName('.foo') ]
//                  ^^^^^^^^ a raw Array, not a Node

The behavioural difference is entirely in serialization.

6.1.2 — dist/selectors/container.js:286 (works):

_proto.toString = function toString() {
  return this.map(String).join('');
};

String(child) on the nested Array invokes JavaScript's built-in Array.prototype.toString(), which recursively coerces each real node — so it happens to produce the correct string.

6.1.3 — dist/selectors/container.js:296,302 (throws):

_proto.toString = function toString(options = {}) {
  return this._stringify(options, 0, resolveMaxNestingDepth(options.maxNestingDepth));
};
_proto._stringify = function _stringify(options, depth, max) {
  return this.map(function (child) {
    return child._stringify(options, depth, max);   // assumes every child is a parser Node
  }).join('');
};

_stringify calls child._stringify(...) directly. The nested Array has no _stringify method, so it throws.

The new _stringify was introduced to thread the nesting-depth counter through serialization for the CVE-2026-9358 fix. The regression is an unintended side effect: the old path tolerated non-Node children via String() coercion; the new path does not.

Impact: Tailwind CSS v3 group-* / peer-* variants

Tailwind v3's lib/util/formatVariantSelector.js expands its :merge() pseudo like this:

selector.walkPseudos((p) => {
  if (p.value === MERGE) {     // MERGE === ':merge'
    p.replaceWith(p.nodes);    // array passed directly
  }
});

:merge() underpins all group-* and peer-* variants. With 6.1.3, finalizeSelector throws, Tailwind catches it per-candidate (generateRules.js catch {}) and drops the rule. The build succeeds but every group-hover:*, peer-checked:*, etc. silently produces no CSS. This is very hard to diagnose because plain hover: and other non-:merge() variants are unaffected.

Proposed fix

Make _stringify fall back to String(child) when a child does not implement _stringify, preserving the lenient behaviour of this.map(String):

_proto._stringify = function _stringify(options, depth, max) {
  return this.map(function (child) {
    return typeof child._stringify === 'function'
      ? child._stringify(options, depth, max)
      : String(child);
  }).join('');
};

Verified: with this change, the minimal reproduction above returns .group:hover .foo on 6.1.3, matching 6.1.2.

Alternative (or complementary) fix: have replaceWith / insertBefore / insertAfter flatten array arguments rather than storing them as a nested array element, so the tree never contains a non-Node child in the first place.

Workaround for affected users

Pin the dependency back to 6.1.2 until this is fixed, e.g. with pnpm:

// package.json
"pnpm": { "overrides": { "postcss-selector-parser": "6.1.2" } }

Or for Tailwind users specifically, the consumer-side fix is p.replaceWith(...p.nodes) (spread).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions