Skip to content

Fix case-insensitive pseudo-selector prefixing - #1558

Merged
ai merged 2 commits into
postcss:mainfrom
jakezwang:fix-case-insensitive-pseudo-selectors
Sep 19, 2026
Merged

ai merged 2 commits into
postcss:mainfrom
jakezwang:fix-case-insensitive-pseudo-selectors

Conversation

@jakezwang

@jakezwang jakezwang commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Pseudo-class and pseudo-element names are case-insensitive, but two case-sensitive substring checks skip uppercase names before the existing case-insensitive replacement runs. For example, targeting Chrome 40 leaves input::PLACEHOLDER unprefixed and drops uppercase entries from prefixed selector lists.

Use a cached, non-global case-insensitive regexp for detection. Keep the original class names, IDs, attribute values, and unprefixed selectors intact.

Adds public API regressions for uppercase pseudo-elements, mixed-case pseudo-classes and mixed-case selector lists, plus repeated-call and quoted-selector controls.

Validation: pnpm test on Node 26.8.1 (258 tests, 100% line coverage, lint and size); all 258 tests on Node 24.4.1; 135 public API checks. Before/after/regexp benchmark results and reproduction script are included in the review reply.

Comment thread lib/selector.js Outdated
*/
check(rule) {
if (rule.selector.includes(this.name)) {
if (rule.selector.toLowerCase().includes(this.name)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we call check on every rule of CSS? toLowerCase() could be very expensive on big numbers.

Maybe it is better to "compile" this.name to case-sensitive RegExp? Let's do a benchmark before/after/regexp.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes: the selector pass walks each CSS rule and invokes the applicable selector checkers. I replaced the lowercase copies with a cached, non-global /i regexp in both detection paths. The existing boundary-check/replacement regexp is unchanged.

I compared the original implementation, this PR’s lowercase conversion, cached /i, and explicit ASCII character classes without flags. Node 26.8.1, macOS arm64; medians of seven fresh-process rounds with rotated execution order:

Workload (ms; lower is better) Original Lowercase Cached /i ASCII classes
200k checks, short non-matching selectors 2.36 4.82 2.94 2.95
200k checks, long non-matching selectors 4.62 16.50 15.12 15.36
200k checks, mixed-case selector lists 4.41 9.62 5.24 5.44
Full processing, 1k short rules 16.89 17.04 17.26 17.34
Full processing, 1k long rules 23.59 24.95 24.23 24.78
Full processing, 1k mixed-case rules 20.31 35.35 35.35 36.63
Full processing, repeated repository fixtures 16.04 16.71 16.52 16.97

The original misses uppercase selectors, so its mixed-case processing time represents less work and is not an equivalent-output comparison. It remains faster for long non-matching selectors; the cached regexp improves on the lowercase version without copying every selector. The fixture workload repeats placeholder, fullscreen, filter, and flexbox input fixtures 20 times. These are local measurements, not a cross-platform guarantee.

pnpm test passes (258 tests, 100% line coverage, lint and size); Node 24 also passes all 258 tests. Repeated-call and quoted-selector checks cover the non-global regexp behavior.

Reproduction script

Save as /tmp/selector-bench.cjs and run node /tmp/selector-bench.cjs from the repository with dependencies installed.

const fs = require('fs')
const path = require('path')
const cp = require('child_process')
const Module = require('module')
const { performance } = require('perf_hooks')
const root = process.cwd()
const file = path.join(root, 'lib/selector.js')
const modes = ['original', 'lowercase', 'regexp-i', 'regexp-ascii']
const mode = process.argv[2]
if (!mode) {
  const runs = []
  for (let round = 0; round < 7; round++) {
    for (let i = 0; i < modes.length; i++) {
      const m = modes[(round + i) % modes.length]
      const result = JSON.parse(cp.execFileSync(process.execPath, [__filename, m], { encoding: 'utf8' }))
      runs.push({ round, ...result })
    }
  }
  const med = a => a.sort((a, b) => a - b)[Math.floor(a.length / 2)]
  const summary = modes.map(m => {
    const rs = runs.filter(r => r.mode === m)
    return { mode: m, ...Object.fromEntries(Object.keys(rs[0].timings).map(k => [k, med(rs.map(r => r.timings[k]))])) }
  })
  fs.writeFileSync(path.join(__dirname, 'autoprefixer-benchmark.json'), JSON.stringify({ node: process.version, arch: process.arch, platform: process.platform, rounds: 7, runs, summary }, null, 2))
  console.log(JSON.stringify(summary, null, 2))
  process.exit()
}
let source = cp.execFileSync('git', ['show', mode === 'original' ? 'c99614d:lib/selector.js' : 'b9a6d31:lib/selector.js'], { cwd: root, encoding: 'utf8' })
if (mode.startsWith('regexp')) {
  let init = mode === 'regexp-i'
    ? "new RegExp(utils.escapeRegexp(name), 'i')"
    : "new RegExp(utils.escapeRegexp(name).replace(/[a-z]/g, letter => `[${letter}${letter.toUpperCase()}]`))"
  source = source.replace('this.regexpCache = new Map()', `this.regexpCache = new Map()\n    this.nameRegexp = ${init}`)
    .replace('rule.selector.toLowerCase().includes(this.name)', 'this.nameRegexp.test(rule.selector)')
    .replace('el.toLowerCase().includes(this.name)', 'this.nameRegexp.test(el)')
}
const originalLoader = Module._extensions['.js']
Module._extensions['.js'] = (mod, filename) => filename === file ? mod._compile(source, filename) : originalLoader(mod, filename)
const Selector = require(file)
const postcss = require(path.join(root, 'node_modules/postcss'))
const autoprefixer = require(root)
const names = ['::placeholder', '::selection', ':fullscreen', ':read-only', ':read-write', ':any-link', ':placeholder-shown', ':autofill', '::file-selector-button', '::backdrop']
const checkers = names.map(n => new Selector(n, ['-webkit-', '-moz-']))
const datasets = {
  short: Array.from({ length: 1000 }, (_, i) => ({ selector: `.card-${i} .Title > input[data-ID="Value${i}"]:hover` })),
  long: Array.from({ length: 1000 }, (_, i) => ({ selector: `.card-${i} ` + '.Container > [data-value="KeepCase"] '.repeat(12) + 'input:hover' })),
  mixed: Array.from({ length: 1000 }, (_, i) => ({ selector: `.Card${i}${i % 5 ? ':hover' : (i % 2 ? '::PLACEHOLDER' : '::placeholder')}, .Other:READ-ONLY` }))
}
let sink = 0
const timings = {}
for (const [label, rules] of Object.entries(datasets)) {
  function run() { for (let k = 0; k < 20; k++) for (const rule of rules) for (const checker of checkers) sink += checker.check(rule) ? 1 : 0 }
  for (let w = 0; w < 3; w++) run()
  let t = performance.now(); for (let n = 0; n < 5; n++) run(); timings['check_' + label] = (performance.now() - t) / 5
}
const plugin = autoprefixer({ overrideBrowserslist: ['Chrome 25', 'Firefox 17', 'IE 10'] })
for (const label of ['short', 'long', 'mixed', 'fixtures']) {
  const css = label === 'fixtures'
    ? ['placeholder', 'fullscreen', 'filter', 'flexbox'].map(n => fs.readFileSync(path.join(root, 'test/cases/' + n + '.css'), 'utf8')).join('\n').repeat(20)
    : datasets[label].map(r => `${r.selector}{display:flex;user-select:none}`).join('\n')
  function run() { sink += postcss([plugin]).process(css, { from: undefined }).css.length }
  for (let w = 0; w < 4; w++) run()
  let t = performance.now(); for (let n = 0; n < 8; n++) run(); timings['css_' + label] = (performance.now() - t) / 8
}
console.log(JSON.stringify({ mode, timings, sink }))

@ai
ai merged commit c6d89bc into postcss:main Sep 19, 2026
9 checks passed
@ai

ai commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Thanks

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.

2 participants