diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c7add42a23..ceba3010700c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix `order-first` and `order-last` for Firefox ([#16266](https://github.com/tailwindlabs/tailwindcss/pull/16266)) - Ensure `NODE_PATH` is respected when resolving JavaScript and CSS files ([#16274](https://github.com/tailwindlabs/tailwindcss/pull/16274)) - Ensure Node addons are packaged correctly with FreeBSD builds ([#16277](https://github.com/tailwindlabs/tailwindcss/pull/16277)) +- Fix an issue where `@variant` inside a referenced stylesheet could cause a stack overflow ([#16300](https://github.com/tailwindlabs/tailwindcss/pull/16300)) ## [4.0.3] - 2025-02-01 diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index 142fd6312ccf..8bd2004ace73 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -1,5 +1,15 @@ import { expect, it } from 'vitest' -import { context, decl, optimizeAst, styleRule, toCss, walk, WalkAction } from './ast' +import { + atRule, + context, + decl, + optimizeAst, + styleRule, + toCss, + walk, + WalkAction, + type AstNode, +} from './ast' import * as CSS from './css-parser' const css = String.raw @@ -188,3 +198,310 @@ it('should not emit empty rules once optimized', () => { " `) }) + +it('should only visit children once when calling `replaceWith` with single element array', () => { + let visited = new Set() + + let ast = [ + atRule('@media', '', [styleRule('.foo', [decl('color', 'blue')])]), + styleRule('.bar', [decl('color', 'blue')]), + ] + + walk(ast, (node, { replaceWith }) => { + if (visited.has(node)) { + throw new Error('Visited node twice') + } + visited.add(node) + + if (node.kind === 'at-rule') replaceWith(node.nodes) + }) +}) + +it('should only visit children once when calling `replaceWith` with multi-element array', () => { + let visited = new Set() + + let ast = [ + atRule('@media', '', [ + context({}, [ + styleRule('.foo', [decl('color', 'red')]), + styleRule('.baz', [decl('color', 'blue')]), + ]), + ]), + styleRule('.bar', [decl('color', 'green')]), + ] + + walk(ast, (node, { replaceWith }) => { + let key = id(node) + if (visited.has(key)) { + throw new Error('Visited node twice') + } + visited.add(key) + + if (node.kind === 'at-rule') replaceWith(node.nodes) + }) + + expect(visited).toMatchInlineSnapshot(` + Set { + "@media ", + ".foo", + "color: red", + ".baz", + "color: blue", + ".bar", + "color: green", + } + `) +}) + +it('should never visit children when calling `replaceWith` with `WalkAction.Skip`', () => { + let visited = new Set() + + let inner = styleRule('.foo', [decl('color', 'blue')]) + + let ast = [atRule('@media', '', [inner]), styleRule('.bar', [decl('color', 'blue')])] + + walk(ast, (node, { replaceWith }) => { + visited.add(node) + + if (node.kind === 'at-rule') { + replaceWith(node.nodes) + return WalkAction.Skip + } + }) + + expect(visited).not.toContain(inner) + expect(visited).toMatchInlineSnapshot(` + Set { + { + "kind": "at-rule", + "name": "@media", + "nodes": [ + { + "kind": "rule", + "nodes": [ + { + "important": false, + "kind": "declaration", + "property": "color", + "value": "blue", + }, + ], + "selector": ".foo", + }, + ], + "params": "", + }, + { + "kind": "rule", + "nodes": [ + { + "important": false, + "kind": "declaration", + "property": "color", + "value": "blue", + }, + ], + "selector": ".bar", + }, + { + "important": false, + "kind": "declaration", + "property": "color", + "value": "blue", + }, + } + `) +}) + +it('should skip the correct number of children based on the the replaced children nodes', () => { + { + let ast = [ + decl('--index', '0'), + decl('--index', '1'), + decl('--index', '2'), + decl('--index', '3'), + decl('--index', '4'), + ] + let visited: string[] = [] + walk(ast, (node, { replaceWith }) => { + visited.push(id(node)) + if (node.kind === 'declaration' && node.value === '2') { + replaceWith([]) + return WalkAction.Skip + } + }) + + expect(visited).toMatchInlineSnapshot(` + [ + "--index: 0", + "--index: 1", + "--index: 2", + "--index: 3", + "--index: 4", + ] + `) + } + + { + let ast = [ + decl('--index', '0'), + decl('--index', '1'), + decl('--index', '2'), + decl('--index', '3'), + decl('--index', '4'), + ] + let visited: string[] = [] + walk(ast, (node, { replaceWith }) => { + visited.push(id(node)) + if (node.kind === 'declaration' && node.value === '2') { + replaceWith([]) + return WalkAction.Continue + } + }) + + expect(visited).toMatchInlineSnapshot(` + [ + "--index: 0", + "--index: 1", + "--index: 2", + "--index: 3", + "--index: 4", + ] + `) + } + + { + let ast = [ + decl('--index', '0'), + decl('--index', '1'), + decl('--index', '2'), + decl('--index', '3'), + decl('--index', '4'), + ] + let visited: string[] = [] + walk(ast, (node, { replaceWith }) => { + visited.push(id(node)) + if (node.kind === 'declaration' && node.value === '2') { + replaceWith([decl('--index', '2.1')]) + return WalkAction.Skip + } + }) + + expect(visited).toMatchInlineSnapshot(` + [ + "--index: 0", + "--index: 1", + "--index: 2", + "--index: 3", + "--index: 4", + ] + `) + } + + { + let ast = [ + decl('--index', '0'), + decl('--index', '1'), + decl('--index', '2'), + decl('--index', '3'), + decl('--index', '4'), + ] + let visited: string[] = [] + walk(ast, (node, { replaceWith }) => { + visited.push(id(node)) + if (node.kind === 'declaration' && node.value === '2') { + replaceWith([decl('--index', '2.1')]) + return WalkAction.Continue + } + }) + + expect(visited).toMatchInlineSnapshot(` + [ + "--index: 0", + "--index: 1", + "--index: 2", + "--index: 2.1", + "--index: 3", + "--index: 4", + ] + `) + } + + { + let ast = [ + decl('--index', '0'), + decl('--index', '1'), + decl('--index', '2'), + decl('--index', '3'), + decl('--index', '4'), + ] + let visited: string[] = [] + walk(ast, (node, { replaceWith }) => { + visited.push(id(node)) + if (node.kind === 'declaration' && node.value === '2') { + replaceWith([decl('--index', '2.1'), decl('--index', '2.2')]) + return WalkAction.Skip + } + }) + + expect(visited).toMatchInlineSnapshot(` + [ + "--index: 0", + "--index: 1", + "--index: 2", + "--index: 3", + "--index: 4", + ] + `) + } + + { + let ast = [ + decl('--index', '0'), + decl('--index', '1'), + decl('--index', '2'), + decl('--index', '3'), + decl('--index', '4'), + ] + let visited: string[] = [] + walk(ast, (node, { replaceWith }) => { + visited.push(id(node)) + if (node.kind === 'declaration' && node.value === '2') { + replaceWith([decl('--index', '2.1'), decl('--index', '2.2')]) + return WalkAction.Continue + } + }) + + expect(visited).toMatchInlineSnapshot(` + [ + "--index: 0", + "--index: 1", + "--index: 2", + "--index: 2.1", + "--index: 2.2", + "--index: 3", + "--index: 4", + ] + `) + } +}) + +function id(node: AstNode) { + switch (node.kind) { + case 'at-rule': + return `${node.name} ${node.params}` + case 'rule': + return node.selector + case 'context': + return '' + case 'at-root': + return '' + case 'declaration': + return `${node.property}: ${node.value}` + case 'comment': + return `// ${node.value}` + default: + node satisfies never + throw new Error('Unknown node kind') + } +} diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index ac4529e87000..0e9215bbec0f 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -137,39 +137,54 @@ export function walk( } path.push(node) + let replacedNode = false + let replacedNodeOffset = 0 let status = visit(node, { parent, context, path, replaceWith(newNode) { + replacedNode = true + if (Array.isArray(newNode)) { if (newNode.length === 0) { ast.splice(i, 1) + replacedNodeOffset = 0 } else if (newNode.length === 1) { ast[i] = newNode[0] + replacedNodeOffset = 1 } else { ast.splice(i, 1, ...newNode) + replacedNodeOffset = newNode.length } } else { ast[i] = newNode + replacedNodeOffset = 1 } - - // We want to visit the newly replaced node(s), which start at the - // current index (i). By decrementing the index here, the next loop - // will process this position (containing the replaced node) again. - i-- }, }) ?? WalkAction.Continue path.pop() + // We want to visit or skip the newly replaced node(s), which start at the + // current index (i). By decrementing the index here, the next loop will + // process this position (containing the replaced node) again. + if (replacedNode) { + if (status === WalkAction.Continue) { + i-- + } else { + i += replacedNodeOffset - 1 + } + continue + } + // Stop the walk entirely if (status === WalkAction.Stop) return WalkAction.Stop // Skip visiting the children of this node if (status === WalkAction.Skip) continue - if (node.kind === 'rule' || node.kind === 'at-rule') { + if ('nodes' in node) { path.push(node) let result = walk(node.nodes, visit, path, context) path.pop() diff --git a/packages/tailwindcss/src/at-import.test.ts b/packages/tailwindcss/src/at-import.test.ts index 108498216d9a..07922e83a44a 100644 --- a/packages/tailwindcss/src/at-import.test.ts +++ b/packages/tailwindcss/src/at-import.test.ts @@ -586,3 +586,35 @@ test('resolves @reference as `@import "…" reference`', async () => { " `) }) + +test('resolves `@variant` used as `@custom-variant` inside `@reference`', async () => { + let loadStylesheet = async (id: string, base: string) => { + expect(base).toBe('/root') + expect(id).toBe('./foo/bar.css') + return { + content: css` + @variant dark { + &:where([data-theme='dark'] *) { + @slot; + } + } + `, + base: '/root/foo', + } + } + + await expect( + run( + css` + @reference './foo/bar.css'; + @tailwind utilities; + `, + { loadStylesheet, candidates: ['dark:flex'] }, + ), + ).resolves.toMatchInlineSnapshot(` + ".dark\\:flex:where([data-theme="dark"] *) { + display: flex; + } + " + `) +}) diff --git a/packages/tailwindcss/src/compat/selector-parser.ts b/packages/tailwindcss/src/compat/selector-parser.ts index f62e0aed4cec..341f9b581379 100644 --- a/packages/tailwindcss/src/compat/selector-parser.ts +++ b/packages/tailwindcss/src/compat/selector-parser.ts @@ -92,29 +92,44 @@ export function walk( ) { for (let i = 0; i < ast.length; i++) { let node = ast[i] + let replacedNode = false + let replacedNodeOffset = 0 let status = visit(node, { parent, replaceWith(newNode) { + replacedNode = true + if (Array.isArray(newNode)) { if (newNode.length === 0) { ast.splice(i, 1) + replacedNodeOffset = 0 } else if (newNode.length === 1) { ast[i] = newNode[0] + replacedNodeOffset = 1 } else { ast.splice(i, 1, ...newNode) + replacedNodeOffset = newNode.length } } else { ast[i] = newNode + replacedNodeOffset = 1 } - - // We want to visit the newly replaced node(s), which start at the - // current index (i). By decrementing the index here, the next loop - // will process this position (containing the replaced node) again. - i-- }, }) ?? SelectorWalkAction.Continue + // We want to visit or skip the newly replaced node(s), which start at the + // current index (i). By decrementing the index here, the next loop will + // process this position (containing the replaced node) again. + if (replacedNode) { + if (status === SelectorWalkAction.Continue) { + i-- + } else { + i += replacedNodeOffset - 1 + } + continue + } + // Stop the walk entirely if (status === SelectorWalkAction.Stop) return SelectorWalkAction.Stop diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 37c19a3f3edf..14bcee2fe104 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -433,15 +433,6 @@ async function parseCss( } else if (params.length > 0) { replaceWith(node.nodes) } - - walk(node.nodes, (node) => { - if (node.kind !== 'at-rule') return - if (node.name !== '@variant') return - - variantNodes.push(node) - }) - - return WalkAction.Skip } // Handle `@theme` diff --git a/packages/tailwindcss/src/value-parser.ts b/packages/tailwindcss/src/value-parser.ts index 80a7827b2e8a..dda1a7d80ab7 100644 --- a/packages/tailwindcss/src/value-parser.ts +++ b/packages/tailwindcss/src/value-parser.ts @@ -63,29 +63,43 @@ export function walk( ) { for (let i = 0; i < ast.length; i++) { let node = ast[i] + let replacedNode = false + let replacedNodeOffset = 0 let status = visit(node, { parent, replaceWith(newNode) { + replacedNode = true + if (Array.isArray(newNode)) { if (newNode.length === 0) { ast.splice(i, 1) + replacedNodeOffset = 0 } else if (newNode.length === 1) { ast[i] = newNode[0] + replacedNodeOffset = 1 } else { ast.splice(i, 1, ...newNode) + replacedNodeOffset = newNode.length } } else { ast[i] = newNode } - - // We want to visit the newly replaced node(s), which start at the - // current index (i). By decrementing the index here, the next loop - // will process this position (containing the replaced node) again. - i-- }, }) ?? ValueWalkAction.Continue + // We want to visit or skip the newly replaced node(s), which start at the + // current index (i). By decrementing the index here, the next loop will + // process this position (containing the replaced node) again. + if (replacedNode) { + if (status === ValueWalkAction.Continue) { + i-- + } else { + i += replacedNodeOffset - 1 + } + continue + } + // Stop the walk entirely if (status === ValueWalkAction.Stop) return ValueWalkAction.Stop