postcss-merge-rules 9.0.0 → 9.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -5,67 +5,28 @@ import {
5
5
  sameVendor,
6
6
  noVendor,
7
7
  } from './lib/ensureCompatibility.js';
8
- import { isConflictingProp } from './lib/propertyRelations.js';
8
+ import {
9
+ filterRuleIntersections,
10
+ indexOfDeclaration,
11
+ intersect,
12
+ sameDeclarationsAndOrder,
13
+ } from './lib/declarations.js';
14
+ import { flush, getDecls, getMeta } from './lib/rule-meta.js';
15
+ import {
16
+ buildMergedRule,
17
+ mergeParents,
18
+ mergeWithNextRule,
19
+ } from './lib/rule-rewrite.js';
9
20
 
10
21
  /** @import browserslist from 'browserslist' */
11
22
  const { sameParent } = cssnanoUtils;
12
23
  /** @import {Declaration, Rule} from 'postcss' */
13
24
  /**
14
- * @param {Declaration} a
15
- * @param {Declaration} b
16
- * @return {boolean}
17
- */
18
- function declarationIsEqual(a, b) {
19
- return (
20
- a.important === b.important && a.prop === b.prop && a.value === b.value
21
- );
22
- }
23
-
24
- /**
25
- * @param {Declaration[]} array
26
- * @param {Declaration} decl
27
- * @return {number}
28
- */
29
- function indexOfDeclaration(array, decl) {
30
- return array.findIndex((d) => declarationIsEqual(d, decl));
31
- }
32
-
33
- /**
34
- * Returns filtered array of matched or unmatched declarations
35
- * @param {Declaration[]} a
36
- * @param {Declaration[]} b
37
- * @param {boolean} [not=false]
38
- * @return {Declaration[]}
39
- */
40
- function intersect(a, b, not) {
41
- return a.filter((c) => {
42
- const index = indexOfDeclaration(b, c) !== -1;
43
- return not ? !index : index;
44
- });
45
- }
46
-
47
- /**
48
- * @param {Declaration[]} a
49
- * @param {Declaration[]} b
50
- * @return {boolean}
51
- */
52
- function sameDeclarationsAndOrder(a, b) {
53
- if (a.length !== b.length) {
54
- return false;
55
- }
56
- return a.every((d, index) => declarationIsEqual(d, b[index]));
57
- }
58
-
59
- /**
60
- * RuleMeta stores metadata about a `Rule` during the merging process.
61
- * It tracks selectors and declarations without re-parsing the AST many times.
62
- *
63
25
  * @typedef {Object} RuleMeta
64
26
  * @property {string[]} selectors - Array of selector strings for the rule
65
27
  * @property {Declaration[]} declarations - Array of declaration nodes for the rule
66
28
  * @property {boolean} dirty - Whether the selectors have been modified and need flushing
67
29
  */
68
-
69
30
  /**
70
31
  * @param {Rule} ruleA
71
32
  * @param {Rule} ruleB
@@ -128,378 +89,24 @@ function canMerge(
128
89
  function isRuleOrAtRule(node) {
129
90
  return node.type === 'rule' || node.type === 'atrule';
130
91
  }
131
- /**
132
- * @param {import('postcss').ChildNode} node
133
- * @return {node is Declaration}
134
- */
135
- function isDeclaration(node) {
136
- return node.type === 'decl';
137
- }
138
-
139
- /**
140
- * Retrieves or initializes virtual metadata for a PostCSS rule.
141
- *
142
- * This metadata caches selectors and declarations to avoid expensive AST
143
- * re-parsing, especially for the selectors.
144
- *
145
- * @param {Rule} rule The PostCSS rule to get metadata for.
146
- * @param {WeakMap<Rule, RuleMeta>} [ruleMeta] The metadata cache.
147
- * @return {RuleMeta} The rule's virtual metadata.
148
- */
149
- function getMeta(rule, ruleMeta) {
150
- if (ruleMeta && rule) {
151
- let meta = ruleMeta.get(rule);
152
- if (!meta && rule.nodes) {
153
- meta = {
154
- selectors: rule.selectors,
155
- declarations: rule.nodes.filter(isDeclaration),
156
- dirty: false,
157
- };
158
- ruleMeta.set(rule, meta);
159
- }
160
- return meta ?? { selectors: [], declarations: [], dirty: false };
161
- }
162
- return {
163
- selectors: rule?.selectors ?? [],
164
- declarations: rule?.nodes?.filter(isDeclaration) ?? [],
165
- dirty: false,
166
- };
167
- }
168
-
169
- /**
170
- * Commits virtual metadata changes back to the actual PostCSS rule.
171
- *
172
- * @param {Rule} rule The PostCSS rule to flush.
173
- * @param {WeakMap<Rule, RuleMeta>} ruleMeta The metadata cache.
174
- */
175
- function flush(rule, ruleMeta) {
176
- const meta = ruleMeta.get(rule);
177
- if (meta && meta.dirty) {
178
- rule.selector = meta.selectors.join(',');
179
- meta.dirty = false;
180
- }
181
- }
182
-
183
- /**
184
- * @param {Rule} rule
185
- * @return {Declaration[]}
186
- */
187
- function getDecls(rule) {
188
- return rule.nodes.filter(isDeclaration);
189
- }
190
-
191
- /**
192
- * @param {...Rule} rules
193
- * @return {number}
194
- */
195
- function ruleLength(...rules) {
196
- return rules.map((r) => (r.nodes.length ? String(r) : '')).join('').length;
197
- }
198
-
199
- /**
200
- * @param {Rule} first
201
- * @param {Rule} second
202
- * @return {boolean} merged
203
- */
204
- function mergeParents(first, second) {
205
- // Null check for detached rules
206
- if (!first.parent || !second.parent) {
207
- return false;
208
- }
209
-
210
- // Check if parents share node
211
- if (first.parent === second.parent) {
212
- return false;
213
- }
214
-
215
- // sameParent() already called by canMerge()
216
-
217
- second.remove();
218
- first.parent.append(second);
219
- return true;
220
- }
221
-
222
- /**
223
- * @param {Rule} second
224
- * @return {Rule | null}
225
- */
226
- function getNextRule(second) {
227
- let nextRule = second.next();
228
- if (!nextRule) {
229
- // Grab next cousin
230
- const parentSibling =
231
- /** @type {import('postcss').Container | undefined} */ (
232
- /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
233
- second.parent
234
- ).next()
235
- );
236
- nextRule = parentSibling && parentSibling.nodes && parentSibling.nodes[0];
237
- }
238
- return nextRule?.type === 'rule' ? nextRule : null;
239
- }
240
-
241
- /**
242
- * @param {Rule} first
243
- * @param {Rule} second
244
- * @param {Declaration[]} intersection
245
- * @param {string[]} browsers
246
- * @param {Map<string, boolean>} compatibilityCache
247
- * @param {WeakSet<Rule>} ruleCache
248
- * @param {WeakMap<Rule, RuleMeta>} ruleMeta
249
- * @return {{first: Rule, second: Rule, intersection: Declaration[]}}
250
- */
251
- function mergeWithNextRule(
252
- first,
253
- second,
254
- intersection,
255
- browsers,
256
- compatibilityCache,
257
- ruleCache,
258
- ruleMeta
259
- ) {
260
- const nextRule = getNextRule(second);
261
- if (
262
- !nextRule ||
263
- !canMerge(
264
- second,
265
- nextRule,
266
- browsers,
267
- compatibilityCache,
268
- ruleCache,
269
- ruleMeta
270
- )
271
- ) {
272
- return { first, second, intersection };
273
- }
274
-
275
- const nextIntersection = intersect(
276
- getMeta(second, ruleMeta).declarations,
277
- getMeta(nextRule, ruleMeta).declarations
278
- );
279
- if (nextIntersection.length <= intersection.length) {
280
- return { first, second, intersection };
281
- }
282
-
283
- mergeParents(second, nextRule);
284
- return { first: second, second: nextRule, intersection: nextIntersection };
285
- }
286
92
 
287
93
  /**
288
- * Returns true if hoisting `candidate` cannot reverse the cascade: every
289
- * declaration that might override it is itself being
290
- * hoisted, in the same relative order, so declarations move together.
291
- *
292
- * @param {Declaration} candidate
293
- * @param {number} candidateIndex
294
- * @param {Declaration[]} hoistCandidates
295
- * @param {Declaration[]} earlierRuleDeclarations
296
- * @return {boolean}
94
+ * @param {Rule | import('postcss').Container<import('postcss').ChildNode>} node
95
+ * @return {import('postcss').Container<import('postcss').ChildNode> | undefined}
297
96
  */
298
- function hoistingPreservesOverrideOrder(
299
- candidate,
300
- candidateIndex,
301
- hoistCandidates,
302
- earlierRuleDeclarations
303
- ) {
304
- const indexInEarlierRule = indexOfDeclaration(
305
- earlierRuleDeclarations,
306
- candidate
307
- );
308
- const overridesInEarlierRule = earlierRuleDeclarations
309
- .slice(indexInEarlierRule + 1)
310
- .filter((d) => isConflictingProp(d.prop, candidate.prop));
311
- if (overridesInEarlierRule.length === 0) {
312
- return true;
313
- }
314
- const overridesAmongCandidates = hoistCandidates
315
- .slice(candidateIndex + 1)
316
- .filter((d) => isConflictingProp(d.prop, candidate.prop));
317
- if (overridesInEarlierRule.length !== overridesAmongCandidates.length) {
318
- return false;
319
- }
320
- return overridesInEarlierRule.every((d, index) =>
321
- declarationIsEqual(d, overridesAmongCandidates[index])
97
+ function getParent(node) {
98
+ return /** @type {import('postcss').Container<import('postcss').ChildNode> | undefined} */ (
99
+ node.parent
322
100
  );
323
101
  }
324
102
 
325
- /**
326
- * True if the later rule has an unclaimed declaration equal to `candidate`.
327
- * Records its index in `claimedIndices`, so no other candidate can match the
328
- * same declaration. Always false when the later rule contains `all`,
329
- * since `all` resets everything except `direction`/`unicode-bidi`.
330
- *
331
- * @param {Declaration} candidate
332
- * @param {Declaration[]} laterDeclarations
333
- * @param {Set<number>} claimedIndices
334
- * @return {boolean}
335
- */
336
- function claimMatchInLaterRule(candidate, laterDeclarations, claimedIndices) {
337
- const matchIndex = laterDeclarations.findIndex(
338
- (d, index) =>
339
- !claimedIndices.has(index) && isConflictingProp(d.prop, candidate.prop)
340
- );
341
- if (matchIndex === -1) {
342
- return false;
343
- }
344
- if (!declarationIsEqual(laterDeclarations[matchIndex], candidate)) {
345
- return false;
346
- }
347
- if (
348
- candidate.prop.toLowerCase() !== 'direction' &&
349
- candidate.prop.toLowerCase() !== 'unicode-bidi' &&
350
- laterDeclarations.some(
351
- (declaration) => declaration.prop.toLowerCase() === 'all'
352
- )
353
- ) {
354
- return false;
355
- }
356
- claimedIndices.add(matchIndex);
357
- return true;
358
- }
359
-
360
- /**
361
- * Narrows the declarations shared by two adjacent rules down to those that can
362
- * safely move into a merged rule, along with the indices of the later rule's
363
- * declarations they claim.
364
- *
365
- * The merged rule is emitted after the earlier rule's remaining declarations,
366
- * so hoisting a declaration that a later declaration in the earlier rule
367
- * overrides would reverse the cascade and make the previously dead value
368
- * effective again, unless that overriding declaration is hoisted too, so both
369
- * move together and keep their relative order.
370
- *
371
- * Dropping a candidate can invalidate one that was only admitted because it
372
- * travelled with it, so the pass repeats until the surviving set stops
373
- * shrinking.
374
- *
375
- * @param {Declaration[]} hoistCandidates
376
- * @param {Declaration[]} earlierRuleDeclarations
377
- * @param {Declaration[]} laterRuleDeclarations
378
- * @return {{intersection: Declaration[], claimedIndices: Set<number>}}
379
- */
380
- function filterRuleIntersections(
381
- hoistCandidates,
382
- earlierRuleDeclarations,
383
- laterRuleDeclarations
384
- ) {
385
- let remainingCandidates = hoistCandidates;
386
- for (;;) {
387
- // A candidate rejected by the override check never claims a match.
388
- const claimedIndices = new Set();
389
- const survivors = remainingCandidates.filter(
390
- (candidate, candidateIndex) =>
391
- hoistingPreservesOverrideOrder(
392
- candidate,
393
- candidateIndex,
394
- remainingCandidates,
395
- earlierRuleDeclarations
396
- ) &&
397
- claimMatchInLaterRule(candidate, laterRuleDeclarations, claimedIndices)
398
- );
399
- if (
400
- survivors.length === remainingCandidates.length ||
401
- survivors.length === 0
402
- ) {
403
- return { intersection: survivors, claimedIndices };
404
- }
405
- remainingCandidates = survivors;
103
+ /** @param {Rule | null} rule @param {import('postcss').Container<import('postcss').ChildNode>} container */
104
+ function isDescendant(rule, container) {
105
+ let parent = rule ? getParent(rule) : undefined;
106
+ for (; parent; parent = getParent(parent)) {
107
+ if (parent === container) return true;
406
108
  }
407
- }
408
-
409
- /**
410
- * @param {Rule} first
411
- * @param {Rule} second
412
- * @param {Declaration[]} intersection
413
- * @param {Set<number>} claimedIndices Positions of the declarations the
414
- * intersection claimed in `second`, which the merged rule replaces.
415
- * @param {WeakSet<Rule>} ruleCache
416
- * @param {WeakMap<Rule, RuleMeta>} ruleMeta
417
- * @return {Rule}
418
- */
419
- function buildMergedRule(
420
- first,
421
- second,
422
- intersection,
423
- claimedIndices,
424
- ruleCache,
425
- ruleMeta
426
- ) {
427
- const receivingBlock = second.clone();
428
- const firstSelectors = getMeta(first, ruleMeta).selectors;
429
- const secondSelectors = getMeta(second, ruleMeta).selectors;
430
-
431
- receivingBlock.selector = [...firstSelectors, ...secondSelectors].join();
432
- receivingBlock.nodes = [];
433
-
434
- /** @type {import('postcss').Container<import('postcss').ChildNode>} */ (
435
- second.parent
436
- ).insertBefore(second, receivingBlock);
437
-
438
- const firstClone = first.clone({ selectors: firstSelectors });
439
- const secondClone = second.clone({ selectors: secondSelectors });
440
-
441
- /**
442
- * @param {(decl: Declaration) => void} callback
443
- * @this void
444
- * @return {(decl: Declaration) => void}
445
- */
446
- function moveDecl(callback) {
447
- return (decl) => {
448
- if (indexOfDeclaration(intersection, decl) !== -1) {
449
- callback.call(this, decl);
450
- }
451
- };
452
- }
453
- firstClone.walkDecls(
454
- moveDecl(
455
- /**
456
- * @param {Declaration} decl
457
- */
458
- (decl) => {
459
- decl.remove();
460
- receivingBlock.append(decl);
461
- }
462
- )
463
- );
464
- // Remove exactly the declarations that were claimed: matching by value would
465
- // also delete a duplicate that re-asserts the value later in the rule, after
466
- // an overriding declaration that stays behind.
467
- let laterIndex = 0;
468
- secondClone.walkDecls((decl) => {
469
- if (claimedIndices.has(laterIndex++)) {
470
- decl.remove();
471
- }
472
- });
473
-
474
- // Ensure original rules are flushed for accurate length comparison
475
- if (ruleMeta) {
476
- flush(first, ruleMeta);
477
- flush(second, ruleMeta);
478
- }
479
-
480
- const merged = ruleLength(firstClone, receivingBlock, secondClone);
481
- const original = ruleLength(first, second);
482
- if (merged < original) {
483
- first.replaceWith(firstClone);
484
- second.replaceWith(secondClone);
485
- for (const r of [firstClone, receivingBlock, secondClone]) {
486
- if (r.nodes.length === 0) {
487
- r.remove();
488
- }
489
- }
490
- if (!secondClone.parent) {
491
- ruleCache?.add(receivingBlock);
492
- return receivingBlock;
493
- }
494
- ruleCache?.add(receivingBlock);
495
- ruleCache?.add(secondClone);
496
- ruleMeta?.delete(first);
497
- ruleMeta?.delete(second);
498
- return secondClone;
499
- }
500
-
501
- receivingBlock.remove();
502
- return second;
109
+ return false;
503
110
  }
504
111
 
505
112
  /**
@@ -509,7 +116,8 @@ function buildMergedRule(
509
116
  * @param {Map<string, boolean>} compatibilityCache
510
117
  * @param {WeakSet<Rule>} ruleCache
511
118
  * @param {WeakMap<Rule, RuleMeta>} ruleMeta
512
- * @return {Rule} mergedRule
119
+ * @param {(rule: Rule, oldParent: import('postcss').Container, newParent: import('postcss').Container) => void} [onMove]
120
+ * @return {{rule: Rule, replacements: Rule[], replaced: Rule[], changed: Rule[], moved: boolean}}
513
121
  */
514
122
  function partialMerge(
515
123
  first,
@@ -517,7 +125,8 @@ function partialMerge(
517
125
  browsers,
518
126
  compatibilityCache,
519
127
  ruleCache,
520
- ruleMeta
128
+ ruleMeta,
129
+ onMove
521
130
  ) {
522
131
  if (ruleMeta) {
523
132
  flush(first, ruleMeta);
@@ -526,7 +135,13 @@ function partialMerge(
526
135
  const metaSecond = getMeta(second, ruleMeta);
527
136
  let intersection = intersect(metaFirst.declarations, metaSecond.declarations);
528
137
  if (intersection.length === 0) {
529
- return second;
138
+ return {
139
+ rule: second,
140
+ replacements: [],
141
+ replaced: [],
142
+ changed: [],
143
+ moved: false,
144
+ };
530
145
  }
531
146
  const mergedNext = mergeWithNextRule(
532
147
  first,
@@ -535,7 +150,9 @@ function partialMerge(
535
150
  browsers,
536
151
  compatibilityCache,
537
152
  ruleCache,
538
- ruleMeta
153
+ ruleMeta,
154
+ canMerge,
155
+ onMove
539
156
  );
540
157
  const mergedFirst = mergedNext.first;
541
158
  const mergedSecond = mergedNext.second;
@@ -555,10 +172,16 @@ function partialMerge(
555
172
 
556
173
  if (intersection.length === 0) {
557
174
  // Nothing to merge
558
- return mergedSecond;
175
+ return {
176
+ rule: mergedSecond,
177
+ replacements: [],
178
+ replaced: [],
179
+ changed: mergedNext.moved ? [mergedFirst, mergedSecond] : [],
180
+ moved: mergedNext.moved,
181
+ };
559
182
  }
560
183
 
561
- return buildMergedRule(
184
+ const merged = buildMergedRule(
562
185
  mergedFirst,
563
186
  mergedSecond,
564
187
  intersection,
@@ -566,6 +189,12 @@ function partialMerge(
566
189
  ruleCache,
567
190
  ruleMeta
568
191
  );
192
+ return {
193
+ ...merged,
194
+ replaced: merged.replacements.length ? [mergedFirst, mergedSecond] : [],
195
+ changed: mergedNext.moved ? [mergedFirst, mergedSecond] : [],
196
+ moved: mergedNext.moved,
197
+ };
569
198
  }
570
199
 
571
200
  /**
@@ -573,98 +202,501 @@ function partialMerge(
573
202
  * @param {Map<string, boolean>} compatibilityCache
574
203
  * @param {WeakSet<Rule>} ruleCache
575
204
  * @param {WeakMap<Rule, RuleMeta>} ruleMeta
576
- * @return {{ merger: (rule: Rule) => void, clean: () => void }}
205
+ * @return {{ run: (root: import('postcss').Root) => void }}
577
206
  */
578
207
  function selectorMerger(browsers, compatibilityCache, ruleCache, ruleMeta) {
579
- /** @type {Rule | null} */
580
- let cache = null;
581
- return {
582
- merger(rule) {
583
- // Prime the cache with the first rule, or alternately ensure that it is
584
- // safe to merge both declarations before continuing
585
- if (
586
- !cache ||
587
- !canMerge(
588
- rule,
589
- cache,
590
- browsers,
591
- compatibilityCache,
592
- ruleCache,
593
- ruleMeta
594
- )
208
+ /** @typedef {RuleMeta & {selectorKey: string, contentKey: string, declarationIds: number[], declarationIdSet: Set<number>, previous: Rule | null, next: Rule | null, active: boolean, version: number, sourceOrder: number}} ActiveMeta */
209
+ /** @type {WeakMap<Rule, ActiveMeta>} */
210
+ const active = new WeakMap();
211
+ /** @type {Map<string, number>} */
212
+ const declarationIds = new Map();
213
+ let nextDeclarationId = 0;
214
+ let nextSourceOrder = 0;
215
+ /** @typedef {{first: Rule | null, last: Rule | null}} Boundary */
216
+ /** @type {WeakMap<import('postcss').Container, Boundary>} */
217
+ const boundaries = new WeakMap();
218
+
219
+ /** @param {Declaration} declaration */
220
+ function getDeclarationId(declaration) {
221
+ const key = `${declaration.prop}:${declaration.value}:${declaration.important}`;
222
+ let id = declarationIds.get(key);
223
+ if (id === undefined) {
224
+ id = nextDeclarationId++;
225
+ declarationIds.set(key, id);
226
+ }
227
+ return id;
228
+ }
229
+
230
+ /** @param {Rule} rule @param {number} [sourceOrder] */
231
+ function refresh(rule, sourceOrder) {
232
+ const previous = active.get(rule);
233
+ const base = getMeta(rule, ruleMeta);
234
+ const ids = base.declarations.map(getDeclarationId);
235
+ /** @type {ActiveMeta} */
236
+ const meta = Object.assign(base, {
237
+ selectorKey: base.selectors.join(','),
238
+ contentKey: `${base.selectors.join(',')}|${ids.join(',')}`,
239
+ declarationIds: ids,
240
+ declarationIdSet: new Set(ids),
241
+ previous: previous?.previous ?? null,
242
+ next: previous?.next ?? null,
243
+ active: true,
244
+ version: (previous?.version ?? 0) + 1,
245
+ sourceOrder: sourceOrder ?? previous?.sourceOrder ?? nextSourceOrder++,
246
+ });
247
+ active.set(rule, meta);
248
+ return meta;
249
+ }
250
+ /** @param {Rule} first @param {Rule} second */
251
+ function hasPossibleSharedDeclaration(first, second) {
252
+ const a = active.get(first);
253
+ const b = active.get(second);
254
+ if (!a?.active || !b?.active) return false;
255
+ // Empty rules and equivalent at-rule boundaries retain the legacy
256
+ // structural rewrites even though they cannot share a declaration.
257
+ const structuralRewrite =
258
+ (a.declarations.length === 0 && b.declarations.length === 0) ||
259
+ (first.parent !== second.parent && sameParent(first, second));
260
+ if (a.selectorKey === b.selectorKey || structuralRewrite) return true;
261
+ for (const id of a.declarationIds) {
262
+ if (b.declarationIdSet.has(id)) return true;
263
+ }
264
+ return false;
265
+ }
266
+ /** @param {Rule} first @param {Rule} second */
267
+ function estimatedBenefit(first, second) {
268
+ const a = active.get(first);
269
+ const b = active.get(second);
270
+ if (!a || !b) return 0;
271
+ if (a.selectorKey === b.selectorKey) {
272
+ return a.declarationIds.length + b.declarationIds.length;
273
+ }
274
+ let benefit = 0;
275
+ for (const id of a.declarationIds) {
276
+ if (b.declarationIdSet.has(id)) benefit++;
277
+ }
278
+ return benefit;
279
+ }
280
+ /** @param {Rule} rule */
281
+ function detach(rule) {
282
+ const meta = active.get(rule);
283
+ if (!meta?.active) return;
284
+ const { previous, next } = meta;
285
+ let container = getParent(rule);
286
+ for (; container; container = getParent(container)) {
287
+ const boundary = boundaries.get(container);
288
+ if (!boundary) continue;
289
+ if (boundary.first === rule) {
290
+ boundary.first = next && isDescendant(next, container) ? next : null;
291
+ }
292
+ if (boundary.last === rule) {
293
+ boundary.last =
294
+ previous && isDescendant(previous, container) ? previous : null;
295
+ }
296
+ }
297
+ if (previous) {
298
+ const previousMeta = active.get(previous);
299
+ if (previousMeta) previousMeta.next = next;
300
+ }
301
+ if (next) {
302
+ const nextMeta = active.get(next);
303
+ if (nextMeta) nextMeta.previous = previous;
304
+ }
305
+ meta.active = false;
306
+ }
307
+ /** @param {Rule[]} rules */
308
+ function captureBoundaries(rules) {
309
+ const captured = new Map();
310
+ for (const rule of rules) {
311
+ for (
312
+ let container = getParent(rule);
313
+ container;
314
+ container = getParent(container)
595
315
  ) {
596
- if (cache) {
597
- flush(cache, ruleMeta);
316
+ const boundary = boundaries.get(container);
317
+ if (boundary && !captured.has(container)) {
318
+ captured.set(container, {
319
+ first: boundary.first,
320
+ last: boundary.last,
321
+ });
598
322
  }
599
- cache = rule;
600
- return;
601
323
  }
602
- // Ensure that we don't deduplicate the same rule; this is sometimes
603
- // caused by a partial merge
604
- if (cache === rule) {
605
- cache = rule;
606
- return;
324
+ }
325
+ return captured;
326
+ }
327
+ /**
328
+ * Repair the active order for a rule moved by mergeParents. The old and new
329
+ * ancestor paths are the only paths whose first/last descendant can change.
330
+ * @param {Rule} rule
331
+ * @param {import('postcss').Container} oldParent
332
+ * @param {import('postcss').Container} newParent
333
+ */
334
+ // eslint-disable-next-line complexity
335
+ function repairMove(rule, oldParent, newParent) {
336
+ const oldAncestors = [];
337
+ /** @type {import('postcss').Container<import('postcss').ChildNode> | undefined} */
338
+ let container = oldParent;
339
+ for (; container; container = getParent(container)) {
340
+ oldAncestors.push(container);
341
+ }
342
+ const newAncestors = [];
343
+ container = newParent;
344
+ for (; container; container = getParent(container)) {
345
+ newAncestors.push(container);
346
+ }
347
+
348
+ const meta = active.get(rule);
349
+ if (!meta?.active) return;
350
+ const { previous, next } = meta;
351
+ if (previous) {
352
+ const previousMeta = active.get(previous);
353
+ if (previousMeta) previousMeta.next = next;
354
+ }
355
+ if (next) {
356
+ const nextMeta = active.get(next);
357
+ if (nextMeta) nextMeta.previous = previous;
358
+ }
359
+
360
+ for (const ancestor of oldAncestors) {
361
+ const boundary = boundaries.get(ancestor);
362
+ if (!boundary) continue;
363
+ if (boundary.first === rule) {
364
+ boundary.first = next && isDescendant(next, ancestor) ? next : null;
607
365
  }
366
+ if (boundary.last === rule) {
367
+ boundary.last =
368
+ previous && isDescendant(previous, ancestor) ? previous : null;
369
+ }
370
+ }
608
371
 
609
- // Parents merge: check if the rules have same parents, but not same parent nodes
610
- mergeParents(cache, rule);
372
+ const destinationBoundary = boundaries.get(newParent);
373
+ const destinationLast = destinationBoundary?.last ?? null;
374
+ meta.previous = destinationLast;
375
+ meta.next = destinationLast
376
+ ? (active.get(destinationLast)?.next ?? null)
377
+ : null;
378
+ if (destinationLast) {
379
+ const destinationMeta = active.get(destinationLast);
380
+ if (destinationMeta) destinationMeta.next = rule;
381
+ }
382
+ if (meta.next) {
383
+ const nextMeta = active.get(meta.next);
384
+ if (nextMeta) nextMeta.previous = rule;
385
+ }
611
386
 
612
- // Merge when declarations are exactly equal
613
- // e.g. h1 { color: red } h2 { color: red }
614
- if (
615
- sameDeclarationsAndOrder(
616
- getMeta(rule, ruleMeta).declarations,
617
- getMeta(cache, ruleMeta).declarations
618
- )
619
- ) {
620
- const metaRule = getMeta(rule, ruleMeta);
621
- const metaCache = getMeta(cache, ruleMeta);
622
- metaRule.selectors = [...metaCache.selectors, ...metaRule.selectors];
623
- metaRule.dirty = true;
624
- cache.remove();
625
- ruleMeta?.delete(cache);
626
- cache = rule;
627
- ruleCache?.add(rule);
628
- return;
387
+ for (const ancestor of newAncestors) {
388
+ const boundary = boundaries.get(ancestor);
389
+ if (!boundary) continue;
390
+ boundary.first ??= rule;
391
+ boundary.last = rule;
392
+ }
393
+ }
394
+ /** @param {import('postcss').Container<import('postcss').ChildNode>} container @param {{previous: Rule | null}} state */
395
+ function indexContainer(container, state) {
396
+ /** @type {Rule | null} */
397
+ let first = null;
398
+ /** @type {Rule | null} */
399
+ let last = null;
400
+ for (const node of container.nodes ?? []) {
401
+ if (node.type === 'rule') {
402
+ const rule = /** @type {Rule} */ (node);
403
+ const meta = refresh(rule);
404
+ meta.previous = state.previous;
405
+ if (state.previous) {
406
+ /** @type {ActiveMeta} */ (active.get(state.previous)).next = rule;
407
+ }
408
+ meta.next = null;
409
+ first ??= rule;
410
+ state.previous = rule;
411
+ last = rule;
412
+ indexContainer(rule, state);
413
+ const nested = boundaries.get(rule);
414
+ if (nested?.last) last = nested.last;
415
+ state.previous = last;
416
+ boundaries.set(rule, { first: rule, last });
417
+ continue;
629
418
  }
630
- // Merge when both selectors are exactly equal
631
- // e.g. a { color: blue } a { font-weight: bold }
632
- if (
633
- getMeta(cache, ruleMeta).selectors.join(',') ===
634
- getMeta(rule, ruleMeta).selectors.join(',')
635
- ) {
636
- const cachedDecls = getMeta(cache, ruleMeta).declarations;
637
- rule.walk((node) => {
638
- if (
639
- node.type === 'decl' &&
640
- indexOfDeclaration(cachedDecls, node) !== -1
641
- ) {
642
- node.remove();
643
- return;
419
+ if ('nodes' in node && node.nodes) {
420
+ indexContainer(
421
+ /** @type {import('postcss').Container} */ (node),
422
+ state
423
+ );
424
+ const nested = boundaries.get(
425
+ /** @type {import('postcss').Container} */ (node)
426
+ );
427
+ if (nested?.first) {
428
+ if (!first) first = nested.first;
429
+ last = nested.last;
430
+ }
431
+ }
432
+ }
433
+ boundaries.set(container, { first, last });
434
+ }
435
+ /** @param {import('postcss').Root} root */
436
+ function seed(root) {
437
+ // indexContainer establishes links as it descends, then exposes the root
438
+ // entry point used to seed candidates.
439
+ indexContainer(root, { previous: null });
440
+ return boundaries.get(root)?.first ?? null;
441
+ }
442
+ return {
443
+ // Keep the heap scheduling and rewrite repair together for the AST mutation.
444
+ // eslint-disable-next-line complexity
445
+ run(root) {
446
+ /** @typedef {{first: Rule, second: Rule, firstVersion: number, secondVersion: number, benefit: number, firstSourceOrder: number, contentKey: string}} Candidate */
447
+ /** @type {Candidate[]} */
448
+ let candidates = [];
449
+ let needsGlobalReseed = false;
450
+ /** @param {Candidate} a @param {Candidate} b */
451
+ // The comparator stays local with the heap's candidate type and ordering contract.
452
+ // eslint-disable-next-line unicorn/consistent-function-scoping
453
+ const comesBefore = (a, b) => {
454
+ if (a.benefit !== b.benefit) return a.benefit > b.benefit;
455
+ if (a.firstSourceOrder !== b.firstSourceOrder) {
456
+ return a.firstSourceOrder < b.firstSourceOrder;
457
+ }
458
+ return a.contentKey < b.contentKey;
459
+ };
460
+ /** @param {Candidate} candidate */
461
+ const pushCandidate = (candidate) => {
462
+ let index = candidates.length;
463
+ candidates.push(candidate);
464
+ while (index > 0) {
465
+ const parent = Math.floor((index - 1) / 2);
466
+ if (comesBefore(candidates[parent], candidate)) break;
467
+ candidates[index] = candidates[parent];
468
+ index = parent;
469
+ }
470
+ candidates[index] = candidate;
471
+ };
472
+ const popCandidate = () => {
473
+ const candidate = candidates[0];
474
+ const last = candidates.pop();
475
+ if (last && candidates.length) {
476
+ let index = 0;
477
+ while (true) {
478
+ const left = index * 2 + 1;
479
+ if (left >= candidates.length) break;
480
+ let child = left;
481
+ const right = left + 1;
482
+ if (
483
+ right < candidates.length &&
484
+ comesBefore(candidates[right], candidates[left])
485
+ )
486
+ child = right;
487
+ if (!comesBefore(candidates[child], last)) break;
488
+ candidates[index] = candidates[child];
489
+ index = child;
644
490
  }
645
- /** @type {Rule} */ (cache).append(node);
491
+ candidates[index] = last;
492
+ }
493
+ return candidate;
494
+ };
495
+ /** @param {Rule | null} first @param {Rule | null} second */
496
+ const enqueue = (first, second) => {
497
+ if (!first || !second || !hasPossibleSharedDeclaration(first, second)) {
498
+ return;
499
+ }
500
+ const firstMeta = active.get(first);
501
+ const secondMeta = active.get(second);
502
+ if (!firstMeta || !secondMeta) return;
503
+ pushCandidate({
504
+ first,
505
+ second,
506
+ firstVersion: firstMeta.version,
507
+ secondVersion: secondMeta.version,
508
+ benefit: estimatedBenefit(first, second),
509
+ firstSourceOrder: firstMeta.sourceOrder,
510
+ contentKey: `${firstMeta.contentKey}|${secondMeta.contentKey}`,
646
511
  });
647
- getMeta(cache, ruleMeta).declarations = getDecls(cache);
648
- rule.remove();
649
- ruleMeta?.delete(rule);
650
- return;
651
- }
652
- // Partial merge: check if the rule contains a subset of the last; if
653
- // so create a joined selector with the subset, if smaller.
654
- cache = partialMerge(
655
- cache,
656
- rule,
657
- browsers,
658
- compatibilityCache,
659
- ruleCache,
660
- ruleMeta
661
- );
662
- },
663
- // Flushes any remaining rule in the cache to avoid memory leaks.
664
- clean() {
665
- if (cache) {
666
- flush(cache, ruleMeta);
512
+ };
513
+ /** @param {Rule} rule */
514
+ const enqueueNeighbors = (rule) => {
515
+ const meta = active.get(rule);
516
+ if (!meta?.active) return;
517
+ enqueue(meta.previous, rule);
518
+ enqueue(rule, meta.next);
519
+ };
520
+ const reseedCandidates = () => {
521
+ candidates = [];
522
+ const initialRule = seed(root);
523
+ for (
524
+ let rule = initialRule;
525
+ rule;
526
+ rule = active.get(rule)?.next ?? null
527
+ ) {
528
+ enqueue(rule, active.get(rule)?.next ?? null);
529
+ }
530
+ };
531
+ reseedCandidates();
532
+ /** @param {Rule} first @param {Rule} second */
533
+ const checkedCanMerge = (first, second) => {
534
+ return canMerge(
535
+ first,
536
+ second,
537
+ browsers,
538
+ compatibilityCache,
539
+ ruleCache,
540
+ ruleMeta
541
+ );
542
+ };
543
+ while (candidates.length || needsGlobalReseed) {
544
+ if (!candidates.length) {
545
+ needsGlobalReseed = false;
546
+ reseedCandidates();
547
+ if (!candidates.length) break;
548
+ }
549
+ const candidate = popCandidate();
550
+ const first = candidate.first;
551
+ const second = candidate.second;
552
+ const firstMeta = active.get(first);
553
+ const secondMeta = active.get(second);
554
+ if (
555
+ !firstMeta?.active ||
556
+ !secondMeta?.active ||
557
+ firstMeta.next !== second ||
558
+ firstMeta.version !== candidate.firstVersion ||
559
+ secondMeta.version !== candidate.secondVersion
560
+ )
561
+ continue;
562
+ if (!checkedCanMerge(first, second)) continue;
563
+ // Equivalent at-rule moves preserve depth-first leaf-rule order.
564
+ const oldParent = second.parent;
565
+ const newParent = first.parent;
566
+ const moved = mergeParents(first, second);
567
+ if (moved && oldParent && newParent) {
568
+ repairMove(second, oldParent, newParent);
569
+ }
570
+ if (
571
+ sameDeclarationsAndOrder(
572
+ getMeta(second, ruleMeta).declarations,
573
+ getMeta(first, ruleMeta).declarations
574
+ )
575
+ ) {
576
+ const metaSecond = getMeta(second, ruleMeta);
577
+ metaSecond.selectors = [
578
+ ...getMeta(first, ruleMeta).selectors,
579
+ ...metaSecond.selectors,
580
+ ];
581
+ metaSecond.dirty = true;
582
+ flush(second, ruleMeta);
583
+ detach(first);
584
+ first.remove();
585
+ ruleMeta?.delete(first);
586
+ refresh(second);
587
+ ruleCache?.add(second);
588
+ enqueueNeighbors(second);
589
+ continue;
590
+ }
591
+ if (
592
+ getMeta(first, ruleMeta).selectors.join(',') ===
593
+ getMeta(second, ruleMeta).selectors.join(',')
594
+ ) {
595
+ const cachedDecls = getMeta(first, ruleMeta).declarations;
596
+ second.walk((node) => {
597
+ if (
598
+ node.type === 'decl' &&
599
+ indexOfDeclaration(cachedDecls, node) !== -1
600
+ )
601
+ node.remove();
602
+ else first.append(node);
603
+ });
604
+ getMeta(first, ruleMeta).declarations = getDecls(first);
605
+ detach(second);
606
+ second.remove();
607
+ ruleMeta?.delete(second);
608
+ refresh(first);
609
+ enqueueNeighbors(first);
610
+ continue;
611
+ }
612
+ const replacedRules = [first, second];
613
+ const capturedBoundaries = captureBoundaries(replacedRules);
614
+ const outcome = partialMerge(
615
+ first,
616
+ second,
617
+ browsers,
618
+ compatibilityCache,
619
+ ruleCache,
620
+ ruleMeta,
621
+ (rule, movedFrom, movedTo) => repairMove(rule, movedFrom, movedTo)
622
+ );
623
+ if (outcome.replacements.length) {
624
+ const firstWasBoundary =
625
+ !outcome.moved &&
626
+ outcome.replaced.some((rule) =>
627
+ [...capturedBoundaries.values()].some((b) => b.first === rule)
628
+ );
629
+ const lastWasBoundary =
630
+ !outcome.moved &&
631
+ outcome.replaced.some((rule) =>
632
+ [...capturedBoundaries.values()].some((b) => b.last === rule)
633
+ );
634
+ const previous = active.get(outcome.replaced[0])?.previous ?? null;
635
+ const lastReplaced = /** @type {Rule} */ (outcome.replaced.at(-1));
636
+ const next = active.get(lastReplaced)?.next ?? null;
637
+ const sourceOrder = active.get(outcome.replaced[0])?.sourceOrder;
638
+ for (const rule of outcome.replaced) {
639
+ detach(rule);
640
+ ruleMeta?.delete(rule);
641
+ }
642
+ let prior = previous;
643
+ for (const replacement of outcome.replacements) {
644
+ const meta = refresh(replacement, sourceOrder);
645
+ meta.previous = prior;
646
+ if (prior) {
647
+ /** @type {ActiveMeta} */ (active.get(prior)).next = replacement;
648
+ }
649
+ prior = replacement;
650
+ }
651
+ if (prior) {
652
+ /** @type {ActiveMeta} */ (active.get(prior)).next = next;
653
+ }
654
+ if (next) {
655
+ /** @type {ActiveMeta} */ (active.get(next)).previous = prior;
656
+ }
657
+ if (firstWasBoundary && outcome.replacements[0]?.parent) {
658
+ for (
659
+ let container = getParent(outcome.replacements[0]);
660
+ container;
661
+ container = getParent(container)
662
+ ) {
663
+ const boundary = boundaries.get(container);
664
+ if (boundary) boundary.first = outcome.replacements[0];
665
+ }
666
+ }
667
+ const lastReplacement = /** @type {Rule} */ (
668
+ outcome.replacements.at(-1)
669
+ );
670
+ if (lastWasBoundary && lastReplacement.parent) {
671
+ for (
672
+ let container = getParent(lastReplacement);
673
+ container;
674
+ container = getParent(container)
675
+ ) {
676
+ const boundary = boundaries.get(container);
677
+ if (boundary) boundary.last = lastReplacement;
678
+ }
679
+ }
680
+ if (outcome.moved) {
681
+ // repairMove keeps the current queue safe to drain. Rebuild once
682
+ // afterwards so a cascade of independent moves does not rescan
683
+ // the complete rule list after every boundary change.
684
+ needsGlobalReseed = true;
685
+ }
686
+ for (const replacement of outcome.replacements) {
687
+ enqueueNeighbors(replacement);
688
+ }
689
+ enqueue(previous, outcome.replacements[0] ?? next);
690
+ enqueue(outcome.replacements.at(-1) ?? previous, next);
691
+ } else if (moved || outcome.moved) {
692
+ for (const changed of [first, second, ...outcome.changed]) {
693
+ refresh(changed);
694
+ enqueueNeighbors(changed);
695
+ }
696
+ needsGlobalReseed = true;
697
+ }
667
698
  }
699
+ root.walkRules((rule) => flush(rule, ruleMeta));
668
700
  },
669
701
  };
670
702
  }
@@ -701,14 +733,13 @@ function pluginCreator(opts = {}) {
701
733
  * @param {import('postcss').Root} css
702
734
  */
703
735
  OnceExit(css) {
704
- const { merger, clean } = selectorMerger(
736
+ const merger = selectorMerger(
705
737
  browsers,
706
738
  compatibilityCache,
707
739
  ruleCache,
708
740
  ruleMeta
709
741
  );
710
- css.walkRules(merger);
711
- clean();
742
+ merger.run(css);
712
743
  },
713
744
  };
714
745
  },