Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sort arbitrary variants deterministically regardless of content order #10016

Merged
merged 5 commits into from
Dec 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 82 additions & 1 deletion src/lib/offsets.js
Original file line number Diff line number Diff line change
@@ -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 @@ -190,7 +191,7 @@ export class Offsets {

return {
...this.create('variants'),
variants: 1n << this.reservedVariantBits,
variants: this.variantOffsets.get(variant),
}
}

Expand Down Expand Up @@ -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)))
}
}
Expand All @@ -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 changes: 82 additions & 0 deletions src/lib/remap-bitfield.js
Original file line number Diff line number Diff line change
@@ -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
}
52 changes: 44 additions & 8 deletions tests/arbitrary-variants.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
`)
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -593,22 +593,22 @@ 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 {
.\[\&_\.foo\]\:tw-bg-white .foo {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}

.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-bg-white .foo {
.foo .\[\.foo_\&\]\:tw-bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-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));
}
Expand Down 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
Original file line number Diff line number Diff line change
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