From 4fad444732b93a6a146a94feb7a07a8f208781dc Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Wed, 15 Apr 2026 09:51:53 +0200 Subject: [PATCH 1/2] chore: Adds theme optimisation and default theme layer --- .../__snapshots__/index.test.ts.snap | 52 ++------ .../__snapshots__/internal.test.ts.snap | 34 +++--- .../public-secondary.test.ts.snap | 114 +++++++++--------- .../__snapshots__/index.test.ts.snap | 50 +++----- .../declaration/__tests__/index.test.ts | 33 +++++ .../declaration/__tests__/transformer.test.ts | 89 ++++++++++++++ src/shared/declaration/index.ts | 2 +- src/shared/declaration/stylesheet.ts | 24 +++- src/shared/declaration/transformer.ts | 30 ++++- 9 files changed, 269 insertions(+), 159 deletions(-) diff --git a/src/browser/__tests__/__snapshots__/index.test.ts.snap b/src/browser/__tests__/__snapshots__/index.test.ts.snap index c83deb6..c5ef58c 100644 --- a/src/browser/__tests__/__snapshots__/index.test.ts.snap +++ b/src/browser/__tests__/__snapshots__/index.test.ts.snap @@ -14,17 +14,10 @@ exports[`applyTheme > with baseThemeId > attaches one style node containing over --shadow-css:orange; --boxShadow-css:var(--brown-css); }} -.secondary-theme.secondary-theme .navigation:not(#\\9){ +.secondary-theme.secondary-theme .navigation:not(#\\9),.navigation.navigation.secondary-theme:not(#\\9){ --shadow-css:pink; } -.navigation.navigation.secondary-theme:not(#\\9){ - --shadow-css:pink; -} -@media not print {.dark.dark.secondary-theme .navigation:not(#\\9){ - --shadow-css:var(--grey-css); - --buttonShadow-css:green; -}} -@media not print {.dark.dark.navigation.secondary-theme:not(#\\9){ +@media not print {.dark.dark.secondary-theme .navigation:not(#\\9),.dark.dark.navigation.secondary-theme:not(#\\9){ --shadow-css:var(--grey-css); --buttonShadow-css:green; }}" @@ -48,11 +41,7 @@ exports[`applyTheme > with secondary theme > attaches one style node containing --shadow-css:pink; --boxShadow-css:purple; } -@media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:var(--brown-css); - --buttonShadow-css:green; -}} -@media not print {.dark.dark.navigation:not(#\\9){ +@media not print {.dark.dark .navigation:not(#\\9),.dark.dark.navigation:not(#\\9){ --shadow-css:var(--brown-css); --buttonShadow-css:green; }}" @@ -76,11 +65,7 @@ exports[`applyTheme > with targetDocument > attaches one style node containing o --shadow-css:pink; --boxShadow-css:purple; } -@media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:var(--brown-css); - --buttonShadow-css:green; -}} -@media not print {.dark.dark.navigation:not(#\\9){ +@media not print {.dark.dark .navigation:not(#\\9),.dark.dark.navigation:not(#\\9){ --shadow-css:var(--brown-css); --buttonShadow-css:green; }}" @@ -104,11 +89,7 @@ exports[`applyTheme > without secondary theme > attaches one style node containi --shadow-css:pink; --boxShadow-css:purple; } -@media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:var(--brown-css); - --buttonShadow-css:green; -}} -@media not print {.dark.dark.navigation:not(#\\9){ +@media not print {.dark.dark .navigation:not(#\\9),.dark.dark.navigation:not(#\\9){ --shadow-css:var(--brown-css); --buttonShadow-css:green; }}" @@ -128,17 +109,10 @@ exports[`generateThemeStylesheet > with baseThemeId > creates override styles 1` --shadow-css:orange; --boxShadow-css:var(--brown-css); }} -.secondary-theme.secondary-theme .navigation:not(#\\9){ - --shadow-css:pink; -} -.navigation.navigation.secondary-theme:not(#\\9){ +.secondary-theme.secondary-theme .navigation:not(#\\9),.navigation.navigation.secondary-theme:not(#\\9){ --shadow-css:pink; } -@media not print {.dark.dark.secondary-theme .navigation:not(#\\9){ - --shadow-css:var(--grey-css); - --buttonShadow-css:green; -}} -@media not print {.dark.dark.navigation.secondary-theme:not(#\\9){ +@media not print {.dark.dark.secondary-theme .navigation:not(#\\9),.dark.dark.navigation.secondary-theme:not(#\\9){ --shadow-css:var(--grey-css); --buttonShadow-css:green; }}" @@ -169,11 +143,7 @@ exports[`generateThemeStylesheet > with secondary theme > creates override style --shadow-css:pink; --boxShadow-css:purple; } -@media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:var(--brown-css); - --buttonShadow-css:green; -}} -@media not print {.dark.dark.navigation:not(#\\9){ +@media not print {.dark.dark .navigation:not(#\\9),.dark.dark.navigation:not(#\\9){ --shadow-css:var(--brown-css); --buttonShadow-css:green; }}" @@ -197,11 +167,7 @@ exports[`generateThemeStylesheet > without secondary theme > creates override st --shadow-css:pink; --boxShadow-css:purple; } -@media not print {.dark.dark .navigation:not(#\\9){ - --shadow-css:var(--brown-css); - --buttonShadow-css:green; -}} -@media not print {.dark.dark.navigation:not(#\\9){ +@media not print {.dark.dark .navigation:not(#\\9),.dark.dark.navigation:not(#\\9){ --shadow-css:var(--brown-css); --buttonShadow-css:green; }}" diff --git a/src/build/__tests__/__snapshots__/internal.test.ts.snap b/src/build/__tests__/__snapshots__/internal.test.ts.snap index 8789fba..ca7613e 100644 --- a/src/build/__tests__/__snapshots__/internal.test.ts.snap +++ b/src/build/__tests__/__snapshots__/internal.test.ts.snap @@ -1,22 +1,22 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`builds internal themed components without errors 1`] = ` -":root { - --font-family-base-91xi75:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; - --color-orange-500-vz48fa:#ec7211; - --color-orange-700-sk82ji:#dd6b10; - --color-background-button-primary-default-nhput1:var(--color-orange-500-vz48fa); - --color-background-button-primary-active-xbf2p5:var(--color-orange-700-sk82ji); - --space-xxs-c0i4qj:4px; - --space-xs-djjbsd:8px; - --space-scaled-xs-yqelf2:var(--space-xs-djjbsd); -} - -.compact-mode:not(#\\9) { - --space-scaled-xs-yqelf2:var(--space-xxs-c0i4qj); -} - -.dark-mode:not(#\\9) { - --color-background-button-primary-active-xbf2p5:var(--color-orange-500-vz48fa); +"@layer cloudscape-base-theme { + :root { + --font-family-base-91xi75:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; + --color-orange-500-vz48fa:#ec7211; + --color-orange-700-sk82ji:#dd6b10; + --color-background-button-primary-default-nhput1:var(--color-orange-500-vz48fa); + --color-background-button-primary-active-xbf2p5:var(--color-orange-700-sk82ji); + --space-xxs-c0i4qj:4px; + --space-xs-djjbsd:8px; + --space-scaled-xs-yqelf2:var(--space-xs-djjbsd); + } + .compact-mode:not(#\\9) { + --space-scaled-xs-yqelf2:var(--space-xxs-c0i4qj); + } + .dark-mode:not(#\\9) { + --color-background-button-primary-active-xbf2p5:var(--color-orange-500-vz48fa); + } }" `; diff --git a/src/build/__tests__/__snapshots__/public-secondary.test.ts.snap b/src/build/__tests__/__snapshots__/public-secondary.test.ts.snap index f12accf..00136c9 100644 --- a/src/build/__tests__/__snapshots__/public-secondary.test.ts.snap +++ b/src/build/__tests__/__snapshots__/public-secondary.test.ts.snap @@ -1,68 +1,64 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Build-time theming of main theme with matching baseThemeId generates the correct css files 1`] = ` -"body { - --font-family-base-rpbu7d:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; - --color-orange-500-25sm1g:#ec7211; - --color-orange-700-2auf4j:#dd6b10; - --color-background-button-primary-default-cqohzr:#aaaaaa; - --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); - --space-xxs-ihlmrd:4px; - --space-xs-xhls0c:8px; - --space-scaled-xs-26ut8b:var(--space-xs-xhls0c); -} - -.compact-mode:not(#\\9) { - --space-scaled-xs-26ut8b:var(--space-xxs-ihlmrd); -} - -.dark-mode:not(#\\9) { - --color-background-button-primary-default-cqohzr:#bbbbbb; - --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); -} - -.secondary:not(#\\9) { - --color-orange-500-25sm1g:#ec72aa; - --color-orange-700-2auf4j:#dd6baa; - --color-background-button-primary-default-cqohzr:var(--color-orange-500-25sm1g); - --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); -} - -.dark-mode.secondary:not(#\\9) { - --color-background-button-primary-default-cqohzr:var(--color-orange-700-2auf4j); - --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); +"@layer cloudscape-base-theme { + body { + --font-family-base-rpbu7d:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; + --color-orange-500-25sm1g:#ec7211; + --color-orange-700-2auf4j:#dd6b10; + --color-background-button-primary-default-cqohzr:#aaaaaa; + --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); + --space-xxs-ihlmrd:4px; + --space-xs-xhls0c:8px; + --space-scaled-xs-26ut8b:var(--space-xs-xhls0c); + } + .compact-mode:not(#\\9) { + --space-scaled-xs-26ut8b:var(--space-xxs-ihlmrd); + } + .dark-mode:not(#\\9) { + --color-background-button-primary-default-cqohzr:#bbbbbb; + --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); + } + .secondary:not(#\\9) { + --color-orange-500-25sm1g:#ec72aa; + --color-orange-700-2auf4j:#dd6baa; + --color-background-button-primary-default-cqohzr:var(--color-orange-500-25sm1g); + --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); + } + .dark-mode.secondary:not(#\\9) { + --color-background-button-primary-default-cqohzr:var(--color-orange-700-2auf4j); + --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); + } }" `; exports[`Build-time theming of secondary theme generates the correct css files 1`] = ` -"body { - --font-family-base-rpbu7d:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; - --color-orange-500-25sm1g:#ec7211; - --color-orange-700-2auf4j:#dd6b10; - --color-background-button-primary-default-l567s3:var(--color-orange-500-25sm1g); - --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); - --space-xxs-ihlmrd:4px; - --space-xs-xhls0c:8px; - --space-scaled-xs-26ut8b:var(--space-xs-xhls0c); -} - -.compact-mode:not(#\\9) { - --space-scaled-xs-26ut8b:var(--space-xxs-ihlmrd); -} - -.dark-mode:not(#\\9) { - --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); -} - -.secondary:not(#\\9) { - --color-orange-500-25sm1g:#ec72aa; - --color-orange-700-2auf4j:#dd6baa; - --color-background-button-primary-default-l567s3:#aaaaaa; - --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); -} - -.dark-mode.secondary:not(#\\9) { - --color-background-button-primary-default-l567s3:#bbbbbb; - --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); +"@layer cloudscape-base-theme { + body { + --font-family-base-rpbu7d:"Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; + --color-orange-500-25sm1g:#ec7211; + --color-orange-700-2auf4j:#dd6b10; + --color-background-button-primary-default-l567s3:var(--color-orange-500-25sm1g); + --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); + --space-xxs-ihlmrd:4px; + --space-xs-xhls0c:8px; + --space-scaled-xs-26ut8b:var(--space-xs-xhls0c); + } + .compact-mode:not(#\\9) { + --space-scaled-xs-26ut8b:var(--space-xxs-ihlmrd); + } + .dark-mode:not(#\\9) { + --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); + } + .secondary:not(#\\9) { + --color-orange-500-25sm1g:#ec72aa; + --color-orange-700-2auf4j:#dd6baa; + --color-background-button-primary-default-l567s3:#aaaaaa; + --color-background-button-primary-active-h8she0:var(--color-orange-700-2auf4j); + } + .dark-mode.secondary:not(#\\9) { + --color-background-button-primary-default-l567s3:#bbbbbb; + --color-background-button-primary-active-h8she0:var(--color-orange-500-25sm1g); + } }" `; diff --git a/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap b/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap index 474c7c9..7b57178 100644 --- a/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap +++ b/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap @@ -1,13 +1,16 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`renderDeclarations > does not render unnecessary declarations 1`] = ` -":global body{ +"@layer cloudscape-base-theme { +:global body{ --fontFamilyBase-css:"Helvetica Neue", Arial, sans-serif; +} }" `; exports[`renderDeclarations > includes secondary theme 1`] = ` -"body{ +"@layer cloudscape-base-theme { +body{ --fontFamilyBase-css:"Helvetica Neue", Arial, sans-serif; --fontFamilyBody-css:var(--fontFamilyBase-css); --black-css:black; @@ -41,11 +44,7 @@ exports[`renderDeclarations > includes secondary theme 1`] = ` --buttonShadow-css:var(--shadow-css); --lineShadow-css:var(--buttonShadow-css); } -@media not print {.dark .navigation{ - --shadow-css:var(--brown-css); - --lineShadow-css:var(--boxShadow-css); -}} -@media not print {.dark.navigation{ +@media not print {.dark .navigation,.dark.navigation{ --shadow-css:var(--brown-css); --lineShadow-css:var(--boxShadow-css); }} @@ -53,10 +52,7 @@ exports[`renderDeclarations > includes secondary theme 1`] = ` --black-css:purple; --brown-css:black; } -.secondary-theme .navigation{ - --boxShadow-css:var(--shadow-css); -} -.navigation.secondary-theme{ +.secondary-theme .navigation,.navigation.secondary-theme{ --boxShadow-css:var(--shadow-css); } @media not print {.dark.secondary-theme .navigation{ @@ -67,11 +63,13 @@ exports[`renderDeclarations > includes secondary theme 1`] = ` --shadow-css:var(--grey-css); --boxShadow-css:var(--brown-css); --lineShadow-css:var(--boxShadow-css); -}}" +}} +}" `; exports[`renderDeclarations > renders declarations for theme with :root selector and context 1`] = ` -":global body{ +"@layer cloudscape-base-theme { +:global body{ --fontFamilyBase-css:"Helvetica Neue", Arial, sans-serif; --fontFamilyBody-css:var(--fontFamilyBase-css); --black-css:black; @@ -105,18 +103,16 @@ exports[`renderDeclarations > renders declarations for theme with :root selector --buttonShadow-css:var(--shadow-css); --lineShadow-css:var(--buttonShadow-css); } -@media not print {:global .dark .navigation{ +@media not print {:global .dark .navigation,:global .dark.navigation{ --shadow-css:var(--brown-css); --lineShadow-css:var(--boxShadow-css); }} -@media not print {:global .dark.navigation{ - --shadow-css:var(--brown-css); - --lineShadow-css:var(--boxShadow-css); -}}" +}" `; exports[`renderDeclarations > renders declarations for theme with non :root selector 1`] = ` -".secondary-theme{ +"@layer cloudscape-base-theme { +.secondary-theme{ --fontFamilyBase-css:"Helvetica Neue", Arial, sans-serif; --fontFamilyBody-css:var(--fontFamilyBase-css); --black-css:purple; @@ -144,26 +140,16 @@ exports[`renderDeclarations > renders declarations for theme with non :root sele .disabled-motion.secondary-theme{ --appear-css:0; } -.secondary-theme .navigation{ +.secondary-theme .navigation,.navigation.secondary-theme{ --shadow-css:var(--black-css); --buttonShadow-css:var(--shadow-css); --boxShadow-css:var(--shadow-css); --lineShadow-css:var(--buttonShadow-css); } -.navigation.secondary-theme{ - --shadow-css:var(--black-css); - --buttonShadow-css:var(--shadow-css); - --boxShadow-css:var(--shadow-css); - --lineShadow-css:var(--buttonShadow-css); -} -@media not print {.dark.secondary-theme .navigation{ +@media not print {.dark.secondary-theme .navigation,.dark.navigation.secondary-theme{ --shadow-css:var(--grey-css); --boxShadow-css:var(--brown-css); --lineShadow-css:var(--boxShadow-css); }} -@media not print {.dark.navigation.secondary-theme{ - --shadow-css:var(--grey-css); - --boxShadow-css:var(--brown-css); - --lineShadow-css:var(--boxShadow-css); -}}" +}" `; diff --git a/src/shared/declaration/__tests__/index.test.ts b/src/shared/declaration/__tests__/index.test.ts index 4177141..afa7d41 100644 --- a/src/shared/declaration/__tests__/index.test.ts +++ b/src/shared/declaration/__tests__/index.test.ts @@ -48,4 +48,37 @@ describe('renderDeclarations', () => { expect(output).toMatchSnapshot(); }); + + test('global theme emits one context rule, not two', () => { + // body is a global selector — the same-element form would produce the same + // selector as the descendant form, so only one should be emitted. + const output = createBuildDeclarations( + rootTheme, + [], + preset.propertiesMap, + (selector) => selector, + Object.keys(rootTheme.tokens), + ); + + const matches = output.match(/\.navigation/g) ?? []; + // Each context rule contains ".navigation" once in its selector. + // With deduplication: plain context (1) + dark descendant (1) + dark same-element (1) = 3. + // Without deduplication there would be 4 (an extra plain same-element rule). + expect(matches.length).toBe(3); + }); + + test('non-global theme emits two context rules with distinct selectors', () => { + // .secondary-theme is not global — descendant (.secondary-theme .navigation) + // and same-element (.navigation.secondary-theme) are genuinely different selectors. + const output = createBuildDeclarations( + secondaryTheme, + [], + preset.propertiesMap, + (selector) => selector, + Object.keys(secondaryTheme.tokens), + ); + + expect(output).toContain('.secondary-theme .navigation'); + expect(output).toContain('.navigation.secondary-theme'); + }); }); diff --git a/src/shared/declaration/__tests__/transformer.test.ts b/src/shared/declaration/__tests__/transformer.test.ts index 5e041fb..1e6aee0 100644 --- a/src/shared/declaration/__tests__/transformer.test.ts +++ b/src/shared/declaration/__tests__/transformer.test.ts @@ -56,4 +56,93 @@ describe('MinimalTransformer', () => { expect(result.getAllRules().length).toBe(1); expect(result.findRule('.child')).toBeUndefined(); }); + + test('merges adjacent rules with identical declarations into a comma selector', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(rootRule, []); + + const ruleA = new Rule('.a'); + ruleA.appendDeclaration(new Declaration('--color', 'red')); + stylesheet.appendRuleWithPath(ruleA, [rootRule]); + + const ruleB = new Rule('.b'); + ruleB.appendDeclaration(new Declaration('--color', 'red')); + stylesheet.appendRuleWithPath(ruleB, [rootRule]); + + const result = new MinimalTransformer().transform(stylesheet); + + expect(result.getAllRules().length).toBe(2); // :root + merged + const merged = result.getAllRules().find((r) => r.selector.includes('.a')); + expect(merged!.selector).toBe('.a,.b'); + }); + + test('does not merge non-adjacent rules with identical declarations', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(rootRule, []); + + const ruleA = new Rule('.a'); + ruleA.appendDeclaration(new Declaration('--color', 'red')); + stylesheet.appendRuleWithPath(ruleA, [rootRule]); + + // .between has different declarations, breaking adjacency + const between = new Rule('.between'); + between.appendDeclaration(new Declaration('--color', 'green')); + stylesheet.appendRuleWithPath(between, [rootRule]); + + const ruleB = new Rule('.b'); + ruleB.appendDeclaration(new Declaration('--color', 'red')); + stylesheet.appendRuleWithPath(ruleB, [rootRule]); + + const result = new MinimalTransformer().transform(stylesheet); + + expect(result.getAllRules().length).toBe(4); + expect(result.findRule('.a')).toBeDefined(); + expect(result.findRule('.b')).toBeDefined(); + }); + + test('does not merge adjacent rules with different declarations', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(rootRule, []); + + const ruleA = new Rule('.a'); + ruleA.appendDeclaration(new Declaration('--color', 'red')); + stylesheet.appendRuleWithPath(ruleA, [rootRule]); + + const ruleB = new Rule('.b'); + ruleB.appendDeclaration(new Declaration('--color', 'green')); + stylesheet.appendRuleWithPath(ruleB, [rootRule]); + + const result = new MinimalTransformer().transform(stylesheet); + + expect(result.getAllRules().length).toBe(3); + expect(result.findRule('.a')).toBeDefined(); + expect(result.findRule('.b')).toBeDefined(); + }); + + test('does not merge adjacent rules with identical declarations but different media', () => { + const stylesheet = new Stylesheet(); + const rootRule = new Rule(':root'); + rootRule.appendDeclaration(new Declaration('--color', 'blue')); + stylesheet.appendRuleWithPath(rootRule, []); + + const ruleA = new Rule('.a'); + ruleA.appendDeclaration(new Declaration('--color', 'red')); + stylesheet.appendRuleWithPath(ruleA, [rootRule]); + + const ruleB = new Rule('.b', 'print'); + ruleB.appendDeclaration(new Declaration('--color', 'red')); + stylesheet.appendRuleWithPath(ruleB, [rootRule]); + + const result = new MinimalTransformer().transform(stylesheet); + + expect(result.getAllRules().length).toBe(3); + expect(result.findRule('.a')).toBeDefined(); + expect(result.findRule('.b')).toBeDefined(); + }); }); diff --git a/src/shared/declaration/index.ts b/src/shared/declaration/index.ts index 04fccb3..d9a8d6c 100644 --- a/src/shared/declaration/index.ts +++ b/src/shared/declaration/index.ts @@ -105,5 +105,5 @@ export function createBuildDeclarations( new UsedPropertyRegistry(propertiesMap, usedTokens), ); const stylesheet = new MultiThemeCreator(themes, ruleCreator, propertiesMap).create(); - return new MinimalTransformer().transform(stylesheet).toString(); + return new MinimalTransformer().transform(stylesheet).toString('cloudscape-base-theme'); } diff --git a/src/shared/declaration/stylesheet.ts b/src/shared/declaration/stylesheet.ts index 924cdad..4d86d42 100644 --- a/src/shared/declaration/stylesheet.ts +++ b/src/shared/declaration/stylesheet.ts @@ -15,6 +15,14 @@ export default class Stylesheet { } appendRuleWithPath(rule: Rule, path: Rule[]) { + // When a global theme (body/:root/html) has a context, the descendant form + // { global: [body], local: [.ctx] } and the same-element form + // { global: [body, .ctx] } both resolve to the same selector string because + // the global selector is stripped. Silently skip the duplicate so that + // rulesMap and paths stay in sync (one entry each per selector). + if (this.rulesMap.has(rule.selector)) { + return; + } this.rulesMap.set(rule.selector, [rule, this.counter++]); this.paths.set(rule, path); } @@ -46,10 +54,9 @@ export default class Stylesheet { /** * @returns CSS */ - toString(): string { - return asValuesArray(this.rulesMap) - .map((rule) => rule.toString()) - .join('\n'); + toString(layer?: string): string { + const result = asValuesArray(this.rulesMap).map((rule) => rule.toString()); + return layer ? `@layer ${layer} {\n${result.join('\n')}\n}` : result.join('\n'); } } @@ -77,13 +84,18 @@ export class Rule { return asValuesArray(this.declarationsMap); } + printAllDeclarations() { + return this.getAllDeclarations() + .map((decl) => decl.toString()) + .join('\n\t'); + } + size(): number { return this.declarationsMap.size; } toString(): string { - const lines = asValuesArray(this.declarationsMap).map((decl) => decl.toString()); - const rule = `${this.selector}{\n\t${lines.join('\n\t')}\n}`; + const rule = `${this.selector}{\n\t${this.printAllDeclarations()}\n}`; if (this.media) { return `@media ${this.media} {${rule}}`; } diff --git a/src/shared/declaration/transformer.ts b/src/shared/declaration/transformer.ts index 51a9897..7dba77d 100644 --- a/src/shared/declaration/transformer.ts +++ b/src/shared/declaration/transformer.ts @@ -93,10 +93,38 @@ export class MinimalTransformer implements Transformer { } }); - return stylesheet; + return mergeSelectors(stylesheet); } } +/** + * Merges adjacent rules with identical declarations into a single comma-separated selector. + * + * For example: + * .a { --color-background: red; } + * .b { --color-background: red; } + * + * becomes: + * .a, + * .b { --color-background: red; } + */ +function mergeSelectors(stylesheet: Stylesheet): Stylesheet { + const rules = stylesheet.getAllRules(); + let i = 1; + while (i < rules.length) { + const prev = rules[i - 1]; + const curr = rules[i]; + if (prev.media === curr.media && prev.printAllDeclarations() === curr.printAllDeclarations()) { + prev.selector = `${prev.selector},${curr.selector}`; + stylesheet.removeRule(curr); + rules.splice(i, 1); + } else { + i++; + } + } + return stylesheet; +} + function difference(mapA: Record, mapB: Record): Record { const diff: Record = {}; From 7f6083169ad4a00966c0e6dd587eac8a538c5a9a Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Wed, 15 Apr 2026 11:04:46 +0200 Subject: [PATCH 2/2] additional optimisation --- .../__snapshots__/index.test.ts.snap | 7 +--- .../declaration/__tests__/index.test.ts | 36 +++++++++++++++++++ src/shared/declaration/multi.ts | 10 ++---- src/shared/declaration/single.ts | 5 +-- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap b/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap index 7b57178..7d9950e 100644 --- a/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap +++ b/src/shared/declaration/__tests__/__snapshots__/index.test.ts.snap @@ -55,14 +55,9 @@ body{ .secondary-theme .navigation,.navigation.secondary-theme{ --boxShadow-css:var(--shadow-css); } -@media not print {.dark.secondary-theme .navigation{ - --shadow-css:var(--grey-css); - --boxShadow-css:var(--brown-css); -}} -@media not print {.dark.navigation.secondary-theme{ +@media not print {.dark.secondary-theme .navigation,.dark.navigation.secondary-theme{ --shadow-css:var(--grey-css); --boxShadow-css:var(--brown-css); - --lineShadow-css:var(--boxShadow-css); }} }" `; diff --git a/src/shared/declaration/__tests__/index.test.ts b/src/shared/declaration/__tests__/index.test.ts index afa7d41..71c33bb 100644 --- a/src/shared/declaration/__tests__/index.test.ts +++ b/src/shared/declaration/__tests__/index.test.ts @@ -81,4 +81,40 @@ describe('renderDeclarations', () => { expect(output).toContain('.secondary-theme .navigation'); expect(output).toContain('.navigation.secondary-theme'); }); + + test('context descendant and same-element selectors are merged into a single rule', () => { + const output = createBuildDeclarations( + secondaryTheme, + [], + preset.propertiesMap, + (selector) => selector, + Object.keys(secondaryTheme.tokens), + ); + + expect(output).toContain('.secondary-theme .navigation,.navigation.secondary-theme'); + }); + + test('mode+context descendant and same-element selectors are merged into a single rule', () => { + const output = createBuildDeclarations( + secondaryTheme, + [], + preset.propertiesMap, + (selector) => selector, + Object.keys(secondaryTheme.tokens), + ); + + expect(output).toContain('.dark.secondary-theme .navigation,.dark.navigation.secondary-theme'); + }); + + test('secondary theme mode+context selectors are merged into a single rule', () => { + const output = createBuildDeclarations( + rootTheme, + [secondaryTheme], + preset.propertiesMap, + (selector) => selector, + Object.keys(rootTheme.tokens), + ); + + expect(output).toContain('.dark.secondary-theme .navigation,.dark.navigation.secondary-theme'); + }); }); diff --git a/src/shared/declaration/multi.ts b/src/shared/declaration/multi.ts index e39a3bb..d82c622 100644 --- a/src/shared/declaration/multi.ts +++ b/src/shared/declaration/multi.ts @@ -114,7 +114,7 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea MultiThemeCreator.appendRuleToStylesheet( stylesheet, contextRuleGlobal, - compact([rootRule, parentContextRule, parentRule]), + compact([parentContextRule, rootRule, parentRule]), ); }); @@ -167,10 +167,6 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea ]), ); - const parentContextAndModeRuleGlobal = stylesheet.findRule( - this.ruleCreator.selectorFor({ global: [secondary.selector, context.selector] }), - ); - const contextAndModeRuleGlobal = this.ruleCreator.create( { global: [secondary.selector, optionalState.selector, context.selector], @@ -184,10 +180,10 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea contextAndModeRuleGlobal, compact([ contextRule, + parentContextAndModeRule, + parentContextRule, modeRule, - parentContextAndModeRuleGlobal, rootRule, - parentContextRule, parentModeRule, parentRule, ]), diff --git a/src/shared/declaration/single.ts b/src/shared/declaration/single.ts index b4fbb20..27b8c81 100644 --- a/src/shared/declaration/single.ts +++ b/src/shared/declaration/single.ts @@ -92,9 +92,6 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre }), ); - const contextRuleGlobal = stylesheet.findRule( - this.ruleCreator.selectorFor({ global: [this.theme.selector, context.selector] }), - ); SingleThemeCreator.appendRuleToStylesheet( stylesheet, contextAndModeRule, @@ -108,7 +105,7 @@ export class SingleThemeCreator extends AbstractCreator implements StylesheetCre SingleThemeCreator.appendRuleToStylesheet( stylesheet, contextRuleAndModeRuleGlobal, - compact([contextRuleGlobal, modeRule, rootRule]), + compact([contextRule, modeRule, rootRule]), ); });