From df96ea5eba94c801a08879cf95837b8a2b317b42 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 2 Feb 2026 14:42:31 +0100 Subject: [PATCH] Fix infinite loop when using `@variant` inside `@custom-variant` that points to another `@custom-variant` (#19633) This PR fixes an infinite loop when you use a `@variant` inside of a `@custom-variant`, where the `@variant` used is another `@custom-variant`. The issue stems from the fact that a `@custom-variant` can use a `@slot` that we have to replace with the proper AST nodes. However in this setup, the AST nodes will include a `@slot` node as well, which causes us to replace the `@slot` again, and so on, causing an infinite loop. ```css @custom-variant a { @slot; } @custom-variant b { @variant a { @slot; } } ``` The solution here is to replace the `@slot` nodes and then skip walking the nodes that were just inserted. This does mean that we end up with a `@slot` node in the final AST but that's not a real issue because that will get replaced later when handling the next `@custom-variant`. ## Test plan 1. Existing tests still pass 2. Added a regression test to ensure that the infinite loop does not happen anymore 3. Added additional tests to ensure that the behavior is correct Thanks @wongjn for your initial debugging help and providing a test case as well! Fixes: #19618 --- CHANGELOG.md | 1 + packages/tailwindcss/src/index.test.ts | 91 ++++++++++++++++++++++++++ packages/tailwindcss/src/variants.ts | 2 +- 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 710d4a14f..74f72f10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Detect utilities when containing capital letters followed by numbers ([#19465](https://github.com/tailwindlabs/tailwindcss/pull/19465)) - Fix class extraction for Rails' strict locals ([#19525](https://github.com/tailwindlabs/tailwindcss/pull/19525)) - Align `@utility` name validation with Oxide scanner rules ([#19524](https://github.com/tailwindlabs/tailwindcss/pull/19524)) +- Fix infinite loop when using `@variant` inside `@custom-variant` ([#19633](https://github.com/tailwindlabs/tailwindcss/pull/19633)) ### Deprecated diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 94b17714e..5182cdd14 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -4558,6 +4558,97 @@ describe('@custom-variant', () => { `) }) + // https://github.com/tailwindlabs/tailwindcss/issues/19618 + test('@custom-variant can use a @variant that eventually uses another @custom-variant', async () => { + expect( + await compileCss( + css` + @custom-variant a { + @slot; + } + + @custom-variant b { + @variant a { + @slot; + } + } + + @tailwind utilities; + `, + ['a:flex', 'b:flex', 'a:b:flex', 'b:a:flex'], + ), + ).toMatchInlineSnapshot(` + ".a\\:flex, .b\\:flex, .a\\:b\\:flex, .b\\:a\\:flex { + display: flex; + }" + `) + }) + + test('@custom-variant can use a @variant that eventually uses another @custom-variant (2)', async () => { + expect( + await compileCss( + css` + @custom-variant a { + .a { + @slot; + } + } + + @custom-variant b { + .b { + @variant a { + .a-inside-b { + @slot; + } + } + } + } + + @tailwind utilities; + `, + ['a:flex', 'b:flex', 'a:b:flex', 'b:a:flex'], + ), + ).toMatchInlineSnapshot(` + ".a\\:flex .a, .b\\:flex .b .a .a-inside-b, .a\\:b\\:flex .a .b .a .a-inside-b, .b\\:a\\:flex .b .a .a-inside-b .a { + display: flex; + }" + `) + }) + + // https://github.com/tailwindlabs/tailwindcss/issues/19618#issuecomment-3830775912 + test('@custom-variant can use existing @slot @variants', async () => { + expect( + await compileCss( + css` + @custom-variant hocus { + @variant hover { + @variant focus { + @slot; + } + } + } + + @custom-variant hover { + &:hover { + @slot; + } + + &[data-hover] { + @slot; + } + } + + @tailwind utilities; + `, + ['hocus:flex'], + ), + ).toMatchInlineSnapshot(` + ".hocus\\:flex:hover:focus, .hocus\\:flex[data-hover]:focus { + display: flex; + }" + `) + }) + test('@custom-variant setup that results in a circular dependency error can be solved', async () => { expect( await compileCss( diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 82a2b8592..0b4bcb0c5 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1196,7 +1196,7 @@ export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) { walk(ast, (node) => { // Replace `@slot` with rule nodes if (node.kind === 'at-rule' && node.name === '@slot') { - return WalkAction.Replace(nodes) + return WalkAction.ReplaceSkip(nodes) } // Wrap `@keyframes` and `@property` in `AtRoot` nodes