diff --git a/CHANGELOG.md b/CHANGELOG.md index 9691e9a160f9..9138c16e2290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/offsets.js b/src/lib/offsets.js index eaa1fe6dd532..70afdb6197d3 100644 --- a/src/lib/offsets.js +++ b/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 @@ -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 +} diff --git a/src/lib/remap-bitfield.js b/src/lib/remap-bitfield.js new file mode 100644 index 000000000000..3ddaf20234c4 --- /dev/null +++ b/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< { 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,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)); } @@ -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` +
+
+
+
+
+
+ `, + }, + ], + 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; + } + `) + }) +}) diff --git a/tests/variants.test.js b/tests/variants.test.js index d6022b8675ab..33b5d2ffe899 100644 --- a/tests/variants.test.js +++ b/tests/variants.test.js @@ -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; } `)