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

Collect user-defined CSS that can be used in @apply #13349

Open
wants to merge 10 commits into
base: next
Choose a base branch
from
159 changes: 159 additions & 0 deletions packages/tailwindcss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,165 @@ describe('@apply', () => {
}"
`)
})

it('should be possible to apply user-defined CSS', () => {
expect(
compileCss(css`
@theme {
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--color-red-600: #e53e3e;
}

.btn {
@apply py-2 px-3;
}

.btn-red {
@apply btn bg-red-600;
}
`),
).toMatchInlineSnapshot(`
":root {
--spacing-2: .5rem;
--spacing-3: .75rem;
--color-red-600: #e53e3e;
}

.btn {
padding-top: var(--spacing-2, .5rem);
padding-bottom: var(--spacing-2, .5rem);
padding-left: var(--spacing-3, .75rem);
padding-right: var(--spacing-3, .75rem);
}

.btn-red {
padding-top: var(--spacing-2, .5rem);
padding-bottom: var(--spacing-2, .5rem);
padding-left: var(--spacing-3, .75rem);
padding-right: var(--spacing-3, .75rem);
background-color: var(--color-red-600, #e53e3e);
}"
`)
})

it('should apply user-defined CSS that happens to be a utility class', () => {
expect(
compileCss(css`
.flex {
--display-mode: flex;
}

.example {
@apply flex;
}
`),
).toMatchInlineSnapshot(`
".flex {
--display-mode: flex;
}

.example {
--display-mode: flex;
display: flex;
}"
`)
})

it('should apply user-defined CSS that is defined after where the `@apply` is used', () => {
expect(
compileCss(css`
.example {
@apply foo;
}

.foo {
color: red;
}
`),
).toMatchInlineSnapshot(`
".example, .foo {
color: red;
}"
`)
})

it('should apply user-defined CSS that is defined multiple times', () => {
expect(
compileCss(css`
.foo {
color: red;
}

.example {
@apply foo;
}

.foo {
background-color: blue;
}
`),
).toMatchInlineSnapshot(`
".foo {
color: red;
}

.example {
color: red;
background-color: #00f;
}

.foo {
background-color: #00f;
}"
`)
})

it('should error when circular @apply is used', () => {
expect(() =>
compileCss(css`
.foo {
@apply bar;
}

.bar {
@apply baz;
}

.baz {
@apply foo;
}
`),
).toThrowErrorMatchingInlineSnapshot(
`[Error: You cannot \`@apply\` the \`foo\` utility here because it creates a circular dependency.]`,
)
})

it('should error when circular @apply is used but nested', () => {
expect(() =>
compileCss(css`
.foo {
&:hover {
@apply bar;
}
}

.bar {
&:hover {
@apply baz;
}
}

.baz {
&:hover {
@apply foo;
}
}
`),
).toThrowErrorMatchingInlineSnapshot(
`[Error: You cannot \`@apply\` the \`foo\` utility here because it creates a circular dependency.]`,
)
})
})

describe('arbitrary variants', () => {
Expand Down
150 changes: 120 additions & 30 deletions packages/tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem } from './design-system'
import { Theme } from './theme'
import { isSimpleClassSelector } from './utils/is-simple-class-selector'

export function compile(css: string): {
build(candidates: string[]): string
Expand All @@ -20,6 +21,10 @@ export function compile(css: string): {
invalidCandidates.add(candidate)
}

// Track `@apply` information
let containsAtApply = css.includes('@apply')
let userDefinedApplyables = new Map<string, AstNode[]>()

// Find all `@theme` declarations
let theme = new Theme()
let firstThemeRule: Rule | null = null
Expand All @@ -28,6 +33,33 @@ export function compile(css: string): {
walk(ast, (node, { replaceWith }) => {
if (node.kind !== 'rule') return

// Track all user-defined classes for `@apply` support
if (
containsAtApply &&
// Verify that it is a valid applyable-class. An applyable class is a
// class that is a very simple selector, like `.foo` or `.bar`, but doesn't
// contain any spaces, combinators, pseudo-selectors, pseudo-elements, or
// attribute selectors.
node.selector[0] === '.' &&

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you don't need node.selector[0] === '.' ?

Because inside isSimpleClassSelector, you perform this test with an early return:
if (selector[0] !== '.') return false

I would suggest to remove this line

Suggested change
node.selector[0] === '.' &&

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still required, if you comment this out a test should fail. This is basically for the scenario where you have .foo.bar which contains 2 classes which is not allowed.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch 😉

isSimpleClassSelector(node.selector)
) {
// Convert the class `.foo` into a candidate `foo`
let candidate = node.selector.slice(1)

// It could be that multiple definitions exist for the same class, so we
// need to track all of them.
let nodes = userDefinedApplyables.get(candidate) ?? []

// Add all children of the current rule to the list of nodes for the
// current candidate.
for (let child of node.nodes) {
nodes.push(child)
}

// Store the list of nodes for the current candidate
userDefinedApplyables.set(candidate, nodes)
}

// Drop instances of `@media reference`
//
// We support `@import "tailwindcss/theme" reference` as a way to import an external theme file
Expand Down Expand Up @@ -143,40 +175,98 @@ export function compile(css: string): {
})

// Replace `@apply` rules with the actual utility classes.
if (css.includes('@apply')) {
walk(ast, (node, { replaceWith }) => {
if (node.kind === 'rule' && node.selector[0] === '@' && node.selector.startsWith('@apply')) {
let candidates = node.selector
.slice(7 /* Ignore `@apply ` when parsing the selector */)
.trim()
.split(/\s+/g)

// Replace the `@apply` rule with the actual utility classes
{
// Parse the candidates to an AST that we can replace the `@apply` rule with.
let candidateAst = compileCandidates(candidates, designSystem, {
onInvalidCandidate: (candidate) => {
throw new Error(`Cannot apply unknown utility class: ${candidate}`)
},
}).astNodes

// Collect the nodes to insert in place of the `@apply` rule. When a
// rule was used, we want to insert its children instead of the rule
// because we don't want the wrapping selector.
let newNodes: AstNode[] = []
for (let candidateNode of candidateAst) {
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
for (let child of candidateNode.nodes) {
newNodes.push(child)
if (containsAtApply) {
walk(ast, (root) => {
if (root.kind !== 'rule') return WalkAction.Continue

// It's possible to `@apply` user-defined classes. We need to make sure
// that we never run into a situation where we are eventually applying
// the same class that we are currently processing otherwise we will end
// up in an infinite loop (circular dependency).
//
// This means that we need to track the current node as a candidate and
// error when we encounter it again.
let rootAsCandidate = root.selector.slice(1)

walk(root.nodes, (node, { replaceWith }) => {
if (
node.kind === 'rule' &&
node.selector[0] === '@' &&
node.selector.startsWith('@apply')
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd flip the condition and do an early return here.

let candidates = node.selector
.slice(7 /* Ignore `@apply ` when parsing the selector */)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really enjoy the comments in your code, you could have use

.slice('@apply '.length)

instead of

.slice(7 /* Ignore `@apply ` when parsing the selector */)

Off course the latest is slightly faster & shorter (if you omit the comment).

In the end, I prefer your version.
Longer with the comment but once you read it once, it feels right 🤓.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep we focus on performance and re-allocating and re-computing the length is unnecessary work.

.trim()
.split(/\s+/g)

// Replace the `@apply` rule with the actual utility classes
{
let newNodes: AstNode[] = []

// Collect all user-defined classes for the current candidates that
// we need to apply.
for (let candidate of candidates) {
// If the candidate is the same as the current node we are
// processing, we have a circular dependency.
if (candidate === rootAsCandidate) {
throw new Error(
`You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be worth printing the selector of root in the error message to help guide the user to the thing with the circular dependency.

)
}

let nodes = userDefinedApplyables.get(candidate)
if (!nodes) continue

for (let child of nodes) {
newNodes.push(structuredClone(child))
}
}

// Parse the candidates to an AST that we can replace the `@apply`
// rule with.
let candidateAst = compileCandidates(candidates, designSystem, {
onInvalidCandidate: (candidate) => {
// We must pass in user-defined classes and then filter them out
// here because, while they are usually not known utilities, the
// user can define a class that happens to *also* be a known
// utility.
//
// For example, given the following, `flex` counts as both a
// user-defined class and a known utility:
//
// ```css
// .flex {
// --display-mode: flex;
// }
// ```
//
// When the user then uses `@apply flex`, we want to both apply
// the user-defined class and the utility class.
if (userDefinedApplyables.has(candidate)) return

throw new Error(`Cannot apply unknown utility class: ${candidate}`)
},
}).astNodes

// Collect the nodes to insert in place of the `@apply` rule. When a
// rule was used, we want to insert its children instead of the rule
// because we don't want the wrapping selector.
for (let candidateNode of candidateAst) {
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
for (let child of candidateNode.nodes) {
newNodes.push(child)
}
} else {
newNodes.push(candidateNode)
}
} else {
newNodes.push(candidateNode)
}
}

replaceWith(newNodes)
replaceWith(newNodes)
}
}
}
})

return WalkAction.Skip
})
}

Expand Down
42 changes: 42 additions & 0 deletions packages/tailwindcss/src/utils/is-simple-class-selector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, it } from 'vitest'
import { isSimpleClassSelector } from './is-simple-class-selector'

it.each([
// Simple class selector
['.foo', true],

// Class selectors with escaped characters
['.w-\\[123px\\]', true],
['.content-\\[\\+\\>\\~\\*\\]', true],

// ID selector
['#foo', false],
['.foo#foo', false],

// Element selector
['h1', false],
['h1.foo', false],

// Attribute selector
['[data-foo]', false],
['.foo[data-foo]', false],
['[data-foo].foo', false],

// Pseudo-class selector
['.foo:hover', false],

// Additional class selector
['.foo.bar', false],

// Combinator
['.foo>.bar', false],
['.foo+.bar', false],
['.foo~.bar', false],
['.foo .bar', false],

// Selector list
['.foo, .bar', false],
['.foo,.bar', false],
])('should validate %s', (selector, expected) => {
expect(isSimpleClassSelector(selector)).toBe(expected)
})