transform() merges an unrelated rule's selector into another rule via an incorrect :is(...) descendant combinator
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/nodeoptimizeCss()(@tailwindcss/viteplugin) — 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:
.scandticket-buy {
max-width: 560px;
margin: 0 auto;
padding: var(--space-8) var(--space-4);
}comes out as:
: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: trueandminify: false(confirmed by inspecting the pretty-printed, non-minified output directly — this is not a minification-only artifact). - Reproduces with
targetsset and withtargetsomitted entirely. - Reproduces with
include: Features.Nesting | Features.MediaQueries, withinclude: 0, and with either flag alone. - Reproduces with
nonStandard.deepSelectorCombinator: trueandfalse— byte-identical output either way. errorRecovery: trueis required only because this real-world input contains an unrelated parse edge case elsewhere in the file (anUnexpected 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():
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