Skip to content

Properly skip over nodes when using replaceNode #16300

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all 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