Skip to content

Commit

Permalink
Make arbitrary variant sorting deterministic
Browse files Browse the repository at this point in the history
  • Loading branch information
thecrypticace committed Dec 8, 2022
1 parent 12dac7d commit 428f560
Show file tree
Hide file tree
Showing 4 changed files with 185 additions and 5 deletions.
62 changes: 62 additions & 0 deletions src/lib/offsets.js
@@ -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
Expand Down Expand Up @@ -243,12 +244,73 @@ 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]) => a.localeCompare(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)))
}
}
Expand Down
82 changes: 82 additions & 0 deletions src/lib/remap-bitfield.js
@@ -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
}
36 changes: 36 additions & 0 deletions tests/arbitrary-variants.test.js
Expand Up @@ -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;
}
`)
})
})
10 changes: 5 additions & 5 deletions tests/variants.test.js
Expand Up @@ -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;
}
`)
Expand Down

0 comments on commit 428f560

Please sign in to comment.