Properly skip over nodes when using replaceNode (#16300)

Fixes #16298 

This PR fixes an issue where using an AST walk in combination with
`replaceNode` and various `SkipAction` would either cause children to be
visited multiple times or not visited at all even though it should. This
PR fixes the issue which also means we can get rid of a custom walk for
`@variant` inside the `@media` that was used to apply `@variant` because
we never recursively visited children inside the `@media` rule.

Because we now can use the regular walk for `@variant`, we now properly
convert `@variant` to `@custom-variant` inside `@reference`-ed
stylesheet which also fixes #16298

## Test plan

Lots of tests added to ensure the combinations of `WalkAction` and
`replaceWith()` works as expected.
This commit is contained in:
Philipp Spiess 2025-02-06 14:30:04 +01:00 committed by GitHub
parent 1e949af9a1
commit 144581d3e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 411 additions and 26 deletions

View File

@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix support for older instruction sets on Linux x64 builds of the standalone CLI ([#16244](https://github.com/tailwindlabs/tailwindcss/pull/16244))
- 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

View File

@ -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 '<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')
}
}

View File

@ -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()

View File

@ -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;
}
"
`)
})

View File

@ -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

View File

@ -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`

View File

@ -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