From 6d1970011ba7770717535959d0e8b3001b80bc64 Mon Sep 17 00:00:00 2001 From: Bodo Tasche Date: Mon, 8 Jun 2026 10:55:02 +0200 Subject: [PATCH] Fixed tree walking for @keyframe attributes The library has a problem when mixing @keyframe with @media and other queries. Classnames inside of @media would never be hashed and svelte will deactive them because they don't match the other classnames that got a hash. --- package-lock.json | 4 +- src/processors/native.ts | 114 +++++++------- test/globalFixtures/keyframes.test.js | 80 ++++++++++ test/nativeFixtures/stylesAttribute.test.js | 159 ++++++++++++++++++++ 4 files changed, 303 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2aacbc..67e26ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "svelte-preprocess-cssmodules", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "svelte-preprocess-cssmodules", - "version": "3.0.0", + "version": "3.0.1", "license": "MIT", "dependencies": { "acorn": "^8.5.0", diff --git a/src/processors/native.ts b/src/processors/native.ts index 21437ad..a0a1c48 100644 --- a/src/processors/native.ts +++ b/src/processors/native.ts @@ -1,4 +1,3 @@ -import { walk } from 'estree-walker'; import type { AST } from 'svelte/compiler'; import type { PluginOptions } from '../types'; import Processor from './processor'; @@ -21,7 +20,7 @@ const updateSelectorBoundaries = ( const lastIndex = selectorBoundaries.length - 1; if (selectorBoundaries[lastIndex]?.end === start) { selectorBoundaries[lastIndex].end = end; - } else if (selectorBoundaries.length < 1 || selectorBoundaries[lastIndex].end < end) { + } else { selectorBoundaries.push({ start, end }); } return selectorBoundaries; @@ -38,66 +37,77 @@ const parser = (processor: Processor): void => { let selectorBoundaries: Boundaries[] = []; - walk(processor.ast.css, { - enter(baseNode) { - (baseNode as AST.CSS.StyleSheet).children?.forEach((node) => { - if (node.type === 'Atrule' && node.name === 'keyframes') { - processor.parseKeyframes(node); - this.skip(); - } - if (node.type === 'Rule') { - node.prelude.children.forEach((child) => { - if (child.type === 'ComplexSelector') { - let start = 0; - let end = 0; + const visitNode = (node: AST.CSS.Node): void => { + if (node.type === 'Atrule') { + if (node.name === 'keyframes') { + processor.parseKeyframes(node); + return; + } + // Recurse into @media, @supports, @layer, etc. + (node.block as AST.CSS.Block)?.children?.forEach(visitNode); + return; + } - child.children.forEach((grandChild, index) => { - let hasPushed = false; - if (grandChild.type === 'RelativeSelector') { - grandChild.selectors.forEach((item) => { - if ( - item.type === 'PseudoClassSelector' && - (item.name === 'global' || item.name === 'local') - ) { - processor.parsePseudoLocalSelectors(item); - if (start > 0 && end > 0) { - selectorBoundaries = updateSelectorBoundaries( - selectorBoundaries, - start, - end - ); - hasPushed = true; - } - start = item.end + 1; - end = 0; - } else if (item.start && item.end) { - if (start === 0) { - start = item.start; - } - end = item.end; - processor.parseClassSelectors(item); - } - }); + if (node.type === 'Rule') { + node.prelude.children.forEach((child) => { + if (child.type === 'ComplexSelector') { + let start = 0; + let end = 0; - if ( - hasPushed === false && - child.children && - index === child.children.length - 1 && - end > 0 - ) { - selectorBoundaries = updateSelectorBoundaries(selectorBoundaries, start, end); + child.children.forEach((grandChild, index) => { + let hasPushed = false; + if (grandChild.type === 'RelativeSelector') { + grandChild.selectors.forEach((item) => { + if ( + item.type === 'PseudoClassSelector' && + (item.name === 'global' || item.name === 'local') + ) { + processor.parsePseudoLocalSelectors(item); + if (start > 0 && end > 0) { + selectorBoundaries = updateSelectorBoundaries( + selectorBoundaries, + start, + end + ); + hasPushed = true; + } + start = item.end + 1; + end = 0; + } else if (item.start && item.end) { + if (start === 0) { + start = item.start; } + end = item.end; + processor.parseClassSelectors(item); } }); + + if ( + hasPushed === false && + child.children && + index === child.children.length - 1 && + end > 0 + ) { + selectorBoundaries = updateSelectorBoundaries(selectorBoundaries, start, end); + } } }); + } + }); + + processor.parseBoundVariables(node.block); + processor.storeAnimationProperties(node.block); - processor.parseBoundVariables(node.block); - processor.storeAnimationProperties(node.block); + // Recurse into nested rules (CSS nesting: rules inside rule blocks) + (node.block as AST.CSS.Block)?.children?.forEach((child) => { + if (child.type === 'Rule' || child.type === 'Atrule') { + visitNode(child); } }); - }, - }); + } + }; + + processor.ast.css?.children?.forEach(visitNode); processor.overwriteAnimationProperties(); diff --git a/test/globalFixtures/keyframes.test.js b/test/globalFixtures/keyframes.test.js index f57e3b7..0dc220d 100644 --- a/test/globalFixtures/keyframes.test.js +++ b/test/globalFixtures/keyframes.test.js @@ -116,4 +116,84 @@ describe('Scoped Keyframes', () => { expect(output).toBe(expectedOutput); }); + + test('Native mode: class inside @media is hashed when @keyframes is also present', async () => { + const source = + '' + + '
'; + + const expectedOutput = + '' + + '
'; + + const output = await compiler( + { + source, + }, + { + mode: 'native', + localIdentName: '[local]-123', + } + ); + + expect(output).toBe(expectedOutput); + }); + + test('Native mode: class inside @media after @keyframes is hashed', async () => { + const source = + '' + + 'Bar'; + + const expectedOutput = + '' + + 'Bar'; + + const output = await compiler( + { + source, + }, + { + mode: 'native', + localIdentName: '[local]-123', + } + ); + + expect(output).toBe(expectedOutput); + }); }); diff --git a/test/nativeFixtures/stylesAttribute.test.js b/test/nativeFixtures/stylesAttribute.test.js index 3f7b2a5..7e27f14 100644 --- a/test/nativeFixtures/stylesAttribute.test.js +++ b/test/nativeFixtures/stylesAttribute.test.js @@ -63,6 +63,165 @@ describe('Native Mode', () => { expect(output).toBe(expectedOutput); }); + test('Globalize class selector inside @media block', async () => { + const source = + '\n' + + '
Red
'; + + const expectedOutput = + '\n' + + '
Red
'; + + const output = await compiler( + { + source, + }, + { + mode: 'native', + localIdentName: '[local]-123', + } + ); + + expect(output).toBe(expectedOutput); + }); + + test('Globalize class selectors inside nested @supports block', async () => { + const source = + '\n' + + '
Grid
'; + + const expectedOutput = + '\n' + + '
Grid
'; + + const output = await compiler( + { + source, + }, + { + mode: 'native', + localIdentName: '[local]-123', + } + ); + + expect(output).toBe(expectedOutput); + }); + + test('Globalize class selector directly nested inside a rule (CSS nesting)', async () => { + const source = + '\n' + + '
'; + + const expectedOutput = + '\n' + + '
'; + + const output = await compiler( + { source }, + { mode: 'native', localIdentName: '[local]-123' } + ); + + expect(output).toBe(expectedOutput); + }); + + test('Globalize class selector in deeply nested CSS nesting (&.modifier > .child)', async () => { + const source = + '\n' + + '
'; + + const expectedOutput = + '\n' + + '
'; + + const output = await compiler( + { source }, + { mode: 'native', localIdentName: '[local]-123' } + ); + + expect(output).toBe(expectedOutput); + }); + + test('Globalize class selector nested inside rule inside @media (combined nesting)', async () => { + const source = + '\n' + + '
'; + + const expectedOutput = + '\n' + + '
'; + + const output = await compiler( + { source }, + { mode: 'native', localIdentName: '[local]-123' } + ); + + expect(output).toBe(expectedOutput); + }); + test('Scoped local selector', async () => { const source = '