mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Sort arbitrary variants deterministically regardless of content order (#10016)
* Fix off-by-one error in variant sort mapping This didn’t actually have any negative effect because of how it was implemented. But it only happened to work right :D * Make arbitrary variant sorting deterministic * Update changelog * Use faster byte-order comparison We really only care that the order is _always_ the same. localeCompare is still locale dependent based on environment. This ensures it’s dependent on content only. * Fix CS
This commit is contained in:
parent
fde30c307e
commit
cea08380dc
@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Improve return value of `resolveConfig`, unwrap `ResolvableTo` ([#9972](https://github.com/tailwindlabs/tailwindcss/pull/9972))
|
||||
- Clip unbalanced brackets in arbitrary values ([#9973](https://github.com/tailwindlabs/tailwindcss/pull/9973))
|
||||
- Don’t reorder webkit scrollbar pseudo elements ([#9991](https://github.com/tailwindlabs/tailwindcss/pull/9991))
|
||||
- Deterministic sorting of arbitrary variants ([#10016](https://github.com/tailwindlabs/tailwindcss/pull/10016))
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// @ts-check
|
||||
|
||||
import bigSign from '../util/bigSign'
|
||||
import { remapBitfield } from './remap-bitfield.js'
|
||||
|
||||
/**
|
||||
* @typedef {'base' | 'defaults' | 'components' | 'utilities' | 'variants' | 'user'} Layer
|
||||
@ -190,7 +191,7 @@ export class Offsets {
|
||||
|
||||
return {
|
||||
...this.create('variants'),
|
||||
variants: 1n << this.reservedVariantBits,
|
||||
variants: this.variantOffsets.get(variant),
|
||||
}
|
||||
}
|
||||
|
||||
@ -243,12 +244,68 @@ export class Offsets {
|
||||
return a.index - b.index
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbitrary variants are recorded in the order they're encountered.
|
||||
* This means that the order is not stable between environments and sets of content files.
|
||||
*
|
||||
* In order to make the order stable, we need to remap the arbitrary variant offsets to
|
||||
* be in alphabetical order starting from the offset of the first arbitrary variant.
|
||||
*/
|
||||
recalculateVariantOffsets() {
|
||||
// Sort the variants by their name
|
||||
let variants = Array.from(this.variantOffsets.entries())
|
||||
.filter(([v]) => v.startsWith('['))
|
||||
.sort(([a], [z]) => fastCompare(a, z))
|
||||
|
||||
// Sort the list of offsets
|
||||
// This is not necessarily a discrete range of numbers which is why
|
||||
// we're using sort instead of creating a range from min/max
|
||||
let newOffsets = variants.map(([, offset]) => offset).sort((a, z) => bigSign(a - z))
|
||||
|
||||
// Create a map from the old offsets to the new offsets in the new sort order
|
||||
/** @type {[bigint, bigint][]} */
|
||||
let mapping = variants.map(([, oldOffset], i) => [oldOffset, newOffsets[i]])
|
||||
|
||||
// Remove any variants that will not move letting us skip
|
||||
// remapping if everything happens to be in order
|
||||
return mapping.filter(([a, z]) => a !== z)
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {[RuleOffset, T][]} list
|
||||
* @returns {[RuleOffset, T][]}
|
||||
*/
|
||||
remapArbitraryVariantOffsets(list) {
|
||||
let mapping = this.recalculateVariantOffsets()
|
||||
|
||||
// No arbitrary variants? Nothing to do.
|
||||
// Everyhing already in order? Nothing to do.
|
||||
if (mapping.length === 0) {
|
||||
return list
|
||||
}
|
||||
|
||||
// Remap every variant offset in the list
|
||||
return list.map((item) => {
|
||||
let [offset, rule] = item
|
||||
|
||||
offset = {
|
||||
...offset,
|
||||
variants: remapBitfield(offset.variants, mapping),
|
||||
}
|
||||
|
||||
return [offset, rule]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {[RuleOffset, T][]} list
|
||||
* @returns {[RuleOffset, T][]}
|
||||
*/
|
||||
sort(list) {
|
||||
list = this.remapArbitraryVariantOffsets(list)
|
||||
|
||||
return list.sort(([a], [b]) => bigSign(this.compare(a, b)))
|
||||
}
|
||||
}
|
||||
@ -268,3 +325,27 @@ function max(nums) {
|
||||
|
||||
return max
|
||||
}
|
||||
|
||||
/**
|
||||
* A fast ASCII order string comparison function.
|
||||
*
|
||||
* Using `.sort()` without a custom compare function is faster
|
||||
* But you can only use that if you're sorting an array of
|
||||
* only strings. If you're sorting strings inside objects
|
||||
* or arrays, you need must use a custom compare function.
|
||||
*
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
*/
|
||||
function fastCompare(a, b) {
|
||||
let aLen = a.length
|
||||
let bLen = b.length
|
||||
let minLen = aLen < bLen ? aLen : bLen
|
||||
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
let cmp = a.charCodeAt(i) - b.charCodeAt(i)
|
||||
if (cmp !== 0) return cmp
|
||||
}
|
||||
|
||||
return aLen - bLen
|
||||
}
|
||||
|
||||
82
src/lib/remap-bitfield.js
Normal file
82
src/lib/remap-bitfield.js
Normal file
@ -0,0 +1,82 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* We must remap all the old bits to new bits for each set variant
|
||||
* Only arbitrary variants are considered as those are the only
|
||||
* ones that need to be re-sorted at this time
|
||||
*
|
||||
* An iterated process that removes and sets individual bits simultaneously
|
||||
* will not work because we may have a new bit that is also a later old bit
|
||||
* This means that we would be removing a previously set bit which we don't
|
||||
* want to do
|
||||
*
|
||||
* For example (assume `bN` = `1<<N`)
|
||||
* Given the "total" mapping `[[b1, b3], [b2, b4], [b3, b1], [b4, b2]]`
|
||||
* The mapping is "total" because:
|
||||
* 1. Every input and output is accounted for
|
||||
* 2. All combinations are unique
|
||||
* 3. No one input maps to multiple outputs and vice versa
|
||||
* And, given an offset with all bits set:
|
||||
* V = b1 | b2 | b3 | b4
|
||||
*
|
||||
* Let's explore the issue with removing and setting bits simultaneously:
|
||||
* V & ~b1 | b3 = b2 | b3 | b4
|
||||
* V & ~b2 | b4 = b3 | b4
|
||||
* V & ~b3 | b1 = b1 | b4
|
||||
* V & ~b4 | b2 = b1 | b2
|
||||
*
|
||||
* As you can see, we end up with the wrong result.
|
||||
* This is because we're removing a bit that was previously set.
|
||||
* And, thus the final result is missing b3 and b4.
|
||||
*
|
||||
* Now, let's explore the issue with removing the bits first:
|
||||
* V & ~b1 = b2 | b3 | b4
|
||||
* V & ~b2 = b3 | b4
|
||||
* V & ~b3 = b4
|
||||
* V & ~b4 = 0
|
||||
*
|
||||
* And then setting the bits:
|
||||
* V | b3 = b3
|
||||
* V | b4 = b3 | b4
|
||||
* V | b1 = b1 | b3 | b4
|
||||
* V | b2 = b1 | b2 | b3 | b4
|
||||
*
|
||||
* We get the correct result because we're not removing any bits that were
|
||||
* previously set thus properly remapping the bits to the new order
|
||||
*
|
||||
* To collect this into a single operation that can be done simultaneously
|
||||
* we must first create a mask for the old bits that are set and a mask for
|
||||
* the new bits that are set. Then we can remove the old bits and set the new
|
||||
* bits simultaneously in a "single" operation like so:
|
||||
* OldMask = b1 | b2 | b3 | b4
|
||||
* NewMask = b3 | b4 | b1 | b2
|
||||
*
|
||||
* So this:
|
||||
* V & ~oldMask | newMask
|
||||
*
|
||||
* Expands to this:
|
||||
* V & ~b1 & ~b2 & ~b3 & ~b4 | b3 | b4 | b1 | b2
|
||||
*
|
||||
* Which becomes this:
|
||||
* b1 | b2 | b3 | b4
|
||||
*
|
||||
* Which is the correct result!
|
||||
*
|
||||
* @param {bigint} num
|
||||
* @param {[bigint, bigint][]} mapping
|
||||
*/
|
||||
export function remapBitfield(num, mapping) {
|
||||
// Create masks for the old and new bits that are set
|
||||
let oldMask = 0n
|
||||
let newMask = 0n
|
||||
for (let [oldBit, newBit] of mapping) {
|
||||
if (num & oldBit) {
|
||||
oldMask = oldMask | oldBit
|
||||
newMask = newMask | newBit
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all old bits
|
||||
// Set all new bits
|
||||
return (num & ~oldMask) | newMask
|
||||
}
|
||||
@ -517,11 +517,11 @@ test('allows attribute variants with quotes', () => {
|
||||
expect(result.css).toMatchFormattedCss(css`
|
||||
${defaults}
|
||||
|
||||
.\[\&\[data-test\=\'2\'\]\]\:underline[data-test="2"] {
|
||||
.\[\&\[data-test\=\"2\"\]\]\:underline[data-test='2'] {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.\[\&\[data-test\=\"2\"\]\]\:underline[data-test='2'] {
|
||||
.\[\&\[data-test\=\'2\'\]\]\:underline[data-test='2'] {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
`)
|
||||
@ -554,12 +554,12 @@ test('classes in arbitrary variants should not be prefixed', () => {
|
||||
|
||||
return run(input, config).then((result) => {
|
||||
expect(result.css).toMatchFormattedCss(css`
|
||||
.foo .\[\.foo_\&\]\:tw-text-red-400 {
|
||||
.\[\&_\.foo\]\:tw-text-red-400 .foo {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.\[\&_\.foo\]\:tw-text-red-400 .foo {
|
||||
.foo .\[\.foo_\&\]\:tw-text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
@ -593,16 +593,6 @@ test('classes in the same arbitrary variant should not be prefixed', () => {
|
||||
|
||||
return run(input, config).then((result) => {
|
||||
expect(result.css).toMatchFormattedCss(css`
|
||||
.foo .\[\.foo_\&\]\:tw-bg-white {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.foo .\[\.foo_\&\]\:tw-text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.\[\&_\.foo\]\:tw-bg-white .foo {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||
@ -612,6 +602,16 @@ test('classes in the same arbitrary variant should not be prefixed', () => {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.foo .\[\.foo_\&\]\:tw-bg-white {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.foo .\[\.foo_\&\]\:tw-text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
@ -1063,3 +1063,39 @@ it('should be possible to use modifiers and arbitrary peers', () => {
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
it('Arbitrary variants are ordered alphabetically', () => {
|
||||
let config = {
|
||||
content: [
|
||||
{
|
||||
raw: html`
|
||||
<div>
|
||||
<div class="[&::b]:underline"></div>
|
||||
<div class="[&::a]:underline"></div>
|
||||
<div class="[&::c]:underline"></div>
|
||||
<div class="[&::b]:underline"></div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
],
|
||||
corePlugins: { preflight: false },
|
||||
}
|
||||
|
||||
let input = css`
|
||||
@tailwind utilities;
|
||||
`
|
||||
|
||||
return run(input, config).then((result) => {
|
||||
expect(result.css).toMatchFormattedCss(css`
|
||||
.\[\&\:\:a\]\:underline::a {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.\[\&\:\:b\]\:underline::b {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.\[\&\:\:c\]\:underline::c {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1122,25 +1122,25 @@ test('arbitrary variant selectors should not re-order scrollbar pseudo classes',
|
||||
let result = await run(input, config)
|
||||
|
||||
expect(result.css).toMatchFormattedCss(css`
|
||||
.\[\&\:\:-webkit-scrollbar\:hover\]\:underline::-webkit-scrollbar:hover {
|
||||
.\[\&\:\:-webkit-resizer\:hover\]\:underline::-webkit-resizer:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.\[\&\:\:-webkit-scrollbar-button\:hover\]\:underline::-webkit-scrollbar-button:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.\[\&\:\:-webkit-scrollbar-thumb\:hover\]\:underline::-webkit-scrollbar-thumb:hover {
|
||||
.\[\&\:\:-webkit-scrollbar-corner\:hover\]\:underline::-webkit-scrollbar-corner:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.\[\&\:\:-webkit-scrollbar-track\:hover\]\:underline::-webkit-scrollbar-track:hover {
|
||||
.\[\&\:\:-webkit-scrollbar-thumb\:hover\]\:underline::-webkit-scrollbar-thumb:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.\[\&\:\:-webkit-scrollbar-track-piece\:hover\]\:underline::-webkit-scrollbar-track-piece:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.\[\&\:\:-webkit-scrollbar-corner\:hover\]\:underline::-webkit-scrollbar-corner:hover {
|
||||
.\[\&\:\:-webkit-scrollbar-track\:hover\]\:underline::-webkit-scrollbar-track:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.\[\&\:\:-webkit-resizer\:hover\]\:underline::-webkit-resizer:hover {
|
||||
.\[\&\:\:-webkit-scrollbar\:hover\]\:underline::-webkit-scrollbar:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
`)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user