Skip to content

fix: $ dropped from attribute names - #336

Merged
MoOx merged 3 commits into
postcss:mainfrom
theRizwan:fix/dollar-in-attribute-name
Nov 8, 2026
Merged

MoOx merged 3 commits into
postcss:mainfrom
theRizwan:fix/dollar-in-attribute-name

Conversation

@theRizwan

Copy link
Copy Markdown
Contributor

Fixes #211. Fixes #306.

Both reports are the same defect seen through different syntax.

The bug

parser().astSync("[#{$attr}]").toString();   // "[#{attr}]"
parser().astSync("[$attr]").toString();      // "[attr]"
parser().astSync("[#{$a}=#{$b}]").toString() // "[#{a}=#{$b}]"

The $ is dropped when it belongs to the attribute name. The value position already worked, which is why [lang=#{$locale}] round-trips in the existing tests while [#{$attr}] does not.

Cause

attribute() has a branch for $ in the value position, and the tokens.caret case immediately below turns $ into the suffix match operator when the next token is =. Nothing handled a $ that is part of the attribute name, so it fell through to tokens.caret, failed the = check, and was discarded without being recorded anywhere.

The new branch mirrors the value-position handling, and is guarded on the next token not being =.

Why the guard matters

Without it, $= stops parsing as an operator. With it, a name and an operator can coexist:

parser().astSync('[$attr$="x"]')
// attribute "$attr", operator "$=", value "x"

[a$=x] and [data-weird-attr$="Something=weird"] are unaffected, and both remain covered by the existing tests.

Verification

Compared against main across 4,800 generated attribute selectors, varying the name, namespace, operator, value, quoting, insensitivity flag and padding:

newly round-tripping 991
regressions (round-tripped before, broken now) 0
parsed before, throws now 0
raw TypeError 45 → 30

The TypeError reduction is a side effect rather than the aim: a trailing $ such as [a$] now returns before reaching the unguarded next[TOKEN.TYPE] lookahead in tokens.caret. That lookahead is the subject of #334 and is not otherwise touched here.

npm test passes, including oxlint, the type check and the coverage thresholds: 94.99% lines, 95.41% branches, 97.71% functions against gates of 94/94/96. Test count 789 to 799.

Tests

Added to nonstandard.mjs, next to the existing sass escapes cases, since these arrive through preprocessor interpolation: interpolated attribute name, bare $ name, interpolation on both sides, a $ name combined with $=, and a namespaced $ name.

Not addressed

[#{$attr} i] still drops the insensitivity flag, both before and after this change. The flag survives only when an operator is present, which is a separate defect and left alone.

`attribute()` handled `$` in the value position, and treated it as the suffix
match operator when followed by `=`, but had no branch for a `$` that belongs to
the attribute name. Those fell through to the `tokens.caret` case, which only
acts when the next token is `=`, so the character was silently discarded:

    parser().astSync("[#{$attr}]").toString()   // "[#{attr}]"
    parser().astSync("[$attr]").toString()      // "[$attr]" -> "[attr]"

Preprocessors reach this through interpolation, which is how both reports arrived.
The value position already worked, so `[lang=#{$locale}]` round-tripped while
`[#{$attr}]` did not.

The new branch mirrors the existing value-position handling and is guarded on the
next token not being `=`, which keeps the suffix match operator intact. That guard
is what allows a name and an operator to coexist:

    [$attr$="x"]   attribute "$attr", operator "$=", value "x"

Compared against main across 4,800 generated attribute selectors: 991 newly
round-trip, 0 regressions, and none that previously parsed now throw. A trailing
`$` such as `[a$]` also stops raising a raw TypeError as a side effect, since the
new branch returns before the unguarded lookahead is reached.

Fixes postcss#211
Fixes postcss#306
@MoOx

MoOx commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Thanks. The interpolation half of this is right, and it's what #211 and #306 actually report: [#{$attr}] and [#{$attr}=#{$value}] need fixing.

The three bare-$ tests go further than those issues, though, and I'd like them out:

  • sass variable as an attribute name ([$attr])
  • sass variable name with a suffix match operator ([$attr$="x"])
  • namespaced sass variable attribute name ([ns|$attr])

[$attr] isn't valid SCSS. dart-sass rejects it with Error: Expected identifier., while [#{$a}] compiles to [foo]. Interpolation is legitimate, a bare variable is not. lightningcss (No qualified name in attribute selector), css-tree (Identifier is expected), Less and Chrome all agree. So those three tests pin a syntax no tool accepts.

They also collide with #335. Git merges the two branches cleanly, but together those three tests fail: #335 drops a trailing $ ([a$] -> [a], the lossy path [href=] already takes), this branch makes it part of the name. The spec settles [a$]: <attr-matcher> = [ '~' | '|' | '^' | '$' | '*' ]? '=' always ends in =, so [a$] has no valid reading.

The two cases are distinguishable. When the $ is read, node.attribute is "#{" for the interpolation, "a" for [a$], undefined for [$attr]. Gating on an open interpolation separates them:

// A preprocessor interpolation (`#{...}` in SCSS, `@{...}` in Less) is open
// when an opener has been consumed and its closing brace has not.
function insideInterpolation(text) {
  if (!text) {
    return false;
  }
  const open = text.lastIndexOf("{");
  if (open < 1 || text.indexOf("}", open) !== -1) {
    return false;
  }
  const prefix = text[open - 1];
  return prefix === "#" || prefix === "@";
}
           if (
-            (lastAdded === "attribute" || !node.attribute) &&
+            lastAdded === "attribute" &&
+            insideInterpolation(node.attribute) &&
             !(next && next[TOKEN.TYPE] === tokens.equals)
           ) {

I ran that on top of #335: 812 tests pass, and the only failures are the three above. Both interpolation tests still pass, [#{$Keep}] and [#{$b}] from the issues round-trip, and so do [#{$a}$=x], [a#{$b}c], [data-#{$a}], [ns|#{$a}] and [@{a}].

Could you narrow the condition and drop those three tests? This goes in right after #335.

One process note: please open one PR per defect. This branch bundles the #211/#306 fix with a behaviour change nobody asked for, and that bundling is what made it collide with your own other PR.

Narrow the new branch to a `$` read while a `#{` or `@{` interpolation is open
in the attribute name, and drop the three bare `$` name tests. `[$attr]` is not
valid SCSS or CSS, and the broader condition collided with postcss#335 on `[a$]`.
* origin/main:
  fix: preserve selector delimiters after escaped backslashes (postcss#354)
  fix: keep the processor's entry points usable when destructured
  feat: support the case-sensitive attribute flag (s) per Selectors Level 4 (postcss#327)
  fix: escape values assigned to an id through the value setter (postcss#326)
  fix: escape class values supplied to the constructor (postcss#349)
  fix: preserve the spaces declared on selector and root containers (postcss#356)
  fix: forward the default options given to the parser factory (postcss#345)
  7.1.8
  fix: remove four more quadratic paths from selector parsing
  chore(deps): bump tinypool and oxfmt (postcss#351)
  7.1.7
  docs: add CVE-2026-104844 to the 7.1.6 changelog entry
  fix: keep whitespace around the namespace separator in attribute selectors (postcss#339)
  fix: invalidate stale raw spelling on combinator value assignment (postcss#342)
  fix: set parents for all variadic inserted nodes (postcss#343)
  Consume whitespace after hexadecimal CSS escapes (postcss#340)
  fix: lossless mode drops whitespace when a selector ends empty (postcss#337)
  chore(deps-dev): bump source-map-js from 1.2.1 to 1.2.2 (postcss#347)
  7.1.6
  fix: parse flat selectors in linear time, closing a CPU exhaustion vulnerability

# Conflicts:
#	src/parser.js
@MoOx

MoOx commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator

First, thank you for narrowing the condition in e4d839c. That was three weeks ago and nobody told you: it resolves the disagreement with #335, which can now land alongside this one. Together the two pass npm test with 1010 tests.

I merged current main into your branch rather than asking you to rebase, because the conflict came from our side. Your insideInterpolation lands exactly where #354 put its own helper, and the merge also carried an older uniqs() that would have undone the linear-time fix from 7.1.6. I resolved it by keeping all three helpers and main's Set-based uniqs().

On this branch alone: npm test passes with 998 tests, parsing a flat selector of 60 000 classes stays at 41 ms, and [#{$a}], [@{a}] and [#{$a}=#{$b}] all round-trip while [$attr] is unchanged.

Tell me if you would rather resolve it yourself and I will drop my merge commit.

@MoOx
MoOx merged commit 617c25b into postcss:main Nov 8, 2026
4 checks passed
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.

SCSS interpreter in attribute lost $ can't parse attribute with scss expression

2 participants