+2
-1
@@ -55,2 +55,3 @@ { | ||
| "scripts": { | ||
| "benchmark": "node test/benchmark.js", | ||
| "lint": "eslint .", | ||
@@ -65,3 +66,3 @@ "lint:fix": "eslint . --fix", | ||
| "types": "src/index.d.ts", | ||
| "version": "1.7.0" | ||
| "version": "1.8.0" | ||
| } |
+59
-13
| import postcss from 'postcss'; | ||
| import { normalizeProp, declarationKey } from './normalization.js'; | ||
| import { normalizeProp, declarationKey, resetPropertyCache } from './normalization.js'; | ||
| import { isIgnoredSelector, resolveIgnorePatterns } from './hacks.js'; | ||
| import { splitSelectors, hasSpacedTopLevelComma, selectorsAreMutuallyExclusive, selectorsLikelyDisjoint, resetSubjectIdentities } from './selectors.js'; | ||
| import { splitSelectors, hasSpacedTopLevelComma, selectorsAreMutuallyExclusive, selectorsLikelyDisjoint, resetSelectorCaches } from './selectors.js'; | ||
| import { propertiesOverlap } from './shorthands.js'; | ||
@@ -11,2 +11,20 @@ | ||
| // The memoization the hot paths rely on is all per run: reused across one | ||
| // style sheet’s passes, never carried over to the next (see | ||
| // `resetSelectorCaches()` for why unbounded growth matters here). Called at | ||
| // the start of both top-level entry points. | ||
| function resetCaches() { | ||
| resetSelectorCaches(); | ||
| resetPropertyCache(); | ||
| separatorCache = new WeakMap(); | ||
| } | ||
| // A rule’s own `splitSelectors()` result is a shared, cached array (see | ||
| // `selectors.js`)—anything that ends up in the public `Occurrence.selectors` | ||
| // or `AppliedChange.selectors` gets a copy, so a consumer mutating what it | ||
| // was handed can’t corrupt the cache for the rest of the run | ||
| function ownSelectors(selector) { | ||
| return [...splitSelectors(selector)]; | ||
| } | ||
| // An anonymous `@layer {}` block is its own, distinct cascade layer—unlike | ||
@@ -186,2 +204,4 @@ // two same-name `@layer x {}` blocks, which share one layer—so each block | ||
| export function analyzeRoot(root, options = {}) { | ||
| resetCaches(); | ||
| const ignorePatterns = resolveIgnorePatterns(options); | ||
@@ -255,3 +275,3 @@ const aggressive = options.aggressive ?? false; | ||
| selector: rule.selector, | ||
| selectors: splitSelectors(rule.selector), | ||
| selectors: ownSelectors(rule.selector), | ||
| line: rule.source?.start?.line, | ||
@@ -296,3 +316,3 @@ })), | ||
| selector: rule.selector, | ||
| selectors: splitSelectors(rule.selector), | ||
| selectors: ownSelectors(rule.selector), | ||
| prop: decl.prop, | ||
@@ -340,3 +360,19 @@ value: decl.value, | ||
| // rules outvote that convention. | ||
| // | ||
| // Memoized per container for one run, by node identity. By the time a second | ||
| // residual asks, the container already holds the residuals this run inserted | ||
| // (each carrying this very separator), so a fresh tally would be counting its | ||
| // own output. | ||
| let separatorCache = new WeakMap(); | ||
| function typicalSeparator(container) { | ||
| const cached = separatorCache.get(container); | ||
| if (cached !== undefined) return cached; | ||
| const computed = computeTypicalSeparator(container); | ||
| separatorCache.set(container, computed); | ||
| return computed; | ||
| } | ||
| function computeTypicalSeparator(container) { | ||
| const counts = new Map(); | ||
@@ -464,5 +500,5 @@ for (let i = 1; i < container.nodes.length; i++) { | ||
| function consolidateRoot(root, options = {}) { | ||
| // The subject-identity memoization is per run: fresh here, reused across | ||
| // this run’s fixed-point passes, never carried over to the next style sheet | ||
| resetSubjectIdentities(); | ||
| // The memoization is per run: fresh here, reused across this run’s | ||
| // fixed-point passes, never carried over to the next style sheet | ||
| resetCaches(); | ||
@@ -573,8 +609,14 @@ // Taken before any mutation, so it reflects the file as it stood on disk— | ||
| function findBlockingRule(scope, distinctRules, exemptRules, firstIndex, lastIndex, propNormalized) { | ||
| const groupSelectors = distinctRules.flatMap(rule => splitSelectors(rule.selector)); | ||
| for (const [index, rule] of scope.rules.entries()) { | ||
| if (index <= firstIndex || index >= lastIndex || exemptRules.has(rule)) continue; | ||
| // Only the rules actually inside the span can block—walking the whole | ||
| // scope to skip everything outside it made this quadratic in the scope’s | ||
| // rule count. The group’s own selector list is likewise only needed once | ||
| // something in that span conflicts, which is the rare case. | ||
| let groupSelectors = null; | ||
| for (let index = firstIndex + 1; index < lastIndex; index++) { | ||
| const rule = scope.rules[index]; | ||
| if (exemptRules.has(rule)) continue; | ||
| const conflict = rule.nodes.find(node => node.type === 'decl' && propertiesOverlap(propOf(node.prop), propNormalized)); | ||
| if (!conflict) continue; | ||
| groupSelectors ??= distinctRules.flatMap(groupRule => splitSelectors(groupRule.selector)); | ||
| const candidateSelectors = splitSelectors(rule.selector); | ||
@@ -655,3 +697,3 @@ // The (memoized, cheap) heuristic goes first: In aggressive mode it | ||
| key: splitSelectors(target.selector).join(', '), | ||
| selectors: splitSelectors(target.selector), | ||
| selectors: ownSelectors(target.selector), | ||
| folded: true, | ||
@@ -663,3 +705,3 @@ }); | ||
| if (merged) { | ||
| applied.push(...removeRedundantDuplicates(target, scope.label, splitSelectors(target.selector))); | ||
| applied.push(...removeRedundantDuplicates(target, scope.label, ownSelectors(target.selector))); | ||
| } | ||
@@ -943,2 +985,6 @@ } | ||
| if (run.length) { | ||
| // Note: Replacing these scans with a rule → position `Map` is | ||
| // twice as slow. `indexOf()` stops at the hit; building the map is | ||
| // always a full pass, and each rebuild covers only a handful of | ||
| // lookups (`scope.rules` changes as merges land). | ||
| const previousIndex = scope.rules.indexOf(run.at(-1)); | ||
@@ -1308,3 +1354,3 @@ const nextIndex = scope.rules.indexOf(rule); | ||
| for (const rule of eligibleRules(scope, ignorePatterns)) { | ||
| applied.push(...removeRedundantDuplicates(rule, scope.label, splitSelectors(rule.selector))); | ||
| applied.push(...removeRedundantDuplicates(rule, scope.label, ownSelectors(rule.selector))); | ||
| } | ||
@@ -1311,0 +1357,0 @@ } |
+24
-0
@@ -316,3 +316,27 @@ import { normalizeColors } from './colors.js'; | ||
| // Memoized, and scoped to one run (see `resetPropertyCache()`): The | ||
| // merge-safety scan normalizes the same property names over and over within | ||
| // a run, but a long-lived process (a PostCSS watch build) must not | ||
| // accumulate every custom property name—think generated or hashed ones—it | ||
| // has ever seen. One cache per mode, rather than one keyed by mode plus | ||
| // name: Building that composite key would allocate a string per lookup. | ||
| const propCacheDefault = new Map(); | ||
| const propCacheAggressive = new Map(); | ||
| export function resetPropertyCache() { | ||
| propCacheDefault.clear(); | ||
| propCacheAggressive.clear(); | ||
| } | ||
| export function normalizeProp(prop, aggressive = false) { | ||
| const cache = aggressive ? propCacheAggressive : propCacheDefault; | ||
| const cached = cache.get(prop); | ||
| if (cached !== undefined) return cached; | ||
| const computed = computeNormalizeProp(prop, aggressive); | ||
| cache.set(prop, computed); | ||
| return computed; | ||
| } | ||
| function computeNormalizeProp(prop, aggressive) { | ||
| const trimmed = prop.trim(); | ||
@@ -319,0 +343,0 @@ // Custom property names are case-sensitive (`--Foo` !== `--foo`); every |
+26
-5
| // Splits a selector list on top-level commas only, respecting commas nested | ||
| // inside `:is(a, b)`, `[attr="a,b"]`, and similar constructs | ||
| // inside `:is(a, b)`, `[attr="a,b"]`, and similar constructs. Memoized on the | ||
| // same per-run terms as `subjectIdentities` below—the same handful of | ||
| // selector strings get split repeatedly within a run (eligibility filtering, | ||
| // merge-safety scans, occurrence reporting), but selector text is unbounded | ||
| // across runs. | ||
| // | ||
| // The cached array is shared between callers, so anything handing it to the | ||
| // outside world (`Occurrence.selectors`, `AppliedChange.selectors`) copies | ||
| // it first—see `index.js`. | ||
| const splitCache = new Map(); | ||
| export function splitSelectors(selectorList) { | ||
| const cached = splitCache.get(selectorList); | ||
| if (cached) return cached; | ||
| const computed = computeSplitSelectors(selectorList); | ||
| splitCache.set(selectorList, computed); | ||
| return computed; | ||
| } | ||
| function computeSplitSelectors(selectorList) { | ||
| const selectors = []; | ||
@@ -194,3 +213,3 @@ let depth = 0; | ||
| // Memoized, and scoped to one consolidation run (see | ||
| // `resetSubjectIdentities()`): The merge-safety scan asks about the same | ||
| // `resetSelectorCaches()`): The merge-safety scan asks about the same | ||
| // selectors over and over within a run, but a long-lived process (a PostCSS | ||
@@ -201,6 +220,8 @@ // watch build, say) must not accumulate every selector—think generated or | ||
| // Called at the start of each top-level `dedupRoot()` run—the only flow that | ||
| // reaches `subjectIdentity()` | ||
| export function resetSubjectIdentities() { | ||
| // Called at the start of each top-level `analyzeRoot()`/`dedupRoot()` run— | ||
| // `subjectIdentity()` is reached only from the latter, but `splitSelectors()` | ||
| // from both, so both have to bound their caches | ||
| export function resetSelectorCaches() { | ||
| subjectIdentities.clear(); | ||
| splitCache.clear(); | ||
| } | ||
@@ -207,0 +228,0 @@ |
+48
-7
@@ -91,4 +91,38 @@ // Maps each shorthand property to the longhands it sets—physical and | ||
| function expandProperty(prop) { | ||
| return new Set([prop, ...(SHORTHAND_LONGHANDS[prop] ?? [])]); | ||
| // Each shorthand’s expansion, including its own name—built once at module | ||
| // load rather than per call, since `propertiesOverlap()` below sits on the | ||
| // merge-safety hot path and ran once per declaration per scanned rule | ||
| const EXPANSIONS = new Map( | ||
| Object.entries(SHORTHAND_LONGHANDS).map(([prop, longhands]) => [prop, new Set([prop, ...longhands])]), | ||
| ); | ||
| // Every property name taking part in any shorthand/longhand relation, on | ||
| // either side. A name outside this set expands to just itself, so it can | ||
| // only ever overlap a property it’s equal to—which lets the overwhelmingly | ||
| // common case (`color`, `display`, …) answer from one lookup. | ||
| const RELATED_PROPS = new Set([ | ||
| ...Object.keys(SHORTHAND_LONGHANDS), | ||
| ...Object.values(SHORTHAND_LONGHANDS).flat(), | ||
| ]); | ||
| // Keyed by first property, then second—nested rather than by a joined | ||
| // string, so a lookup allocates nothing. Bounded by `RELATED_PROPS` (a fixed | ||
| // vocabulary), so unlike the per-run caches elsewhere this one never needs | ||
| // clearing. | ||
| const overlapCache = new Map(); | ||
| function computeOverlap(a, b) { | ||
| const expandedA = EXPANSIONS.get(a); | ||
| const expandedB = EXPANSIONS.get(b); | ||
| // Neither is a shorthand, so both expand to just themselves—and equality | ||
| // was already ruled out by the caller | ||
| if (!expandedA && !expandedB) return false; | ||
| if (!expandedA) return expandedB.has(a); | ||
| if (!expandedB) return expandedA.has(b); | ||
| const [small, large] = expandedA.size <= expandedB.size ? [expandedA, expandedB] : [expandedB, expandedA]; | ||
| for (const prop of small) { | ||
| if (large.has(prop)) return true; | ||
| } | ||
| return false; | ||
| } | ||
@@ -104,8 +138,15 @@ | ||
| if (a === b) return true; | ||
| const expandedA = expandProperty(a); | ||
| const expandedB = expandProperty(b); | ||
| for (const prop of expandedA) { | ||
| if (expandedB.has(prop)) return true; | ||
| if (!RELATED_PROPS.has(a) || !RELATED_PROPS.has(b)) return false; | ||
| let row = overlapCache.get(a); | ||
| if (!row) { | ||
| row = new Map(); | ||
| overlapCache.set(a, row); | ||
| } | ||
| return false; | ||
| const cached = row.get(b); | ||
| if (cached !== undefined) return cached; | ||
| const overlap = computeOverlap(a, b); | ||
| row.set(b, overlap); | ||
| return overlap; | ||
| } |
203460
2.72%3318
3.53%