transform() merges an unrelated rule's selector into another rule via an incorrect :is(...) descendant combinator

Author: philipugurgel-lgtmCreated Sep 16, 2026Updated Sep 16, 2026

Environment

  • lightningcss: 1.32.0 (npm)
  • Node: v24.14.1
  • OS: Windows 11 Enterprise 10.0.26200
  • Reached through Tailwind CSS v4.3.3's @tailwindcss/node optimizeCss() (@tailwindcss/vite plugin) — but confirmed independent of Tailwind, see Reproduction.

Summary

transform() can take a completely unrelated, later rule in a stylesheet and rewrite it so its selector is prefixed with a descendant combinator built from an unconnected earlier rule's selector list — changing what the rule matches, not just its specificity.

This standalone, top-level rule:

css
.scandticket-buy {
  max-width: 560px;
  margin: 0 auto;
  padding: var(--space-8) var(--space-4);
}

comes out as:

css
:is(.scandticket-me__event-filter,.scandticket-me button[data-modal-target],.st-button,.scandticket-me__event-header .scandticket-me__event-date,#me-logout,.scandticket-ticket-modal__close,.scandticket-ticket-modal__transfer,nav,header,footer,aside) .scandticket-buy{max-width:560px;padding:var(--space-8) var(--space-4);margin:0 auto}

The :is(...) group is the selector list of an entirely different, earlier rule elsewhere in the file (a @media print { … { display:none!important } } block, with a different declaration). The two rules share no declaration, selector, or nesting relationship. After the rewrite, .scandticket-buy's rule only applies when .scandticket-buy is a descendant of one of those unrelated elements — in the real page it never is, so the rule silently never matches.

Not limited to one rule: the same input produces the identical :is(...)-prefix corruption on at least three other, otherwise-unrelated selectors in the same file (.scandticket-buy__tier, .scandticket-seatmap__svg-wrap, and a bare body {...} rule that has no @media wrapper in the source at all).

Narrowed down (each tested independently, same input, same corrupted result unless noted):

  • Reproduces with minify: true and minify: false (confirmed by inspecting the pretty-printed, non-minified output directly — this is not a minification-only artifact).
  • Reproduces with targets set and with targets omitted entirely.
  • Reproduces with include: Features.Nesting | Features.MediaQueries, with include: 0, and with either flag alone.
  • Reproduces with nonStandard.deepSelectorCombinator: true and false — byte-identical output either way.
  • errorRecovery: true is required only because this real-world input contains an unrelated parse edge case elsewhere in the file (an Unexpected token Delim('*') in a comment/selector-list context); it is not related to this bug.

So: not a minify-only bug, not a targets/downleveling artifact, not deepSelectorCombinator. I have not identified which pass is responsible.

Reproduction

Repro bundle (input.css + repro.mjs): https://gist.github.com/philipugurgel-lgtm/8c3552e062040f8dc132782de08e026c

input.css (93,905 bytes) is real, valid, already-correct CSS — it is Tailwind CSS v4's own compiler output, captured before it is ever handed to lightningcss. It reproduces with no Tailwind or Vite code involved, only lightningcss.transform():

javascript
import { readFileSync } from 'node:fs';
import { transform, Features } from 'lightningcss';

const input = readFileSync('./input.css');

const result = transform({
  filename: 'input.css',
  code: input,
  minify: true,
  drafts: { customMedia: true },
  nonStandard: { deepSelectorCombinator: true },
  include: Features.Nesting | Features.MediaQueries,
  exclude: Features.LogicalProperties | Features.DirSelector | Features.LightDark,
  targets: {
    safari: (16 << 16) | 1024,
    ios_saf: (16 << 16) | 1024,
    firefox: 8388608,
    chrome: 7274496,
  },
  errorRecovery: true,
});

console.log(result.code.toString());

(Options copied verbatim from Tailwind v4's node_modules/@tailwindcss/node/dist/index.mjs — this is the exact call Tailwind makes. All of minify, targets, include, and nonStandard.deepSelectorCombinator were independently varied and none of them change whether the bug occurs — see "Narrowed down" above.)

Run with: npm install [email protected] && node repro.mjs

Expected behavior

.scandticket-buy (and the other affected selectors) should remain standalone top-level rules, matching exactly what they matched in the input.

Actual behavior

lightningcss prefixes them with an unrelated rule's :is(...) selector list via a descendant combinator, changing which elements the rule matches.

Possibly related

#1324, #1159, #1260, #1310 touch the same general selector-splitting/ merging area, though none matches this exact symptom, and unlike this issue, all four appear to require targets to be set — this one does not.

Source: parcel-bundler/lightningcss