From 144581d3e46510ffbbf1d96330d46d308cdf0509 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Thu, 6 Feb 2025 14:30:04 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + packages/tailwindcss/src/ast.test.ts | 319 +++++++++++++++++- packages/tailwindcss/src/ast.ts | 27 +- packages/tailwindcss/src/at-import.test.ts | 32 ++ .../tailwindcss/src/compat/selector-parser.ts | 25 +- packages/tailwindcss/src/index.ts | 9 - packages/tailwindcss/src/value-parser.ts | 24 +- 7 files changed, 411 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbfac55fb083..9519e13c90e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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