Remove all @keyframes in reference import mode (#15581)

This PR fixes an issue where JavaScript plugins were still able to
contribute `@keyframes` when loaded inside an `@reference` import. This
was possible because we only gated the `addBase` API and not the
`addUtilities` one which also has a special branch to handle `@keyframe`
rules.

To make this work, we have to create a new instance of the plugin API
that has awareness of wether the plugin accessing it is inside reference
import mode.

## Test plan

Added a unit test that reproduces the issue observed via #15544
This commit is contained in:
Philipp Spiess 2025-01-09 17:14:07 +01:00 committed by GitHub
parent a3aec17908
commit d7c8448eec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 127 additions and 52 deletions

View File

@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve performance and memory usage ([#15529](https://github.com/tailwindlabs/tailwindcss/pull/15529))
- Ensure `@apply` rules are processed in the correct order ([#15542](https://github.com/tailwindlabs/tailwindcss/pull/15542))
- Allow negative utility names in `@utilty` ([#15573](https://github.com/tailwindlabs/tailwindcss/pull/15573))
- Remove all `@keyframes` contributed by JavaScript plugins when using `@reference` imports ([#15581](https://github.com/tailwindlabs/tailwindcss/pull/15581))
- _Upgrade (experimental)_: Do not extract class names from functions (e.g. `shadow` in `filter: 'drop-shadow(…)'`) ([#15566](https://github.com/tailwindlabs/tailwindcss/pull/15566))
### Changed

View File

@ -325,6 +325,11 @@ export function optimizeAst(ast: AstNode[]) {
// Context
else if (node.kind === 'context') {
// Remove reference imports from printing
if (node.context.reference) {
return
}
for (let child of node.nodes) {
transform(child, parent, depth)
}

View File

@ -276,14 +276,27 @@ function upgradeToFullPluginSupport({
}
}
let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig, {
set current(value: number) {
features |= value
let pluginApiConfig = {
designSystem,
ast,
resolvedConfig,
featuresRef: {
set current(value: number) {
features |= value
},
},
})
}
let pluginApi = buildPluginApi({ ...pluginApiConfig, referenceMode: false })
let referenceModePluginApi = undefined
for (let { handler, reference } of resolvedConfig.plugins) {
handler(reference ? { ...pluginApi, addBase: () => {} } : pluginApi)
if (reference) {
referenceModePluginApi ||= buildPluginApi({ ...pluginApiConfig, referenceMode: true })
handler(referenceModePluginApi)
} else {
handler(pluginApi)
}
}
// Merge the user-configured theme keys into the design system. The compat

View File

@ -86,14 +86,22 @@ export type PluginAPI = {
const IS_VALID_UTILITY_NAME = /^[a-z@][a-zA-Z0-9/%._-]*$/
export function buildPluginApi(
designSystem: DesignSystem,
ast: AstNode[],
resolvedConfig: ResolvedConfig,
featuresRef: { current: Features },
): PluginAPI {
export function buildPluginApi({
designSystem,
ast,
resolvedConfig,
featuresRef,
referenceMode,
}: {
designSystem: DesignSystem
ast: AstNode[]
resolvedConfig: ResolvedConfig
featuresRef: { current: Features }
referenceMode: boolean
}): PluginAPI {
let api: PluginAPI = {
addBase(css) {
if (referenceMode) return
let baseNodes = objectToAst(css)
featuresRef.current |= substituteFunctions(baseNodes, designSystem)
ast.push(atRule('@layer', 'base', baseNodes))
@ -212,7 +220,9 @@ export function buildPluginApi(
for (let [name, css] of entries) {
if (name.startsWith('@keyframes ')) {
ast.push(rule(name, objectToAst(css)))
if (!referenceMode) {
ast.push(rule(name, objectToAst(css)))
}
continue
}

View File

@ -3212,7 +3212,7 @@ describe('`@import "…" reference`', () => {
{ loadStylesheet },
)
expect(build(['text-underline', 'border']).trim()).toMatchInlineSnapshot(`"@layer utilities;"`)
expect(build(['text-underline', 'border']).trim()).toMatchInlineSnapshot(`""`)
})
test('removes styles when the import resolver was handled outside of Tailwind CSS', async () => {
@ -3241,13 +3241,91 @@ describe('`@import "…" reference`', () => {
[],
),
).resolves.toMatchInlineSnapshot(`
"@layer theme;
@media (width >= 48rem) {
"@media (width >= 48rem) {
.bar:hover, .bar:focus {
color: red;
}
}"
`)
})
test('removes all @keyframes, even those contributed by JavasScript plugins', async () => {
await expect(
compileCss(
css`
@media reference {
@layer theme, base, components, utilities;
@layer theme {
@theme {
--animate-spin: spin 1s linear infinite;
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
}
}
@layer base {
@keyframes ping {
75%,
100% {
transform: scale(2);
opacity: 0;
}
}
}
@plugin "my-plugin";
}
.bar {
@apply animate-spin;
}
`,
['animate-spin', 'match-utility-initial', 'match-components-initial'],
{
loadModule: async () => ({
module: ({
addBase,
addUtilities,
addComponents,
matchUtilities,
matchComponents,
}: PluginAPI) => {
addBase({
'@keyframes base': { '100%': { opacity: '0' } },
})
addUtilities({
'@keyframes utilities': { '100%': { opacity: '0' } },
})
addComponents({
'@keyframes components ': { '100%': { opacity: '0' } },
})
matchUtilities(
{
'match-utility': (value) => ({
'@keyframes match-utilities': { '100%': { opacity: '0' } },
}),
},
{ values: { initial: 'initial' } },
)
matchComponents(
{
'match-components': (value) => ({
'@keyframes match-components': { '100%': { opacity: '0' } },
}),
},
{ values: { initial: 'initial' } },
)
},
base: '/root',
}),
},
),
).resolves.toMatchInlineSnapshot(`
".bar {
animation: var(--animate-spin);
}"
`)
})
})

View File

@ -362,42 +362,6 @@ async function parseCss(
// Handle `@import "…" reference`
else if (param === 'reference') {
walk(node.nodes, (child, { replaceWith }) => {
if (child.kind !== 'at-rule') {
replaceWith([])
return WalkAction.Skip
}
switch (child.name) {
case '@theme': {
let themeParams = segment(child.params, ' ')
if (!themeParams.includes('reference')) {
child.params = (child.params === '' ? '' : ' ') + 'reference'
}
return WalkAction.Skip
}
case '@import':
case '@config':
case '@plugin':
case '@variant':
case '@utility': {
return WalkAction.Skip
}
case '@media':
case '@supports':
case '@layer': {
// These rules should be recursively traversed as these might be
// inserted by the `@import` resolution.
return
}
default: {
replaceWith([])
return WalkAction.Skip
}
}
})
node.nodes = [contextNode({ reference: true }, node.nodes)]
}
@ -420,6 +384,10 @@ async function parseCss(
if (node.name === '@theme') {
let [themeOptions, themePrefix] = parseThemeOptions(node.params)
if (context.reference) {
themeOptions |= ThemeOptions.REFERENCE
}
if (themePrefix) {
if (!IS_VALID_PREFIX.test(themePrefix)) {
throw new Error(