Stylus 嵌套选择器的逗号为什么会编译错
把两组选择器合到 :is() 里,再往下写一层嵌套,本来是很自然的整理方式。但在 Stylus 0.64.0 中,这次整理可能改变最终 CSS 的含义:一个分支的直接子元素不再命中,另一个分支仍然正常,构建过程却没有报错。
这个问题容易被误判成 CSS 权重、动态 class 或 DOM 结构变化。它的特殊之处在于,产物甚至可以是合法 CSS;浏览器忠实执行了产物,只是产物已经不再表达源码原来的意思。
下面用一个不依赖框架的例子,把输入、parser 中的分组、最终选择器和实际命中结果对齐。分析基线为 2026 年 10 月 28 日核对的 npm latest Stylus 0.64.0,Node.js 26.12.0;浏览器实验使用隔离、无扩展的 Edge 156.0.4314.42。后半部分继续给出源码修复与回归结果;该补丁目前保留在本地 fork,尚未进入上游或 npm 发布包。
一层嵌套怎样改变了选择器
原始 Stylus 只有三行:
:is(.a, .b)
.item
display: none
意图是隐藏 .a 或 .b 里面的 .item,预期产物为:
:is(.a, .b) .item {
display: none;
}
实际调用 stylus.render(),0.64.0 输出:
:is(.a .item,
.b) .item {
display: none;
}
去掉用于排版的换行,变化更容易辨认:
/* 预期 */
:is(.a, .b) .item
/* 实际 */
:is(.a .item, .b) .item
第一个分支被多塞进了一次 .item。对于下面两个节点,原规则本来都应该命中:
<div class="a">
<span class="item" id="a-direct">A</span>
</div>
<div class="b">
<span class="item" id="b-direct">B</span>
</div>
实际产物却只命中 b-direct。a-direct 需要找到一个匹配 :is(.a .item, .b) 的祖先;它的祖先只有 .a,既不匹配 .a .item,也不匹配 .b,所以整条规则不成立。
如果 .a 里恰好还有一层 .item,更深的 .item 又能命中。于是页面上就会出现「同类元素有的生效、有的不生效」的现象。

这张截图来自真实浏览器实验。为了让命中的节点仍然可见,演示把声明改成绿色背景,保留三列各自的选择器;绿色由 CSS 实际应用,下面的列表由浏览器 matches() 实际读取。打开交互实验可以查看完整 DOM 与选择器。
逗号为什么不能直接当作分组边界
CSS 里的逗号出现在不同层级,作用并不一样:
/* 规则最外层:两个完整选择器共用声明。 */
.a .item, .b .item { display: none; }
/* 函数内部:参数列表属于同一个完整选择器。 */
:is(.a, .b) .item { display: none; }
:is() 接受一个选择器列表,只要其中任一分支能匹配候选元素,就满足这部分条件。后面的 .item 仍然处于函数外。这个边界由括号决定,不能只看见逗号就把字符串分成两个独立的父选择器。Selectors Level 4 的逻辑组合伪类规则定义了这种参数与匹配关系。
这里还同时存在两种「嵌套」:Stylus 的缩进表示预处理器要组合父、子选择器,:is() 的括号表示 CSS 选择器内部的参数。预处理器需要先识别完整的函数边界,才能正确展开外面的缩进关系。
parser 已经把一个选择器拆成两段
Stylus 处理样式时,大致会先解析源码,得到表示选择器和声明的节点,再求值、规范化,最后组合选择器并输出 CSS。表示这些语法结构的节点通常称为抽象语法树(AST);这里关心的是一个选择器组究竟保存了哪些项。
直接检查 Parser.parse() 的结果,最小例子的父选择器组已经是:
{
"parserGroup": [":is(.a", ".b)"],
"childGroup": [".item"]
}
这份结果采自 parser 刚完成的 AST,并未经过 normalizer。":is(.a" 和 ".b)" 各自都不完整,却已经被当作两个可以独立参与嵌套展开的父选择器保存了。
selector() 怎样消费逗号
关键入口是 0.64.0 对应提交中的 Parser.selector()。下面保留完整方法:
// stylus/lib/parser.js:1607-1636
selector() {
var arr
, group = new nodes.Group
, scope = this.selectorScope
, isRoot = 'root' == this.currentState()
, selector;
do {
// Clobber newline after ,
this.accept('newline');
arr = this.selectorParts();
// Push the selector
if (isRoot && scope) arr.unshift(new nodes.Literal(scope + ' '));
if (arr.length) {
selector = new nodes.Selector(arr);
selector.lineno = arr[0].lineno;
selector.column = arr[0].column;
group.push(selector);
}
} while (this.accept(',') || this.accept('newline'));
if ('selector-parts' == this.currentState()) return group.nodes;
this.state.push('selector');
group.block = this.block(group);
this.state.pop();
return group;
}
这个方法反复读取 selectorParts(),遇到逗号就开始组内下一项。问题在于,selectorParts() 的函数 token 分支只是把函数名和左括号追加为字面量;这个选择器读取过程没有在本例中把括号内的逗号保护到同一项里。
因此,读到 :is(.a 后,逗号结束当前项,selector() 又从 .b) 开始下一项。AST 的实际结果比「源码里存在一个 split(',')」更直接地说明了首次出错的位置。
子选择器被分别附到了两段上
后续的 utils.compileSelectors()会遍历不同层的选择器组,将需要继承的子选择器与父选择器组合。这本来正是 Stylus 支持嵌套的机制。
但它接到的父组已经是两个片段,所以 .item 分别被接到 :is(.a 和 .b) 后面,再由 compiler 用逗号连接。括号在最终字符串中又恰好闭合,最终得到的 CSS 仍然能被浏览器解析。
这解释了构建为什么可能成功。编译器完成了它手里那份结构的展开,浏览器也接受了结果;真正丢失的是原输入里「这两个分支共同属于 :is() 参数」的结构。
normalizer 还有另一条按逗号拆分的路径
在 Normalizer.visitGroup()中,还能看到处理插值后选择器的代码:
// stylus/lib/visitor/normalizer.js:246-253,选择器规范化中的节选
if (!~selector.val.indexOf(',')) return;
if (~selector.val.indexOf('\\,')) {
selector.val = selector.val.replace(/\\,/g, ',');
return;
}
parts = selector.val.split(',');
~indexOf(...) 是这段旧代码判断是否找到子串的写法。这里确实也没有在普通拆分分支中识别括号或引号,因此插值产生的完整选择器还可能在这个阶段遭遇类似问题。
不过,对于本文直接写在源码里的最小例子,进入该阶段的两项已经分别是 :is(.a、.b),每项连逗号都没有了。把本例全部归因于 normalizer 的这一行,就会把修复位置找晚一层。
哪些写法会触发,哪些只是看起来相似
在同一版本中,下面几种输入的结果并不相同:
| 输入形态 | 0.64.0 的观察结果 | 原因或边界 |
|---|---|---|
根层 :is(.a, .b),直接写声明 |
本例含义保持正确 | 两段重新用逗号连接后,恰好还是原选择器 |
根层 :is(.a, .b),下面嵌套 .item |
错误 | 子选择器被接到两个不完整片段上 |
一行写完 :is(.a, .b) .item,直接写声明 |
本例正确 | 避免了本例出错的下一层组合 |
.scope 下嵌套 :is(.a, .b) |
产物结构改变 | 父级前缀也可能被拼进函数的某个分支 |
:where(.a, .b) 下嵌套 .item |
出现同类错误 | 本次也实际运行了这个变体 |
第四项的实际输出是:
.scope :is(.a,
.scope .b) {
display: none;
}
这个产物的结构和权重都需要重新判断,不能因为眼前 DOM 恰好仍然命中,就认为编译保持了语义。上述结果也不意味着所有 :is() 都不能用;本次缺陷集中在语法分组与后续嵌套组合的边界。
完整输入、输出与 parser 记录见编译与浏览器复现记录弹窗。
改写选择器时还要保护权重
错误产物除了漏选,也改变了权重。按 (ID, 类/属性/伪类, 元素) 记法:
/* :is() 取参数里最高的权重,再加外面的 .item。 */
:is(.a, .b) .item /* (0, 2, 0) */
:is(.a .item, .b) .item /* (0, 3, 0) */
第二条里,:is() 内部最高的是 .a .item。即便实际通过 .b 分支命中,整个 :is() 也仍采用参数里的最高权重。因此,.b 下的元素虽然没漏选,参与层叠时也已经和原规则不同了。MDN 的 :is() 权重说明给出了同一规则。
这也约束了规避方案。:is(#hero, .panel) .item 的权重是 (1, 1, 0);若拆成普通列表,.panel .item 分支只有 (0, 2, 0)。它们可能选中同一批元素,覆盖其他规则的能力却不同。:where() 的参数权重为零,直接展开更不能默认等价。
几种实际验证过的规避方式
选择方案时需要同时保留命中范围和权重。下面几种输入都在 0.64.0 中运行过,适合按现有样式的复杂程度选择。
同权重分支用普通分组
本例的 .a、.b 都是单个类选择器,可以使用 Stylus 本来就擅长的分组:
.a
.b
.item
display: none
输出为:
.a .item,
.b .item {
display: none;
}
这次改写保持了本例的命中范围与权重,也便于后续增加分支。如果参数里混有 ID、:where() 或其他复杂条件,先核对权重,再决定是否展开。
需要保留函数语义时使用原样 CSS
Stylus 官方提供 @css 字面量,可以把一段 CSS 原样输出:
@css {
:is(.a, .b) .item { display: none; }
}
本次验证中,这段输入保留了正确的选择器。它适合少量现代选择器的局部兼容处理,代价是这段内容按原样 CSS 维护,不能继续假定内部享有 Stylus 的变量求值或缩进嵌套能力。
如果项目已经支持直接导入 CSS,也可以把完整规则放进普通 CSS 文件。最终仍需查看打包后的规则,确认其他插件没有再次改变它。
将完整选择器写在同一行
:is(.a, .b) .item
display: none
这个最小输入在 0.64.0 中也输出正确。它的优点是保留 :is(),改动很小;限制是当前只验证了这个具体输入。以后再给它增加外层作用域或下一层嵌套时,需要重新检查产物。
反斜杠方案需要区分源码与运行时字符串
上游讨论里有利用转义逗号绕过拆分的办法。本次额外比较了 .styl 内容里实际放入一个反斜杠和三个反斜杠的情况:
.styl 源内容 |
本例结果 |
|---|---|
:is(.a\, .b) |
仍输出错误的嵌套选择器 |
:is(.a\\\, .b) |
输出 :is(.a,.b) .item |
三个反斜杠的结果与 issue #2750 的相关评论一致,但不建议把它作为团队默认写法。它依赖预处理器自身的转义过程;如果外面再包一层 JavaScript 字符串,还要额外区分 JS 转义和 .styl 最终收到的字符,维护成本明显高于普通分组或 @css。
上游 issue 和 PR 到了哪一步
这次复现能直接对应到 issue #2750:Wrong compiling for :is, :has, :where。它于 2023 年 1 月提交,报告的就是函数式选择器下嵌套 a 后,子选择器被拼进参数分支的现象。截至 2026 年 10 月 28 日,该 issue 仍为 open。
几项容易混在一起的记录,需要分别看:
| 记录 | 本次核对状态 | 它实际处理或报告的内容 |
|---|---|---|
| issue #2581 | closed,2022-05-26 关闭 | 较早的 :is(a,b) 解析问题 |
| PR #2684 | merged,2022-05-26 合并 | 将 is、has、where 加入 CSS 伪类识别名单,关联上述 issue 等问题 |
| issue #2750 | open | 本文直接对应的嵌套组合错误,链接见本节开头 |
| PR #2650 | open,未合并 | 尝试让 normalizer 避开引号与括号内的逗号,示例为含 RGB 字符串的属性选择器 |
PR #2684 的标题写着支持 CSS Level 4 逻辑组合选择器,但它的核心改动是识别名单,并没有因此保证所有嵌套组合都正确。其讨论里后来也直接链接了 #2750。
PR #2650 与逗号边界有关,不过它修改的是 normalizer。本文的 parser 记录已经证明,直接书写的最小输入在更早阶段就被拆开了,不能把这个候选 PR 当作本例已经解决的依据。本次没有对该 PR 分支做完整测试。
相关状态、关键 diff 与讨论链接保存在上游核对快照弹窗中。快照只代表核对当天的状态;issue 关闭、PR 合并和修复进入 npm 发布包,也仍是三个需要分别确认的事实。
分别修好源码解析与插值展开
前面的 @css、普通分组与转义写法适用于继续使用已发布版本的项目。要让原始 Stylus 写法直接工作,修复就需要同时覆盖 parser 和 normalizer:只改 parser,插值生成的字符串还会被 normalizer 拆错;只改 normalizer,直接写在源码里的参数又已经在更早阶段丢失了边界。
这份本地补丁以 dev 分支提交 2e11b972a56d6e41c1408254e60e3d6fb76356d8 为基线,保留既有测试方式,没有增加运行时依赖。完整补丁包含三处实现改动、三组新增 .styl / .css 回归样本,以及已有伪类样本的预期格式调整。
图中的两个入口共享后续嵌套展开,但输入来源不同。它们都先保留完整的 :is(.a, .b),.item 才会被附加在函数外面。
在 token 层记录括号深度
parser 已经有词法分析器,可以知道一个 token 是函数开头、括号还是字符串。修复在 selectorParts() 内增加局部计数器:遇到 function 或 ( 增加深度,遇到 ) 减少深度;深度大于零时,允许读取逗号作为当前选择器的一部分。
补丁中的关键结构如下,省略原有的 token 到字面量转换分支:
// stylus/lib/parser.js:本地补丁中的 selectorParts() 节选
selectorParts() {
var tok
, arr = []
, parens = 0;
while (tok = this.selectorToken() || (parens && this.accept(','))) {
// ...省略原有 debug 输出
if ('function' == tok.type || '(' == tok.type) {
++parens;
} else if (')' == tok.type && parens) {
--parens;
}
// ...保留原有 token 映射,将内容追加到 arr
}
return arr;
}
这样,:is(.a, .b) 的逗号会留在当前项里,函数结束后出现的顶层逗号仍交给 selector() 开始下一项。:not(.a, :has(> .b, + .c)) 有多层函数,计数器也会随对应的开、闭括号变化。
计数只针对 token 类型,字符串 token 内部的 ) 不会错误地关闭函数。比如 :is([data-value="),x"], .b) 中的引号、右括号和逗号,仍属于属性值。插值表达式的求值与原有字面量转换也保持原来的处理方式。
normalizer 扫描字符串里的真正分组边界
插值求值之后拿到的是字符串,不能直接照搬 token 层的计数。补丁在内部工具模块增加 splitSelectors(),扫描时维护括号深度、方括号深度和当前引号状态:
- 反斜杠使后一个字符作为转义内容处理,避免把转义括号当作结构。
- 引号内只识别对应的结束引号,属性值里的逗号和括号都保留。
- 注释整体跳过,不让注释中的标点影响层级。
- 方括号内保留属性内容;方括号外再记录函数括号的开合。
- 只有处于所有这些结构之外的逗号,才分隔两个完整选择器。
因此,下面的输入会拆成 :is(.a, .b) 和 .fallback 两项,而不是拆成三个片段:
.wrapper
{':is(.a, .b), .fallback'}
.item
color red
用一条忽略括号内逗号的正则去处理多层函数、属性值和转义引号,边界很容易再次遗漏。这里采用逐字符扫描,保留原始片段文本,后续继续沿用 normalizer 已有的修剪、根引用和节点创建逻辑。它没有尝试重新实现整套 CSS 解析器,也没有增加新的 CSS 语法校验。
原有转义逗号的兼容处理仍在拆分后执行。旧版用来绕过函数参数拆分的三个反斜杠写法也加入了回归;修复并不要求项目一次性清除存量转义代码。
展开到新数组,保留原列表的后续项
修复还需要处理 normalizer 的数组写入方式。原实现遍历 group.nodes 时,也把展开出来的节点写回同一个数组:一个插值项变成两项,就可能覆盖原本排在它后面的选择器。
可以用这个输入区分两个问题:
.before, {':is(.a, .b), .fallback'}, .after
.item
color red
即使函数参数里的逗号已被正确保护,顶层仍然需要展开 .fallback。若继续在原数组上覆盖,.after 仍可能丢失。补丁改成把每项的展开结果追加到新数组,遍历完成后再赋给 group.nodes,最终应保留四个完整分支:
.before .item,
:is(.a, .b) .item,
.fallback .item,
.after .item {
color: #f00;
}
这里保护的是选择器的数量和顺序,不能仅检查最终 CSS 中是否出现 .fallback。同组测试还保留了 / 根引用、selector() 内建函数、单双引号、转义引号、转义括号和注释中的逗号。
修复保持函数语义,测试同时保护权重与作用域
补丁保留了 :is()、:where() 等函数本身,由原来的选择器组合机制追加子项。它没有把参数强行展开成普通列表,所以前面讨论的权重规则也不需要由补丁重新模拟。
@extend 也加入了样本。错误分组会让不同函数式选择器留下相同的末尾片段,进而影响扩展目标的查找;完整保留目标选择器后,扩展应只出现在对应规则里。测试还覆盖带外层作用域、父引用 &、多层函数、顶层分组、多行参数和压缩输出。
本地验证先保留人工推导的预期,再执行原始实现与补丁:
| 验证范围 | 原始基线 | 本地补丁 |
|---|---|---|
| 原有完整测试 | 382 项通过 | 原有测试继续通过 |
| 新增三组编译回归 | 三组均失败 | 三组均通过,完整测试合计 385 项 |
| Node.js 版本 | 原始基线在 26.12.0 验证 | 22.23.4、26.12.0 均通过完整测试 |
| 隔离 Edge 156.0.4314.42 | 最小复现漏选 A 分支 | 9 组正例、反例与实际样式断言通过 |
已有 selectors.pseudo.css 的预期只调整两条函数式选择器内部的换行,保留为完整的一行参数。新增样本没有通过批量接受当前输出生成预期,浏览器验证也实际加载了补丁编译后的 CSS。
这些是 Windows 本地验证结果,上游跨平台 CI 尚未执行。官方 0.64.0 与这份本地补丁仍然需要区分:package.json 的版本号没有被改成一个尚不存在的正式修复版本。应用方法、固定输入、测试环境和补丁摘要见本地修复补丁与验证材料弹窗。
回归测试要检查命中范围
只断言产物里包含 display: none,这个 bug 会直接漏过;只检查 CSS 能否解析,也同样发现不了。两份规则都可能合法,却表达了不同的 DOM 条件。
可以先保留一条小的编译回归,检查完整规则是否符合预期,再把编译结果交给真实浏览器,检查该命中的节点与不该命中的节点:
// 示意:在真实浏览器页面中检查编译后选择器的语义。
const selector = ':is(.a, .b) .item';
const a = document.getElementById('a-direct');
const b = document.getElementById('b-direct');
console.assert(a.matches(selector) === true);
console.assert(b.matches(selector) === true);
真实测试应让 selector 来自刚生成的 CSS,或直接加载完整样式并断言 getComputedStyle();不要只把手写的预期选择器放进浏览器,那样验证的是浏览器支持语法,没有覆盖编译过程。
对于 display: none,浏览器断言既要保护目标节点确实隐藏,也要保护相邻的普通节点仍然显示。本文的交互实验额外使用不同深度的 .item,就是为了让「只漏掉直接子元素」这种情况显现出来。
DOM 模拟器可以继续覆盖日常节点操作和业务逻辑,但复杂选择器本身也有实现差异。另一篇关于 Happy DOM 的排查展示了为什么同一条合法 CSS 在测试环境里也可能被误判。编译产物与运行环境各自的语义,都需要有明确的验证位置。
继续使用官方 0.64.0 时,简单同权重分组可以改成普通嵌套,需要保留函数语义时局部使用 @css。有条件维护本地补丁的项目,可以按固定基线验证源码修复。后续切换依赖来源或升级官方版本时,仍需重新确认编译产物与实际命中,再决定是否去掉规避代码。
Stylus 编译与浏览器复现记录
采样日期为 2026-10-28,Node.js 26.12.0,Stylus 0.64.0。浏览器对照使用隔离 Edge 156.0.4314.42,没有扩展、远程网页或登录态。
可在独立实验目录安装固定版本,再下载本文的复现脚本运行:
npm init -y
npm install --save-exact stylus@0.64.0
node reproduce.mjs
脚本从当前工作目录的 package.json 解析依赖,也可以把另一个实验项目的 package.json 路径作为第一个参数。输出 JSON 保留每项真实 .styl 字符串、CSS 或异常,没有把转义字符改成展示文本。内部结构探针可用相同方式运行,只针对 0.64.0。
全部编译输入与输出
{
"node": "v26.12.0",
"stylus": "0.64.0",
"results": [
{
"name": "root",
"source": ":is(.a, .b)\n color: red\n",
"css": ":is(.a,\n.b) {\n color: #f00;\n}\n"
},
{
"name": "nested",
"source": ":is(.a, .b)\n .item\n display: none\n",
"css": ":is(.a .item,\n.b) .item {\n display: none;\n}\n"
},
{
"name": "flat",
"source": ":is(.a, .b) .item\n display: none\n",
"css": ":is(.a,\n.b) .item {\n display: none;\n}\n"
},
{
"name": "grouped",
"source": ".a\n.b\n .item\n display: none\n",
"css": ".a .item,\n.b .item {\n display: none;\n}\n"
},
{
"name": "literal",
"source": "@css {\n :is(.a, .b) .item { display: none; }\n}\n",
"css": "\n:is(.a, .b) .item { display: none; }\n"
},
{
"name": "oneEscape",
"source": ":is(.a\\, .b)\n .item\n display: none\n",
"css": ":is(.a .item,\n.b) .item {\n display: none;\n}\n"
},
{
"name": "threeEscapes",
"source": ":is(.a\\\\\\, .b)\n .item\n display: none\n",
"css": ":is(.a,.b) .item {\n display: none;\n}\n"
},
{
"name": "where",
"source": ":where(.a, .b)\n .item\n display: none\n",
"css": ":where(.a .item,\n.b) .item {\n display: none;\n}\n"
},
{
"name": "scoped",
"source": ".scope\n :is(.a, .b)\n display: none\n",
"css": ".scope :is(.a,\n.scope .b) {\n display: none;\n}\n"
}
]
}Parser.parse() 后的选择器组
{
"stylus": "0.64.0",
"source": ":is(.a, .b)\n .item\n display: none\n",
"parserGroup": [
":is(.a",
".b)"
],
"childGroup": [
".item"
]
}真实浏览器命中及背景色记录
{
"checkedOn": "2026-10-28",
"browser": "156.0.4314.42",
"results": [
{
"name": "原生 CSS:预期行为",
"selector": ":is(.a, .b) .item",
"ids": [
"a-direct",
"a-parent",
"a-deep",
"b-direct"
],
"colors": [
{
"id": "a-direct",
"background": "rgb(220, 235, 227)"
},
{
"id": "a-parent",
"background": "rgb(220, 235, 227)"
},
{
"id": "a-deep",
"background": "rgb(220, 235, 227)"
},
{
"id": "b-direct",
"background": "rgb(220, 235, 227)"
}
]
},
{
"name": "Stylus 0.64.0:错误产物",
"selector": ":is(.a .item,\n.b) .item",
"ids": [
"a-deep",
"b-direct"
],
"colors": [
{
"id": "a-direct",
"background": "rgb(255, 255, 255)"
},
{
"id": "a-parent",
"background": "rgb(255, 255, 255)"
},
{
"id": "a-deep",
"background": "rgb(220, 235, 227)"
},
{
"id": "b-direct",
"background": "rgb(220, 235, 227)"
}
]
},
{
"name": "普通分组:本例的规避",
"selector": ".a .item,\n.b .item",
"ids": [
"a-direct",
"a-parent",
"a-deep",
"b-direct"
],
"colors": [
{
"id": "a-direct",
"background": "rgb(220, 235, 227)"
},
{
"id": "a-parent",
"background": "rgb(220, 235, 227)"
},
{
"id": "a-deep",
"background": "rgb(220, 235, 227)"
},
{
"id": "b-direct",
"background": "rgb(220, 235, 227)"
}
]
}
]
}Stylus 上游 issue、PR 与 npm 快照
核对日期为 2026-10-28。npm 0.64.0 的 gitHead 为 d6f55c19bc0181eaab472752af2504dc2448a44d;正文源码链接固定到这个提交。
快照保存 issue 状态、候选 PR 的 merged 字段、合并时间、关键 diff 和相关讨论,便于后续升级时重新核对。它不代表之后的上游状态。
上游状态与 diff
{
"checkedOn": "2026-10-28",
"npm": {
"latest": "0.64.0",
"published": "2024-10-20T07:12:10.493Z",
"gitHead": "d6f55c19bc0181eaab472752af2504dc2448a44d"
},
"issues": [
{
"url": "https://github.com/stylus/stylus/issues/2750",
"title": "Wrong compiling for :is, :has, :where",
"state": "open",
"state_reason": null,
"created_at": "2023-01-11T07:52:46Z",
"updated_at": "2024-10-12T10:31:14Z",
"closed_at": null,
"body": "```stylus\r\n:is(section.is-styling, aside.is-styling, footer.is-styling) {\r\n a {\r\n color: red;\r\n }\r\n}\r\n```\r\n\r\nis wrongly composed as \r\n\r\n```stylus\r\n:is(section.is-styling a, aside.is-styling a, footer.is-styling) a{\r\n color: red;\r\n}\r\n```\r\n\r\n<img width=\"736\" alt=\"Screen Shot 2023-01-11 at 16 50 35\" src=\"https://user-images.githubusercontent.com/44498510/211748659-6068793d-5cb2-4d80-bb09-494cef26283c.png\">\r\n"
},
{
"url": "https://github.com/stylus/stylus/issues/2581",
"title": ":is(a,b) selector doesn't parse correctly",
"state": "closed",
"state_reason": "completed",
"created_at": "2021-03-15T10:55:45Z",
"updated_at": "2022-05-26T11:54:15Z",
"closed_at": "2022-05-26T11:54:15Z",
"body": "it does work in some circumstances, but since the comma is taken as a sign that a new selector has started, the following problems arise:\r\n\r\n### stylus\r\n```css\r\na{\r\n &:is(.this,.that){\r\n color:red;\r\n }\r\n}\r\nb,c{\r\n &:is(.this,.that){\r\n color:blue;\r\n }\r\n}\r\nd,e{\r\n &:is(.this\\,/.that){\r\n color:green;\r\n }\r\n}\r\n```\r\n### expected result\r\n```css\r\na:is(.this,.that){\r\n color:#f00;\r\n}\r\nb:is(.this,.that),\r\nc:is(.this,.that){\r\n color:#00f;\r\n}\r\nd:is(.this,.that),\r\ne:is(.this,.that){\r\n color:#008000;\r\n}\r\n```\r\n### actual result:\r\n```css\r\na:is(.this,\r\na .that) {\r\n color: #f00;\r\n}\r\nb:is(.this,\r\nc:is(.this,\r\nb .that),\r\nc .that) {\r\n color: #00f;\r\n}\r\nd:is(.this,\r\ne:is(.this,\r\n.that) {\r\n color: #008000;\r\n}\r\n```\r\n### workarounds\r\n```css\r\na{\r\n &:is(.this,/.that){\r\n color:red;\r\n }\r\n}\r\nb,c{\r\n &.this,\r\n &.that{\r\n color:blue;\r\n }\r\n}\r\n```\r\n### workaround results\r\n```css\r\na:is(.this,\r\n.that) {\r\n color: #f00;\r\n}\r\nb.this,\r\nc.this,\r\nb.that,\r\nc.that {\r\n color: #00f;\r\n}\r\n```\r\n\r\nIt would be nice to at least have the possiblity to actually escape the comma, but that isn't currently possible.\r\nAlso, using the :is() operator in parsing could reduce style size, but that's not as important as properly being able to use the selector manually."
}
],
"pullRequests": [
{
"url": "https://github.com/stylus/stylus/pull/2684",
"title": "feat: support full css level 4 logical combination selectors",
"state": "closed",
"merged": true,
"created_at": "2022-05-26T11:45:25Z",
"updated_at": "2023-01-11T08:11:05Z",
"closed_at": "2022-05-26T11:54:15Z",
"merged_at": "2022-05-26T11:54:15Z",
"base_sha": "2bf86ff830c0ee1c9e39d6ebcf51db29ac3b3e64",
"head_sha": "99a9f9f9bb0fdd0da16ab13e8523d56b03cc5e2b",
"merge_commit_sha": "6a1fb55c5b3695ca8a5d463ad79d5eef72099375",
"diff": "@@ -66,8 +66,11 @@ var selectorTokens = [\n */\n \n var pseudoSelectors = [\n+ // https://www.w3.org/TR/selectors-4/#logical-combination\n // Logical Combinations\n- 'matches'\n+ 'is'\n+ , 'has'\n+ , 'where'\n , 'not'\n \n // Linguistic Pseudo-classes\n@@ -94,3 +94,32 @@ label input:blank,\n label span {\n color: #014;\n }\n+:is(header,\n+main,\n+footer) p:hover {\n+ color: #f00;\n+ cursor: pointer;\n+}\n+:where(header,\n+main,\n+footer) p:hover {\n+ color: #f00;\n+ cursor: pointer;\n+}\n+a:where(:not(:hover)) {\n+ text-decoration: none;\n+}\n+a:has(> img) {\n+ color: #f00;\n+}\n+input {\n+ color: #a7a7a7;\n+}\n+input:not(:last-child) {\n+ color: #000;\n+}\n+@media (min-width: 0px) {\n+ div :not(.icon) {\n+ display: none;\n+ }\n+}\n\\ No newline at end of file\n@@ -83,3 +83,23 @@ label\n input:blank\n span\n color #014\n+\n+:is(header, main, footer) p:hover\n+ color: red;\n+ cursor: pointer;\n+:where(header, main, footer) p:hover\n+ color: red\n+ cursor: pointer\n+a:where(:not(:hover))\n+ text-decoration: none\n+a:has(> img)\n+ color: red\n+\n+input\n+ color #a7a7a7\n+ &:not(:last-child)\n+ color #000\n+\n+@media (min-width: 0px)\n+ div :not(.icon)\n+ display: none"
},
{
"url": "https://github.com/stylus/stylus/pull/2650",
"title": "Do not target comma's that are between quotes or parentheses",
"state": "open",
"merged": false,
"created_at": "2021-12-27T18:00:16Z",
"updated_at": "2022-12-01T21:29:36Z",
"closed_at": null,
"merged_at": null,
"base_sha": "14462ea7e7bffce8f192f7a933114ffbf577aa84",
"head_sha": "435f06e926ed767c6278b1bc4ee3085c215a9da4",
"merge_commit_sha": null,
"diff": "@@ -246,16 +246,19 @@ Normalizer.prototype.visitGroup = function(group){\n \n // normalize interpolated selectors with comma\n group.nodes.forEach(function(selector, i){\n- if (!~selector.val.indexOf(',')) return;\n+ // Don't match `,` that are within \"\" or ().\n+ if (!/\\,(?![^\\(|\\\"]*(\\)|\\\"))/.test(selector.val)) return;\n if (~selector.val.indexOf('\\\\,')) {\n selector.val = selector.val.replace(/\\\\,/g, ',');\n return;\n }\n- parts = selector.val.split(',');\n+ parts = selector.val.split(/\\,(?![^\\(|\\\"]*(\\)|\\\"))/);\n var root = '/' == selector.val.charAt(0)\n , part, s;\n- for (var k = 0, len = parts.length; k < len; ++k){\n- part = parts[k].trim();\n+ for (var k = 0, len = parts.length; k < len; ++k) {\n+ part = parts[k]\n+ if (!part) continue\n+ part = part.trim();\n if (root && k > 0 && !~part.indexOf('&')) {\n part = '/' + part;\n }\n@@ -132,3 +132,6 @@ paper {\n paper paper-input::shadow .color {\n background: #f00;\n }\n+[style=\"color: rgb(0, 186, 124)\"] {\n+ color: #f00;\n+}\n@@ -125,3 +125,6 @@ paper\n \n paper-input::shadow .color\n background red\n+\n+[style=\"color: rgb(0, 186, 124)\"]\n+ color red"
}
],
"comments": [
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-1378454272",
"body": "https://stylus-lang.com/try.html\r\n\r\nSame result in official playground.\r\n<img width=\"901\" alt=\"Screen Shot 2023-01-11 at 18 19 54\" src=\"https://user-images.githubusercontent.com/44498510/211767191-41c57588-0107-402e-8079-920efe606de5.png\">\r\n\r\n"
},
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-1378498822",
"body": "Currently I am doing this way to ensure the correct compiling output.\r\n\r\n```stylus\r\nbuildL4(type, args...) {\r\n st1 = join('', ':', type, '(', join('\\, ', args), ')')\r\n selector(st1)\r\n}\r\n```\r\n\r\n<img width=\"724\" alt=\"Screen Shot 2023-01-11 at 18 57 02\" src=\"https://user-images.githubusercontent.com/44498510/211775759-be64e815-3d5d-4c94-98e6-a29ecf861012.png\">\r\n<img width=\"777\" alt=\"Screen Shot 2023-01-11 at 18 57 07\" src=\"https://user-images.githubusercontent.com/44498510/211775783-039de97b-91eb-4c69-b49f-685e33c9dd01.png\">\r\n"
},
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-1565591453",
"body": "This is still an issue today, even with simpler examples:\r\n\r\n```stylus\r\n.menu-item:is(:hover, :focus)\r\n color red\r\n```\r\n\r\nShould render:\r\n```css\r\n.menu-item:is(:hover, :focus) { color: red; }\r\n```\r\n\r\nBut render this instead:\r\n```css\r\n.menu-item:is(:hover, .menu-item :focus) { color: red; }\r\n```"
},
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-1703767692",
"body": "This problem also appears to occur when `:not` is used\r\n\r\nFrom this I can infer that the problem is apparently occurring in pseudo-classes that accept a list of selectors in general.\r\n\r\n\r\n"
},
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-1703773033",
"body": "Hello, everyone. I don't have enough bandwidth to fix it, so you can use [`@css`](https://stylus-lang.com/docs/literal.html) as a workaround."
},
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-1703793968",
"body": "> Hello, everyone. I don't have enough bandwidth to fix it, so you can use [`@css`](https://stylus-lang.com/docs/literal.html) as a workaround.\r\n\r\ncan use this function to avoid the problem, but I just want it to be fixed officially.\r\n\r\n```css\r\nbuildL4(type, args...) {\r\n st1 = join('', ':', type, '(', join('\\, ', args), ')')\r\n selector(st1)\r\n}\r\n\r\n{buildL4('is', 'section.is-styling', 'aside.is-styling', 'footer.is-styling')} {\r\n a {\r\n color: red;\r\n }\r\n}\r\n\r\n\r\n.is-styling{buildL4('is', 'section', 'aside', 'footer')} {\r\n b {\r\n color: blue;\r\n }\r\n}\r\n\r\n```\r\nIt is proven working since I created this issue.\r\n"
},
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-2033110511",
"body": "@iChenLei, can you please point me in the direction where the codebase might break on this issue? I want to try and fix it."
},
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-2201728635",
"body": "Unfortunately, the use of [`@css`](https://stylus-lang.com/docs/literal.html) means that a functional selector wrapping a long(ish) section of nested (AKA stylus, not CSS) selectors is a no-go … except for @cyfung1031's outsanding workaround.\r\n\r\nWe no longer have to wait for this one to be adopted and fixed by a caring volunteer. Thanks!"
},
{
"url": "https://github.com/stylus/stylus/issues/2750#issuecomment-2408512108",
"body": "`:is(a\\\\\\,b)` might simply solve the problem in most cases."
}
]
}Stylus 本地修复补丁与验证材料
打开原文件 stylus-functional-selector-lists.patch
这份补丁基于 2e11b972a56d6e41c1408254e60e3d6fb76356d8,截至 2026-10-28 尚未提交上游或发布到 npm。它包含源码改动与回归夹具,不修改包版本和依赖。
在单独、干净的 Stylus checkout 中,将补丁下载到仓库根目录后执行:
git switch -c fix-functional-selector-lists 2e11b972a56d6e41c1408254e60e3d6fb76356d8
git apply --check stylus-functional-selector-lists.patch
git apply stylus-functional-selector-lists.patch
npm install --ignore-scripts --package-lock=false
npm test
git apply --check 已在原始文件的隔离副本上通过。其他提交或已有改动可能与补丁冲突,需要先检查,不应假定它能覆盖任意版本。
固定回归样本验证脚本需要和资源内的 data/fix-cases.json 保持相对目录关系。在 Stylus checkout 里运行 node <脚本路径> . 会直接加载当前目录的实现;它不会根据包版本号猜测补丁是否存在。安装官方 0.64.0 的独立实验项目也能运行这个脚本,用来观察未修复时的失败。
修复基线、补丁摘要与验证结果
{
"checkedOn": "2026-10-28",
"baseCommit": "2e11b972a56d6e41c1408254e60e3d6fb76356d8",
"status": "local patch; not submitted upstream or published to npm",
"patch": {
"path": "patches/stylus-functional-selector-lists.patch",
"sha256": "5cda313bbd6ae67a3a9e361dd01a482af01bb9f8c093dadf8aff1a0721bb3919",
"applyCheck": "passed against original tracked files"
},
"tests": {
"before": {
"original": 382,
"newFixtureGroupsFailed": 3
},
"after": {
"total": 385,
"newFixtureGroupsPassed": 3,
"node": [
"22.23.4",
"26.12.0"
],
"mocha": "11.8.0"
},
"browser": {
"engine": "Edge 156.0.4314.42",
"semanticCases": 9,
"result": "passed",
"scope": "positive and negative matches, computed display and CSS rule count"
}
},
"figure": {
"asset": "source/files/2026-10-28 - Stylus 嵌套选择器的逗号为什么会编译错/images/fix-selector-boundaries.85be095f3a4f.png",
"editableSource": "source/files/2026-10-28 - Stylus 嵌套选择器的逗号为什么会编译错/images/editable/fix-selector-boundaries.svg",
"receipt": "docs/image-reviews/2026-10-28-stylus-fix/figure-receipt.json"
},
"checks": {
"blogPreview": "passed at 1440px and 390px; six images loaded; supplementary panels and patch downloads verified"
}
}三组固定编译输入与预期
[
{
"name": "regression.2750.compress.styl",
"source": "// Compression must not move selector fragments across functional arguments.\n\n.scope\n :is(.a, .b), :where(.c, .d)\n .item\n color red\n",
"expected": ".scope :is(.a, .b) .item,.scope :where(.c, .d) .item{color:#f00}",
"options": {
"compress": true
}
},
{
"name": "regression.2750.interpolation.styl",
"source": "// Preserve functional and attribute commas when expanding interpolated lists.\n\nselector = ':is(.a, .b)'\n{selector}\n .item\n color red\n\n.wrapper\n {':not(.a, :has(> .b, + .c)), .fallback'}\n .item\n color red\n\n.before, {':is(.a, .b), .fallback'}, .after\n .item\n color red\n\n.attributes\n {'[data-value=\"a,b\"], [data-other=\",)\"]'}\n .item\n color red\n\n.quoted\n {'[data-value=\"a\\\",b\"], .fallback'}\n .item\n color red\n\n.root\n /{':is(.a, .b), .fallback'}\n .item\n color red\n\n.legacy\n {':is(.a\\, .b), .fallback'}\n .item\n color red\n\n.bif\n /{selector('&:is(.a, .b)')}\n .item\n color red\n\n.single-quoted\n {\"[data-value='a,b'], .fallback\"}\n .item\n color red\n\n.escaped-parens\n {'.bracket\\(x, .bracket\\)x'}\n .item\n color red\n\n.comments\n {':is(.a, .b /* (, */), .fallback'}\n .item\n color red\n",
"expected": ":is(.a, .b) .item {\n color: #f00;\n}\n.wrapper :not(.a, :has(> .b, + .c)) .item,\n.wrapper .fallback .item {\n color: #f00;\n}\n.before .item,\n:is(.a, .b) .item,\n.fallback .item,\n.after .item {\n color: #f00;\n}\n.attributes [data-value=\"a,b\"] .item,\n.attributes [data-other=\",)\"] .item {\n color: #f00;\n}\n.quoted [data-value=\"a\\\",b\"] .item,\n.quoted .fallback .item {\n color: #f00;\n}\n:is(.a, .b) .item,\n.fallback .item {\n color: #f00;\n}\n.legacy :is(.a, .b) .item,\n.legacy .fallback .item {\n color: #f00;\n}\n.bif:is(.a, .b) .item {\n color: #f00;\n}\n.single-quoted [data-value='a,b'] .item,\n.single-quoted .fallback .item {\n color: #f00;\n}\n.escaped-parens .bracket\\(x .item,\n.escaped-parens .bracket\\)x .item {\n color: #f00;\n}\n.comments :is(.a, .b /* (, */) .item,\n.comments .fallback .item {\n color: #f00;\n}",
"options": {
"compress": false
}
},
{
"name": "regression.2750.styl",
"source": "// Keep commas inside functional selectors out of the outer selector list.\n\n:is(.a, .b)\n .item\n color red\n\n.scope\n :is(.a, .b)\n .item\n color red\n\n.menu-item:is(:hover, :focus)\n color red\n\n.parent\n &:is(.a, .b)\n .item\n color red\n\n:where(.a, .b)\n .item\n color red\n\n:not(.disabled, :has(> .icon, + .sibling))\n .item\n color red\n\n:has(> .a, + .b)\n .item\n color red\n\n:nth-child(2n + 1 of .a, .b)\n .item\n color red\n\n.first, :is(.second, .third), .last\n .item\n color red\n\n[data-value=\"a,b\"]\n .item\n color red\n\n:is([data-value=\"),x\"], :not(.c, .d))\n .item\n color red\n\n:is(\n .multiline-a,\n .multiline-b\n)\n .item\n color red\n\n.extend-target:is(.a, .b)\n color red\n\n.extended\n @extend .extend-target:is(.a, .b)\n\n:is(.escaped-a\\\\\\, .escaped-b)\n .item\n color red\n",
"expected": ":is(.a, .b) .item {\n color: #f00;\n}\n.scope :is(.a, .b) .item {\n color: #f00;\n}\n.menu-item:is(:hover, :focus) {\n color: #f00;\n}\n.parent:is(.a, .b) .item {\n color: #f00;\n}\n:where(.a, .b) .item {\n color: #f00;\n}\n:not(.disabled, :has(> .icon, + .sibling)) .item {\n color: #f00;\n}\n:has(> .a, + .b) .item {\n color: #f00;\n}\n:nth-child(2n + 1 of .a, .b) .item {\n color: #f00;\n}\n.first .item,\n:is(.second, .third) .item,\n.last .item {\n color: #f00;\n}\n[data-value=\"a,b\"] .item {\n color: #f00;\n}\n:is([data-value=\"),x\"], :not(.c, .d)) .item {\n color: #f00;\n}\n:is(.multiline-a,.multiline-b) .item {\n color: #f00;\n}\n.extend-target:is(.a, .b),\n.extended {\n color: #f00;\n}\n:is(.escaped-a,.escaped-b) .item {\n color: #f00;\n}",
"options": {
"compress": false
}
}
]