Fix parallel variant ordering clash (#9282)

* Remove remnants of the user layer

It hasn’t been used in a while

* Rewrite sort offset generation

* wip

* wip

wip

* Handle parasite utilities

* wip

* wip

* Make parallel variants sorting more resillient

It’s not perfect but it’s close

* fix

* remove todo

it adds a new bit so it can’t

* Simplify getClassOrder usage

* Simplify

oops

oops

* Add parasite utility for `dark`

dark mode class name

* Cleanup

* Cleanup

* Simplify

* format files

* Fix prettier plugin to use git build of Tailwind CSS

Symlink and build instead of adding a recursive dev dependency

It breaks node < 16

* Fix prettier error

* wip

* fix test

* Update changelog

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Jordan Pittman 2022-09-09 13:12:43 -04:00 • committed by GitHub
parent 88e98f557c
commit d6bec49934
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 321 additions and 129 deletions

View file

@ -41,6 +41,13 @@ jobs:
env:
CI: true
- name: Link and build Tailwind CSS
run: |
npm run swcify
ln -nfs `pwd` node_modules/tailwindcss
env:
CI: true
- name: Test
run: npm test
env:

View file

@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix `fontFamily` config TypeScript types ([#9214](https://github.com/tailwindlabs/tailwindcss/pull/9214))
- Handle variants on complex selector utilities ([#9262](https://github.com/tailwindlabs/tailwindcss/pull/9262))
- Don't mutate shared config objects ([#9294](https://github.com/tailwindlabs/tailwindcss/pull/9294))
- Fix ordering of parallel variants ([#9282](https://github.com/tailwindlabs/tailwindcss/pull/9282))
## [3.1.8] - 2022-08-05

View file

@ -2,7 +2,6 @@ import postcss from 'postcss'
import parser from 'postcss-selector-parser'
import { resolveMatches } from './generateRules'
import bigSign from '../util/bigSign'
import escapeClassName from '../util/escapeClassName'
/** @typedef {Map<string, [any, import('postcss').Rule[]]>} ApplyCache */
@ -149,9 +148,7 @@ function buildLocalApplyCache(root, context) {
/** @type {ApplyCache} */
let cache = new Map()
let highestOffset = context.layerOrder.user >> 4n
root.walkRules((rule, idx) => {
root.walkRules((rule) => {
// Ignore rules generated by Tailwind
for (let node of pathToRoot(rule)) {
if (node.raws.tailwind?.layer !== undefined) {
@ -161,6 +158,7 @@ function buildLocalApplyCache(root, context) {
// Clone what's required to represent this singular rule in the tree
let container = nestedClone(rule)
let sort = context.offsets.create('user')
for (let className of extractClasses(rule)) {
let list = cache.get(className) || []
@ -169,7 +167,7 @@ function buildLocalApplyCache(root, context) {
list.push([
{
layer: 'user',
sort: BigInt(idx) + highestOffset,
sort,
important: false,
},
container,
@ -553,16 +551,12 @@ function processApply(root, context, localCache) {
}
// Insert it
siblings.push([
// Ensure that when we are sorting, that we take the layer order into account
{ ...meta, sort: meta.sort | context.layerOrder[meta.layer] },
root.nodes[0],
])
siblings.push([meta.sort, root.nodes[0]])
}
}
// Inject the rules, sorted, correctly
let nodes = siblings.sort(([a], [z]) => bigSign(a.sort - z.sort)).map((s) => s[1])
let nodes = context.offsets.sort(siblings).map((s) => s[1])
// `parent` refers to the node at `.abc` in: .abc { @apply mt-2 }
parent.after(nodes)

View file

@ -1,7 +1,6 @@
import LRU from 'quick-lru'
import * as sharedState from './sharedState'
import { generateRules } from './generateRules'
import bigSign from '../util/bigSign'
import log from '../util/log'
import cloneNodes from '../util/cloneNodes'
import { defaultExtractor } from './defaultExtractor'
@ -74,8 +73,13 @@ function getClassCandidates(content, extractor, candidates, seen) {
}
}
/**
*
* @param {[import('./offsets.js').RuleOffset, import('postcss').Node][]} rules
* @param {*} context
*/
function buildStylesheet(rules, context) {
let sortedRules = rules.sort(([a], [z]) => bigSign(a - z))
let sortedRules = context.offsets.sort(rules)
let returnValue = {
base: new Set(),
@ -83,48 +87,10 @@ function buildStylesheet(rules, context) {
components: new Set(),
utilities: new Set(),
variants: new Set(),
// All the CSS that is not Tailwind related can be put in this bucket. This
// will make it easier to later use this information when we want to
// `@apply` for example. The main reason we do this here is because we
// still need to make sure the order is correct. Last but not least, we
// will make sure to always re-inject this section into the css, even if
// certain rules were not used. This means that it will look like a no-op
// from the user's perspective, but we gathered all the useful information
// we need.
user: new Set(),
}
for (let [sort, rule] of sortedRules) {
if (sort >= context.minimumScreen) {
returnValue.variants.add(rule)
continue
}
if (sort & context.layerOrder.base) {
returnValue.base.add(rule)
continue
}
if (sort & context.layerOrder.defaults) {
returnValue.defaults.add(rule)
continue
}
if (sort & context.layerOrder.components) {
returnValue.components.add(rule)
continue
}
if (sort & context.layerOrder.utilities) {
returnValue.utilities.add(rule)
continue
}
if (sort & context.layerOrder.user) {
returnValue.user.add(rule)
continue
}
returnValue[sort.layer].add(rule)
}
return returnValue

View file

@ -146,9 +146,9 @@ function applyVariant(variant, matches, context) {
let fn = parseVariant(selector)
let sort = Array.from(context.variantOrder.values()).pop() << 1n
let sort = context.offsets.recordVariant(variant)
context.variantMap.set(variant, [[sort, fn]])
context.variantOrder.set(variant, sort)
}
if (context.variantMap.has(variant)) {
@ -227,11 +227,7 @@ function applyVariant(variant, matches, context) {
// where you keep handling jobs until everything is done and each job can queue more
// jobs if needed.
variantFunctionTuples.push([
// TODO: This could have potential bugs if we shift the sort order from variant A far
// enough into the sort space of variant B. The chances are low, but if this happens
// then this might be the place too look at. One potential solution to this problem is
// reserving additional X places for these 'unknown' variants in between.
variantSort | BigInt(idx << ruleWithVariant.length),
context.offsets.applyParallelOffset(variantSort, idx),
variantFunction,
// If the clone has been modified we have to pass that back
@ -298,7 +294,7 @@ function applyVariant(variant, matches, context) {
let withOffset = [
{
...meta,
sort: variantSort | meta.sort,
sort: context.offsets.applyVariantOffset(meta.sort, variantSort),
collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats),
isArbitraryVariant: isArbitraryValue(variant),
},
@ -409,9 +405,11 @@ function extractArbitraryProperty(classCandidate, context) {
return null
}
let sort = context.offsets.arbitraryProperty()
return [
[
{ sort: context.arbitraryPropertiesSort, layer: 'utilities' },
{ sort, layer: 'utilities' },
() => ({
[asClass(classCandidate)]: {
[property]: normalized,
@ -704,7 +702,7 @@ function generateRules(candidates, context) {
context.candidateRuleCache.set(candidate, rules)
for (const match of matches) {
let [{ sort, layer, options }, rule] = match
let [{ sort, options }, rule] = match
if (options.respectImportant && strategy) {
let container = postcss.root({ nodes: [rule.clone()] })
@ -712,7 +710,7 @@ function generateRules(candidates, context) {
rule = container.nodes[0]
}
let newEntry = [sort | context.layerOrder[layer], rule]
let newEntry = [sort, rule]
rules.add(newEntry)
context.ruleCache.add(newEntry)
allRules.push(newEntry)

239
src/lib/offsets.js Normal file
View file

@ -0,0 +1,239 @@
// @ts-check
import bigSign from '../util/bigSign'
/**
* @typedef {'base' | 'defaults' | 'components' | 'utilities' | 'variants' | 'user'} Layer
*/
/**
* @typedef {object} RuleOffset
* @property {Layer} layer The layer that this rule belongs to
* @property {Layer} parentLayer The layer that this rule originally belonged to. Only different from layer if this is a variant.
* @property {bigint} arbitrary 0n if false, 1n if true
* @property {bigint} variants Dynamic size. 1 bit per registered variant. 0n means no variants
* @property {bigint} parallelIndex Rule index for the parallel variant. 0 if not applicable.
* @property {bigint} index Index of the rule / utility in it's given *parent* layer. Monotonically increasing.
*/
export class Offsets {
constructor() {
/**
* Offsets for the next rule in a given layer
*
* @type {Record<Layer, bigint>}
*/
this.offsets = {
defaults: 0n,
base: 0n,
components: 0n,
utilities: 0n,
variants: 0n,
user: 0n,
}
/**
* Positions for a given layer
*
* @type {Record<Layer, bigint>}
*/
this.layerPositions = {
defaults: 0n,
base: 1n,
components: 2n,
utilities: 3n,
// There isn't technically a "user" layer, but we need to give it a position
// Because it's used for ordering user-css from @apply
user: 4n,
variants: 5n,
}
/**
* The total number of functions currently registered across all variants (including arbitrary variants)
*
* @type {bigint}
*/
this.reservedVariantBits = 0n
/**
* Positions for a given variant
*
* @type {Map<string, bigint>}
*/
this.variantOffsets = new Map()
}
/**
* @param {Layer} layer
* @returns {RuleOffset}
*/
create(layer) {
return {
layer,
parentLayer: layer,
arbitrary: 0n,
variants: 0n,
parallelIndex: 0n,
index: this.offsets[layer]++,
}
}
/**
* @returns {RuleOffset}
*/
arbitraryProperty() {
return {
...this.create('utilities'),
arbitrary: 1n,
}
}
/**
* Get the offset for a variant
*
* @param {string} variant
* @param {number} index
* @returns {RuleOffset}
*/
forVariant(variant, index = 0) {
let offset = this.variantOffsets.get(variant)
if (offset === undefined) {
throw new Error(`Cannot find offset for unknown variant ${variant}`)
}
return {
...this.create('variants'),
variants: offset << BigInt(index),
}
}
/**
* @param {RuleOffset} rule
* @param {RuleOffset} variant
* @returns {RuleOffset}
*/
applyVariantOffset(rule, variant) {
return {
...rule,
layer: 'variants',
parentLayer: rule.layer === 'variants' ? rule.parentLayer : rule.layer,
variants: rule.variants | variant.variants,
// TODO: Technically this is wrong. We should be handling parallel index on a per variant basis.
// We'll take the max of all the parallel indexes for now.
// @ts-ignore
parallelIndex: max([rule.parallelIndex, variant.parallelIndex]),
}
}
/**
* @param {RuleOffset} offset
* @param {number} parallelIndex
* @returns {RuleOffset}
*/
applyParallelOffset(offset, parallelIndex) {
return {
...offset,
parallelIndex: BigInt(parallelIndex),
}
}
/**
* Each variant gets 1 bit per function / rule registered.
* This is because multiple variants can be applied to a single rule and we need to know which ones are present and which ones are not.
* Additionally, every unique group of variants is grouped together in the stylesheet.
*
* This grouping is order-independent. For instance, we do not differentiate between `hover:focus` and `focus:hover`.
*
* @param {string[]} variants
* @param {(name: string) => number} getLength
*/
recordVariants(variants, getLength) {
for (const variant of variants) {
this.recordVariant(variant, getLength(variant))
}
}
/**
* The same as `recordVariants` but for a single arbitrary variant at runtime.
* @param {string} variant
* @param {number} fnCount
*
* @returns {RuleOffset} The highest offset for this variant
*/
recordVariant(variant, fnCount = 1) {
this.variantOffsets.set(variant, 1n << this.reservedVariantBits)
// Ensure space is reserved for each "function" in the parallel variant
// by offsetting the next variant by the number of parallel variants
// in the one we just added.
// Single functions that return parallel variants are NOT handled separately here
// They're offset by 1 (or the number of functions) as usual
// And each rule returned is tracked separately since the functions are evaluated lazily.
// @see `RuleOffset.parallelIndex`
this.reservedVariantBits += BigInt(fnCount)
return {
...this.create('variants'),
variants: 1n << this.reservedVariantBits,
}
}
/**
* @param {RuleOffset} a
* @param {RuleOffset} b
* @returns {bigint}
*/
compare(a, b) {
// Sort layers together
if (a.layer !== b.layer) {
return this.layerPositions[a.layer] - this.layerPositions[b.layer]
}
// Sort variants in the order they were registered
if (a.variants !== b.variants) {
return a.variants - b.variants
}
// Make sure each rule returned by a parallel variant is sorted in ascending order
if (a.parallelIndex !== b.parallelIndex) {
return a.parallelIndex - b.parallelIndex
}
// Always sort arbitrary properties after other utilities
if (a.arbitrary !== b.arbitrary) {
return a.arbitrary - b.arbitrary
}
// Sort utilities, components, etc… in the order they were registered
return a.index - b.index
}
/**
* @template T
* @param {[RuleOffset, T][]} list
* @returns {[RuleOffset, T][]}
*/
sort(list) {
return list.sort(([a], [b]) => bigSign(this.compare(a, b)))
}
}
/**
*
* @param {bigint[]} nums
* @returns {bigint|null}
*/
function max(nums) {
let max = null
for (const num of nums) {
max = max ?? num
max = max > num ? max : num
}
return max
}

View file

@ -12,7 +12,6 @@ import isPlainObject from '../util/isPlainObject'
import escapeClassName from '../util/escapeClassName'
import nameClass, { formatClass } from '../util/nameClass'
import { coerceValue } from '../util/pluginUtils'
import bigSign from '../util/bigSign'
import { variantPlugins, corePlugins } from '../corePlugins'
import * as sharedState from './sharedState'
import { env } from './sharedState'
@ -22,6 +21,7 @@ import negateValue from '../util/negateValue'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { generateRules } from './generateRules'
import { hasContentChanged } from './cacheInvalidation.js'
import { Offsets } from './offsets.js'
let MATCH_VARIANT = Symbol()
@ -201,6 +201,13 @@ export function parseVariant(variant) {
}
}
/**
*
* @param {any} tailwindConfig
* @param {any} context
* @param {object} param2
* @param {Offsets} param2.offsets
*/
function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) {
function getConfigValue(path, defaultValue) {
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
@ -255,7 +262,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
addBase(base) {
for (let [identifier, rule] of withIdentifiers(base)) {
let prefixedIdentifier = prefixIdentifier(identifier, {})
let offset = offsets.base++
let offset = offsets.create('base')
if (!context.candidateRuleMap.has(prefixedIdentifier)) {
context.candidateRuleMap.set(prefixedIdentifier, [])
@ -284,7 +291,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offsets.base++, layer: 'defaults' }, rule])
.push([{ sort: offsets.create('defaults'), layer: 'defaults' }, rule])
}
},
addComponents(components, options) {
@ -307,7 +314,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offsets.components++, layer: 'components', options }, rule])
.push([{ sort: offsets.create('components'), layer: 'components', options }, rule])
}
},
addUtilities(utilities, options) {
@ -330,7 +337,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
context.candidateRuleMap
.get(prefixedIdentifier)
.push([{ sort: offsets.utilities++, layer: 'utilities', options }, rule])
.push([{ sort: offsets.create('utilities'), layer: 'utilities', options }, rule])
}
},
matchUtilities: function (utilities, options) {
@ -341,7 +348,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
options = { ...defaultOptions, ...options }
let offset = offsets.utilities++
let offset = offsets.create('utilities')
for (let identifier in utilities) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
@ -393,7 +400,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
options = { ...defaultOptions, ...options }
let offset = offsets.components++
let offset = offsets.create('components')
for (let identifier in components) {
let prefixedIdentifier = prefixIdentifier(identifier, options)
@ -641,13 +648,10 @@ function resolvePlugins(context, root) {
function registerPlugins(plugins, context) {
let variantList = []
let variantMap = new Map()
let offsets = {
defaults: 0n,
base: 0n,
components: 0n,
utilities: 0n,
user: 0n,
}
context.variantMap = variantMap
let offsets = new Offsets()
context.offsets = offsets
let classList = new Set()
@ -668,49 +672,17 @@ function registerPlugins(plugins, context) {
}
}
let highestOffset = ((args) => args.reduce((m, e) => (e > m ? e : m)))([
offsets.base,
offsets.defaults,
offsets.components,
offsets.utilities,
offsets.user,
])
let reservedBits = BigInt(highestOffset.toString(2).length)
// A number one less than the top range of the highest offset area
// so arbitrary properties are always sorted at the end.
context.arbitraryPropertiesSort = ((1n << reservedBits) << 0n) - 1n
context.layerOrder = {
defaults: (1n << reservedBits) << 0n,
base: (1n << reservedBits) << 1n,
components: (1n << reservedBits) << 2n,
utilities: (1n << reservedBits) << 3n,
user: (1n << reservedBits) << 4n,
}
reservedBits += 5n
let offset = 0
context.variantOrder = new Map(
variantList
.map((variant, i) => {
let variantFunctions = variantMap.get(variant).length
let bits = (1n << BigInt(i + offset)) << reservedBits
offset += variantFunctions - 1
return [variant, bits]
})
.sort(([, a], [, z]) => bigSign(a - z))
)
context.minimumScreen = [...context.variantOrder.values()].shift()
// Make sure to record bit masks for every variant
offsets.recordVariants(variantList, (variant) => variantMap.get(variant).length)
// Build variantMap
for (let [variantName, variantFunctions] of variantMap.entries()) {
let sort = context.variantOrder.get(variantName)
context.variantMap.set(
variantName,
variantFunctions.map((variantFunction, idx) => [sort << BigInt(idx), variantFunction])
variantFunctions.map((variantFunction, idx) => [
offsets.forVariant(variantName, idx),
variantFunction,
])
)
}
@ -816,26 +788,41 @@ function registerPlugins(plugins, context) {
}
}
let darkClassName = [].concat(context.tailwindConfig.darkMode ?? 'media')[1] ?? 'dark'
// A list of utilities that are used by certain Tailwind CSS utilities but
// that don't exist on their own. This will result in them "not existing" and
// sorting could be weird since you still require them in order to make the
// host utitlies work properly. (Thanks Biology)
let parasiteUtilities = new Set([prefix(context, 'group'), prefix(context, 'peer')])
// host utilities work properly. (Thanks Biology)
let parasiteUtilities = [
prefix(context, darkClassName),
prefix(context, 'group'),
prefix(context, 'peer'),
]
context.getClassOrder = function getClassOrder(classes) {
let sortedClassNames = new Map()
for (let [sort, rule] of generateRules(new Set(classes), context)) {
if (sortedClassNames.has(rule.raws.tailwind.candidate)) continue
sortedClassNames.set(rule.raws.tailwind.candidate, sort)
// Non-util classes won't be generated, so we default them to null
let sortedClassNames = new Map(classes.map((className) => [className, null]))
// Sort all classes in order
// Non-tailwind classes won't be generated and will be left as `null`
let rules = generateRules(new Set(classes), context)
rules = context.offsets.sort(rules)
let idx = BigInt(parasiteUtilities.length)
for (const [, rule] of rules) {
sortedClassNames.set(rule.raws.tailwind.candidate, idx++)
}
return classes.map((className) => {
let order = sortedClassNames.get(className) ?? null
let parasiteIndex = parasiteUtilities.indexOf(className)
if (order === null && parasiteUtilities.has(className)) {
if (order === null && parasiteIndex !== -1) {
// This will make sure that it is at the very beginning of the
// `components` layer which technically means 'before any
// components'.
order = context.layerOrder.components
order = BigInt(parasiteIndex)
}
return [className, order]

View file

@ -324,7 +324,7 @@ it('should be possible to read theme values in arbitrary properties (without quo
it('should be possible to read theme values in arbitrary properties (with quotes)', () => {
let config = {
content: [{ raw: html`<div class="[--a:theme('colors.blue.500')] [color:var(--a)]"></div>` }],
content: [{ raw: html`<div class="[color:var(--a)] [--a:theme('colors.blue.500')]"></div>` }],
corePlugins: { preflight: false },
}
@ -338,12 +338,12 @@ it('should be possible to read theme values in arbitrary properties (with quotes
return expect(result.css).toMatchFormattedCss(css`
${defaults}
.\[--a\:theme\(\'colors\.blue\.500\'\)\] {
--a: #3b82f6;
}
.\[color\:var\(--a\)\] {
color: var(--a);
}
.\[--a\:theme\(\'colors\.blue\.500\'\)\] {
--a: #3b82f6;
}
`)
})
})

View file

@ -107,7 +107,7 @@ test('variants without & or an at-rule are ignored', () => {
test('arbitrary variants are sorted after other variants', () => {
let config = {
content: [{ raw: html`<div class="[&>*]:underline underline lg:underline"></div>` }],
content: [{ raw: html`<div class="underline lg:underline [&>*]:underline"></div>` }],
corePlugins: { preflight: false },
}

View file

@ -3,7 +3,7 @@ import { run, html, css, defaults } from './util/run'
it('should be possible to use contrast-more and contrast-less variants', () => {
let config = {
content: [
{ raw: html`<div class="contrast-more:bg-pink-500 contrast-less:bg-black bg-white"></div>` },
{ raw: html`<div class="bg-white contrast-more:bg-pink-500 contrast-less:bg-black"></div>` },
],
corePlugins: { preflight: false },
}