Skip to content

Commit db9cbf7

Browse files
authored
Ensure that @utility is top-level and cannot be nested (#14525)
This PR fixes an issue where we expect the `@utility` to be top-level, but we didn't enforce it. This PR enforces that the `@utility` is top-level.
1 parent 89f0047 commit db9cbf7

File tree

3 files changed

+86
-37
lines changed

3 files changed

+86
-37
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- _Experimental_: Migrate `@import "tailwindcss/tailwind.css"` to `@import "tailwindcss"` ([#14514](https://github.com/tailwindlabs/tailwindcss/pull/14514))
2020
- _Experimental_: Do not wrap comment nodes in `@layer` when running codemods ([#14517](https://github.com/tailwindlabs/tailwindcss/pull/14517))
2121
- _Experimental_: Ensure we don't lose selectors when running codemods ([#14518](https://github.com/tailwindlabs/tailwindcss/pull/14518))
22+
- Ensure that `@utility` is top-level and cannot be nested ([#14525](https://github.com/tailwindlabs/tailwindcss/pull/14525))
2223

2324
## [4.0.0-alpha.25] - 2024-09-24
2425

packages/tailwindcss/src/index.test.ts

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ describe('compiling CSS', () => {
5656
`)
5757
})
5858

59-
test('that only CSS variables are allowed', () =>
60-
expect(
59+
test('that only CSS variables are allowed', () => {
60+
return expect(
6161
compileCss(
6262
css`
6363
@theme {
@@ -79,7 +79,8 @@ describe('compiling CSS', () => {
7979
> }
8080
}
8181
]
82-
`))
82+
`)
83+
})
8384

8485
test('`@tailwind utilities` is only processed once', async () => {
8586
expect(
@@ -290,7 +291,7 @@ describe('@apply', () => {
290291
})
291292

292293
it('should error when using @apply with a utility that does not exist', () => {
293-
expect(
294+
return expect(
294295
compileCss(css`
295296
@tailwind utilities;
296297
@@ -304,7 +305,7 @@ describe('@apply', () => {
304305
})
305306

306307
it('should error when using @apply with a variant that does not exist', () => {
307-
expect(
308+
return expect(
308309
compileCss(css`
309310
@tailwind utilities;
310311
@@ -1184,8 +1185,8 @@ describe('Parsing themes values from CSS', () => {
11841185
`)
11851186
})
11861187

1187-
test('`@media theme(…)` can only contain `@theme` rules', () =>
1188-
expect(
1188+
test('`@media theme(…)` can only contain `@theme` rules', () => {
1189+
return expect(
11891190
compileCss(
11901191
css`
11911192
@media theme(reference) {
@@ -1199,7 +1200,8 @@ describe('Parsing themes values from CSS', () => {
11991200
),
12001201
).rejects.toThrowErrorMatchingInlineSnapshot(
12011202
`[Error: Files imported with \`@import "…" theme(…)\` must only contain \`@theme\` blocks.]`,
1202-
))
1203+
)
1204+
})
12031205

12041206
test('theme values added as `inline` are not wrapped in `var(…)` when used as utility values', async () => {
12051207
expect(
@@ -1550,8 +1552,8 @@ describe('Parsing themes values from CSS', () => {
15501552
})
15511553

15521554
describe('plugins', () => {
1553-
test('@plugin need a path', () =>
1554-
expect(
1555+
test('@plugin need a path', () => {
1556+
return expect(
15551557
compile(
15561558
css`
15571559
@plugin;
@@ -1565,10 +1567,11 @@ describe('plugins', () => {
15651567
}),
15661568
},
15671569
),
1568-
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
1570+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`)
1571+
})
15691572

1570-
test('@plugin can not have an empty path', () =>
1571-
expect(
1573+
test('@plugin can not have an empty path', () => {
1574+
return expect(
15721575
compile(
15731576
css`
15741577
@plugin '';
@@ -1582,10 +1585,11 @@ describe('plugins', () => {
15821585
}),
15831586
},
15841587
),
1585-
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
1588+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`)
1589+
})
15861590
1587-
test('@plugin cannot be nested.', () =>
1588-
expect(
1591+
test('@plugin cannot be nested.', () => {
1592+
return expect(
15891593
compile(
15901594
css`
15911595
div {
@@ -1601,7 +1605,8 @@ describe('plugins', () => {
16011605
}),
16021606
},
16031607
),
1604-
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot be nested.]`))
1608+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot be nested.]`)
1609+
})
16051610
16061611
test('@plugin can accept options', async () => {
16071612
expect.hasAssertions()
@@ -1694,7 +1699,7 @@ describe('plugins', () => {
16941699
})
16951700
16961701
test('@plugin options can only be simple key/value pairs', () => {
1697-
expect(
1702+
return expect(
16981703
compile(
16991704
css`
17001705
@plugin "my-plugin" {
@@ -1736,7 +1741,7 @@ describe('plugins', () => {
17361741
})
17371742
17381743
test('@plugin options can only be provided to plugins using withOptions', () => {
1739-
expect(
1744+
return expect(
17401745
compile(
17411746
css`
17421747
@plugin "my-plugin" {
@@ -1762,7 +1767,7 @@ describe('plugins', () => {
17621767
})
17631768
17641769
test('@plugin errors on array-like syntax', () => {
1765-
expect(
1770+
return expect(
17661771
compile(
17671772
css`
17681773
@plugin "my-plugin" {
@@ -1779,7 +1784,7 @@ describe('plugins', () => {
17791784
})
17801785

17811786
test('@plugin errors on object-like syntax', () => {
1782-
expect(
1787+
return expect(
17831788
compile(
17841789
css`
17851790
@plugin "my-plugin" {
@@ -1794,17 +1799,15 @@ describe('plugins', () => {
17941799
loadModule: async () => ({ module: plugin(() => {}), base: '/root' }),
17951800
},
17961801
),
1797-
).rejects.toThrowErrorMatchingInlineSnapshot(
1798-
`
1802+
).rejects.toThrowErrorMatchingInlineSnapshot(`
17991803
[Error: Unexpected \`@plugin\` option: Value of declaration \`--color: {
18001804
red: 100;
18011805
green: 200;
18021806
blue: 300;
18031807
};\` is not supported.
18041808
18051809
Using an object as a plugin option is currently only supported in JavaScript configuration files.]
1806-
`,
1807-
)
1810+
`)
18081811
})
18091812

18101813
test('addVariant with string selector', async () => {
@@ -2066,36 +2069,39 @@ describe('@source', () => {
20662069
})
20672070

20682071
describe('@variant', () => {
2069-
test('@variant must be top-level and cannot be nested', () =>
2070-
expect(
2072+
test('@variant must be top-level and cannot be nested', () => {
2073+
return expect(
20712074
compileCss(css`
20722075
.foo {
20732076
@variant hocus (&:hover, &:focus);
20742077
}
20752078
`),
2076-
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` cannot be nested.]`))
2079+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` cannot be nested.]`)
2080+
})
20772081

2078-
test('@variant with no body must include a selector', () =>
2079-
expect(
2082+
test('@variant with no body must include a selector', () => {
2083+
return expect(
20802084
compileCss(css`
20812085
@variant hocus;
20822086
`),
20832087
).rejects.toThrowErrorMatchingInlineSnapshot(
20842088
'[Error: `@variant hocus` has no selector or body.]',
2085-
))
2089+
)
2090+
})
20862091

2087-
test('@variant with selector must include a body', () =>
2088-
expect(
2092+
test('@variant with selector must include a body', () => {
2093+
return expect(
20892094
compileCss(css`
20902095
@variant hocus {
20912096
}
20922097
`),
20932098
).rejects.toThrowErrorMatchingInlineSnapshot(
20942099
'[Error: `@variant hocus` has no selector or body.]',
2095-
))
2100+
)
2101+
})
20962102

2097-
test('@variant cannot have both a selector and a body', () =>
2098-
expect(
2103+
test('@variant cannot have both a selector and a body', () => {
2104+
return expect(
20992105
compileCss(css`
21002106
@variant hocus (&:hover, &:focus) {
21012107
&:is(.potato) {
@@ -2105,7 +2111,8 @@ describe('@variant', () => {
21052111
`),
21062112
).rejects.toThrowErrorMatchingInlineSnapshot(
21072113
`[Error: \`@variant hocus\` cannot have both a selector and a body.]`,
2108-
))
2114+
)
2115+
})
21092116

21102117
describe('body-less syntax', () => {
21112118
test('selector variant', async () => {
@@ -2573,6 +2580,43 @@ describe('@variant', () => {
25732580
})
25742581
})
25752582

2583+
describe('@utility', () => {
2584+
test('@utility must be top-level and cannot be nested', () => {
2585+
return expect(
2586+
compileCss(css`
2587+
.foo {
2588+
@utility foo {
2589+
color: red;
2590+
}
2591+
}
2592+
`),
2593+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@utility\` cannot be nested.]`)
2594+
})
2595+
2596+
test('@utility must include a body', () => {
2597+
return expect(
2598+
compileCss(css`
2599+
@utility foo {
2600+
}
2601+
`),
2602+
).rejects.toThrowErrorMatchingInlineSnapshot(
2603+
`[Error: \`@utility foo\` is empty. Utilities should include at least one property.]`,
2604+
)
2605+
})
2606+
2607+
test('@utility cannot contain any special characters', () => {
2608+
return expect(
2609+
compileCss(css`
2610+
@utility 💨 {
2611+
color: red;
2612+
}
2613+
`),
2614+
).rejects.toThrowErrorMatchingInlineSnapshot(
2615+
`[Error: \`@utility 💨\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.]`,
2616+
)
2617+
})
2618+
})
2619+
25762620
test('addBase', async () => {
25772621
let { build } = await compile(
25782622
css`

packages/tailwindcss/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ async function parseCss(
8888

8989
// Collect custom `@utility` at-rules
9090
if (node.selector.startsWith('@utility ')) {
91+
if (parent !== null) {
92+
throw new Error('`@utility` cannot be nested.')
93+
}
94+
9195
let name = node.selector.slice(9).trim()
9296

9397
if (!IS_VALID_UTILITY_NAME.test(name)) {

0 commit comments

Comments
 (0)