Files
orca/config/scripts/check-has-restyle-scope.mjs
T
NeilandNaufaldi 19f6c10c3f perf(renderer): drop group-has-[:focus-visible] rules that restyle whole subtrees (#36098)
Fixes #34312. Takes over naufaldi/orca#1 by @naufaldi, with the main.css part dropped because #34932 already covers it.

- SshTargetCard and the reasoning chevron now use the scoped `[[class~='group/x']:has(:focus-visible)_&]` shape. Tailwind's `group-has-*` compiles to a non-subject `:has()` whose subject is `*`, and that rule made Blink restyle whole subtrees on every mutation. This change is the entire measured win: 19.4 → 1.6 ms, 12.5 → 3.6 ms and 8.0 → 2.4 ms per mutation.
- rich-markdown-editor.css drops a redundant `:has()` on the TOC header, as defence in depth.
- Adds a guard, check-has-restyle-scope.mjs. It compiles the renderer's Tailwind CSS, adds plain stylesheets and the listed vendor stylesheets, and fails on any non-subject `:has()` whose subject has no class, id or attribute. Three pdf.js rules are baselined after being measured as harmless. It runs in lint, check:changed and PR CI in about 1.5 s.

Co-authored-by: Naufaldi <naufaldi.rafif@gmail.com>
2026-10-29 14:31:45 -07:00

259 lines
11 KiB
JavaScript

// Rejects `:has()` rules that make Blink restyle the anchor's whole subtree on any mutation
// (#34312). Checks Tailwind's compiled output plus plain and listed package stylesheets.
// Not covered: `?inline` imports, template-literal imports, `.js` files, relative node_modules paths.
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const TAILWIND_ENTRY = 'src/renderer/src/assets/main.css'
// Plain pathspec `*` also matches `/`; files main.css imports are rescanned harmlessly.
const PLAIN_CSS = ['src/renderer/*.css', 'src/shared/*.css']
// Package stylesheets imported from renderer JS (xterm is also compiled in through main.css).
const VENDOR_CSS = [
'@xterm/xterm/css/xterm.css',
'monaco-editor/min/vs/editor/editor.main.css',
'pdfjs-dist/web/pdf_viewer.css',
'react-grab/styles.css'
]
// Accepted vendor findings. pdf.js's sheet stays loaded once the lazy ImageViewer first opens, but
// these anchor on viewer-only widgets: the #34312 harness showed no subtree restyle and the same
// cost as with every pdf.js :has() rule removed. A vendor bump that adds a finding fails here.
const VENDOR_BASELINE = new Set([
'pdfjs-dist/web/pdf_viewer.css: .annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton):has([data-canvas-name="checked"]) input:checked',
'pdfjs-dist/web/pdf_viewer.css: .annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton):has([data-canvas-name="unchecked"]) input:not(:checked)',
'pdfjs-dist/web/pdf_viewer.css: #addSignatureDialog .mainContainer #addSignatureActionContainer #addSignatureControls #horizontalContainer #addSignatureDescriptionContainer:has(input:disabled) > label'
])
const CSS_IMPORT =
/(?:\bimport\s*(?:[\w*{}\s,]+?\bfrom\s*)?\(?|@import\s+(?:url\()?)\s*(?:['"]([^'"]+\.css)['"]|([^'"()\s]+\.css)\s*\))/g
const IMPORT_SCAN = ['src/renderer/*.ts', 'src/renderer/*.tsx', ...PLAIN_CSS]
const LS_FILES = ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--']
const GREP_CSS = ['grep', '-lz', '--untracked', '-F', '.css', '--']
// Whole-line comments only: stripping mid-line `//` would eat imports after a URL string.
const COMMENT = /^\s*\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm
const SPECIFIC = new Set(['class', 'id', 'attribute'])
const SIBLING = new Set(['next-sibling', 'later-sibling'])
// Tailwind's own packages, resolved through the direct @tailwindcss/vite dependency.
function loadToolchain(root) {
const fromVite = createRequire(
createRequire(path.join(root, 'package.json')).resolve('@tailwindcss/vite')
)
const nodePath = fromVite.resolve('@tailwindcss/node')
return {
tailwind: fromVite(nodePath),
oxide: fromVite('@tailwindcss/oxide'),
lightningcss: createRequire(nodePath)('lightningcss')
}
}
function compounds(selector) {
const result = [[]]
for (const component of selector) {
if (component.type === 'combinator') {
result.push([])
} else {
result.at(-1).push(component)
}
}
return result.filter((compound) => compound.length > 0)
}
// Blink's descendant invalidation set keys on the subject's class/id/attribute; without one it is the whole subtree.
function isBroad(compound) {
return !compound.some(
(component) =>
SPECIFIC.has(component.type) ||
(['is', 'where'].includes(component.kind) &&
component.selectors.every((branch) => !isBroad(compounds(branch).at(-1) ?? [])))
)
}
// Only `:has(+ x)` / `:has(~ x)` (a leading :scope) stay among siblings; `:has(.a + .b)` starts with a descendant.
function isSiblingOnly(relativeSelectors) {
return relativeSelectors.every(
([first, ...rest]) =>
first?.type === 'pseudo-class' &&
first.kind === 'scope' &&
rest.every((component) => component.type !== 'combinator' || SIBLING.has(component.value))
)
}
// `outerSubject` is the rule's final subject when this selector sits inside a non-subject compound, else null.
function visitSelector(selector, outerSubject, report) {
const parts = compounds(selector)
parts.forEach((compound, index) => {
const subject = index === parts.length - 1 ? outerSubject : (outerSubject ?? parts.at(-1))
for (const component of compound) {
// `:host(<selector>)` holds one selector, not a list, and is outside the light DOM.
if (component.type !== 'pseudo-class' || component.kind === 'host') {
continue
}
if (component.kind === 'has') {
if (subject && isBroad(subject) && !isSiblingOnly(component.selectors)) {
report()
}
component.selectors.forEach((nested) => visitSelector(nested, null, report))
continue
}
// :is()/:where()/:not()/:nth-child(of) arguments match this same element, so they keep its context.
for (const nested of [...(component.selectors ?? []), ...(component.of ?? [])]) {
visitSelector(nested, subject, report)
}
}
})
}
// Top-level commas only; escapes and brackets in Tailwind class names stay intact.
function splitSelectorList(text) {
const parts = ['']
let depth = 0
for (let index = 0; index < text.length; index++) {
const char = text[index]
if (char === '\\') {
parts[parts.length - 1] += text.slice(index, index + 2)
index++
continue
}
depth += '(['.includes(char) ? 1 : ')]'.includes(char) ? -1 : 0
if (char === ',' && depth === 0) {
parts.push('')
} else {
parts[parts.length - 1] += char
}
}
return parts.map((part) => part.trim().replace(/\s+/g, ' '))
}
/** Selectors whose non-subject `:has()` invalidates a whole subtree; nesting is lowered first. */
export function findHasRestyleProblems(css, { lightningcss } = loadToolchain(process.cwd())) {
const lowered = lightningcss.transform({
filename: 'input.css',
code: Buffer.from(css),
include: lightningcss.Features.Nesting,
errorRecovery: true
})
const problems = new Set()
const flat = lowered.code.toString()
const lines = flat.split('\n')
lightningcss.transform({
filename: 'flat.css',
code: Buffer.from(flat),
errorRecovery: true,
visitor: {
Rule: {
style(rule) {
rule.value.selectors.forEach((selector, index) =>
visitSelector(selector, null, () => {
// Selector text only for findings: slicing the sheet per rule dominated the runtime.
const { line, column } = rule.value.loc
const tail = lines.slice(line).join('\n')
const texts = splitSelectorList(tail.slice(column - 1, tail.indexOf('{', column - 1)))
problems.add(texts[index] ?? texts.join(', '))
})
)
}
}
}
})
return [...problems]
}
/** Tailwind's build of `css` as Vite would emit it, scanning `scanBase` for candidates. */
export async function compileTailwind(
css,
{ base, scanBase, toolchain = loadToolchain(process.cwd()) }
) {
const { tailwind, oxide } = toolchain
const compiler = await tailwind.compile(css, { base, onDependency() {} })
// A null root means auto-detection from `scanBase`; `source(none)` turns scanning off.
const autoSource = { base: scanBase, pattern: '**/*', ...compiler.root, negated: false }
const detected = compiler.root === 'none' ? [] : [autoSource]
const scanner = new oxide.Scanner({ sources: [...detected, ...compiler.sources] })
return compiler.build(scanner.scan())
}
// Tracked plus untracked-but-not-ignored paths, so local work is checked before it is staged.
function gitPaths(root, args) {
return execFileSync('git', ['-c', 'submodule.recurse=false', ...args], {
cwd: root,
encoding: 'utf8'
})
.split('\0')
.filter((file) => file && file !== TAILWIND_ENTRY && existsSync(path.join(root, file)))
}
// Package CSS that renderer TS/TSX imports or a plain stylesheet @imports; main.css compiles its own.
function unlistedVendorImports(root) {
const specifiers = new Set()
// git grep prefilters ~13k renderer files; reading each one dominated the runtime.
for (const file of gitPaths(root, [...GREP_CSS, ...IMPORT_SCAN])) {
const source = readFileSync(path.join(root, file), 'utf8').replace(COMMENT, '')
for (const [, quoted, unquoted] of source.matchAll(CSS_IMPORT)) {
specifiers.add(quoted ?? unquoted)
}
}
return [...specifiers]
.filter((specifier) => !/^(?:\.|\/|@\/)/.test(specifier) && !VENDOR_CSS.includes(specifier))
.sort()
}
export async function main(root = process.cwd()) {
const toolchain = loadToolchain(root)
const unlisted = unlistedVendorImports(root)
if (unlisted.length > 0) {
console.error(`Add renderer-imported stylesheets to VENDOR_CSS: ${unlisted.join(', ')}`)
return 1
}
const resolveVendor = createRequire(path.join(root, 'src/renderer/src/main.tsx')).resolve
const entry = path.join(root, TAILWIND_ENTRY)
const sources = [
{
name: `${TAILWIND_ENTRY} (compiled)`,
css: await compileTailwind(readFileSync(entry, 'utf8'), {
base: path.dirname(entry),
scanBase: path.join(root, 'src/renderer'),
toolchain
})
},
...gitPaths(root, [...LS_FILES, ...PLAIN_CSS]).map((file) => ({
name: file,
css: readFileSync(path.join(root, file), 'utf8')
})),
...VENDOR_CSS.map((specifier) => ({
name: specifier,
css: readFileSync(resolveVendor(specifier), 'utf8')
}))
]
const all = sources.flatMap(({ name, css }) =>
findHasRestyleProblems(css, toolchain).map((selector) => `${name}: ${selector}`)
)
const stale = [...VENDOR_BASELINE].filter((entry) => !all.includes(entry))
if (stale.length > 0) {
console.error(`Remove stale VENDOR_BASELINE entries:\n- ${stale.join('\n- ')}`)
return 1
}
const findings = all.filter((finding) => !VENDOR_BASELINE.has(finding))
if (findings.length > 0) {
console.error(`:has() restyle-scope check failed with ${findings.length} finding(s):`)
for (const finding of findings) {
console.error(`- ${finding}`)
}
console.error(
'A :has() followed by a combinator into a subject with no class, id or attribute makes ' +
'Blink restyle the whole subtree on any mutation (#34312). Give the subject a class, e.g. ' +
"[[class~='group/x']:has(…)_&]:… instead of group-has-*."
)
return 1
}
console.log(`:has() restyle-scope check passed (${sources.length} stylesheets).`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
// An uninstalled VENDOR_CSS package surfaces as Node's "Cannot find module" message.
process.exit(await main().catch((error) => (console.error(error.message), 1)))
}