Repository navigation
Fix case-insensitive pseudo-selector prefixing - #1558
Conversation
| */ | ||
| check(rule) { | ||
| if (rule.selector.includes(this.name)) { | ||
| if (rule.selector.toLowerCase().includes(this.name)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 }))|
Thanks |
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::PLACEHOLDERunprefixed 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 teston 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.