Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
319 changes: 318 additions & 1 deletion packages/tailwindcss/src/ast.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 '<context>'
case 'at-root':
return '<at-root>'
case 'declaration':
return `${node.property}: ${node.value}`
case 'comment':
return `// ${node.value}`
default:
node satisfies never
throw new Error('Unknown node kind')
}
}
27 changes: 21 additions & 6 deletions packages/tailwindcss/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading