Skip to content

fix: remove root & inside functional pseudo-classes - #356

Open
spokodev wants to merge 1 commit into
thysultan:masterfrom
spokodev:fix/root-ampersand-in-functional-pseudo
Open

spokodev wants to merge 1 commit into
thysultan:masterfrom
spokodev:fix/root-ampersand-in-functional-pseudo

Conversation

@spokodev

Copy link
Copy Markdown

Problem

At the top level (no parent rule), a & inside a functional pseudo-class is substituted with an empty string, producing invalid CSS:

serialize(compile(':is(&, .c){color:red}'), stringify)
// actual:   ":is(, .c){color:red;}"   <- leading comma, invalid
// expected: ":is(.c){color:red;}"

':is(&){color:red}'         -> ":is(){color:red;}"          // empty pseudo
':is(.c, &){color:red}'     -> ":is(.c, ){color:red;}"      // trailing comma
':is(.a, &, .b){color:red}' -> ":is(.a, , .b){color:red;}"  // empty middle arg

css-tree rejects the comma forms with Selector is expected:

import * as csstree from 'css-tree'
csstree.parse(':is(, .c){color:red}') // throws: Selector is expected

For comparison, the same input under a real parent already works, and a top-level & .x already compiles to a clean .x.

Root cause

In ruleset() the parent list at root is [''], so rule[x] === ''. The substitution replace(y, /&\f/g, rule[x]) turns the &\f marker into an empty string, so :is(&\f, .c) becomes :is(, .c). trim() only strips outer whitespace, so the empty argument and its comma stay inside the parentheses and the output is invalid.

Fix

When the parent is empty (root), remove the &\f marker together with one adjacent comma instead of leaving an empty argument. The non-root substitution path is unchanged.

-if (z = trim(j > 0 ? rule[x] + ' ' + y : replace(y, /&\f/g, rule[x])))
+if (z = trim(j > 0 ? rule[x] + ' ' + y : rule[x] ? replace(y, /&\f/g, rule[x]) : replace(y, /&\f *, *| *, *&\f|&\f/g, '')))

This mirrors the existing rule that a root & is removed (issue #333, and the "& root should be removed" test). The empty argument is dropped, so:

:is(&, .c)     -> :is(.c)
:is(.c, &)     -> :is(.c)
:is(.a, &, .b) -> :is(.a, .b)
:is(&)         -> :is()
:where(&)      -> :where()
:not(&)        -> :not()

All outputs parse cleanly with css-tree. Empty :is() / :where() / :not() are valid (forgiving selector list) and are the closest valid result when the only argument was the absent parent.

Tests

Added & root inside functional pseudo should be removed next to the existing root-& tests. It uses compile/serialize directly (no .user wrapper) so the empty-parent path is exercised.

  • Red before the fix: expected ':is(, .c){color:red;}' to equal ':is(.c){color:red;}'.
  • Green after the fix.
  • Full suite: 114 passing, 0 failing (113 existing + 1 new). Lint and build pass.

At the top level the parent reference list is [''], so substituting &\f
with the empty parent inside a functional pseudo-class left a stray empty
argument: :is(&, .c) compiled to :is(, .c), which is invalid CSS. Strip
the now-empty argument together with its adjacent comma so the result is
valid, matching the existing rule that a root & is removed.

  :is(&, .c)  -> :is(.c)
  :is(.a, &, .b) -> :is(.a, .b)
  :is(&) -> :is()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant