diff --git a/.changeset/open-taxis-hear.md b/.changeset/open-taxis-hear.md new file mode 100644 index 0000000000..62f6da1ec0 --- /dev/null +++ b/.changeset/open-taxis-hear.md @@ -0,0 +1,19 @@ +--- +"@lynx-js/template-webpack-plugin": patch +--- + +Fix CSS import order when `enableCSSSelector` is false. + +When the `enableCSSSelector` option is set to false, style rule priority is inversely related to `@import` order(Lynx CSS engine has the incorrect behavior). Reversing the import order to maintain correct priority is required. For example: + +```css +@import "0.css"; +@import "1.css"; +``` + +will convert to: + +```css +@import "1.css"; +@import "0.css"; +``` diff --git a/packages/webpack/css-extract-webpack-plugin/src/CssExtractRspackPlugin.ts b/packages/webpack/css-extract-webpack-plugin/src/CssExtractRspackPlugin.ts index 33e1bebaac..5f5dea5ae8 100644 --- a/packages/webpack/css-extract-webpack-plugin/src/CssExtractRspackPlugin.ts +++ b/packages/webpack/css-extract-webpack-plugin/src/CssExtractRspackPlugin.ts @@ -251,6 +251,7 @@ ${RuntimeGlobals.require}.cssHotUpdateList = ${ const { cssMap } = LynxTemplatePlugin.convertCSSChunksToMap( [content], options.cssPlugins, + options.enableCSSSelector, ); const cssDeps = Object.entries(cssMap).reduce< Record diff --git a/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md b/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md index 2daa694353..00a885e3b9 100644 --- a/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md +++ b/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md @@ -41,7 +41,7 @@ declare namespace CSS { // Warning: (ae-missing-release-tag) "cssChunksToMap" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -function cssChunksToMap(cssChunks: string[], plugins: CSS_2.Plugin[]): { +function cssChunksToMap(cssChunks: string[], plugins: CSS_2.Plugin[], enableCSSSelector: boolean): { cssMap: Record; cssSource: Record; contentMap: Map; @@ -84,7 +84,7 @@ export interface LynxEncodePluginOptions { export class LynxTemplatePlugin { constructor(options?: LynxTemplatePluginOptions | undefined); apply(compiler: Compiler): void; - static convertCSSChunksToMap(cssChunks: string[], plugins: CSS_2.Plugin[]): { + static convertCSSChunksToMap(cssChunks: string[], plugins: CSS_2.Plugin[], enableCSSSelector: boolean): { cssMap: Record; cssSource: Record; }; diff --git a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts index 42bf5ff7fa..02ad0b722c 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts @@ -435,11 +435,12 @@ export class LynxTemplatePlugin { static convertCSSChunksToMap( cssChunks: string[], plugins: CSS.Plugin[], + enableCSSSelector: boolean, ): { cssMap: Record; cssSource: Record; } { - return cssChunksToMap(cssChunks, plugins); + return cssChunksToMap(cssChunks, plugins, enableCSSSelector); } /** @@ -761,6 +762,7 @@ class LynxTemplatePluginImpl { .filter((v): v is Asset => !!v) .map(asset => asset.source.source().toString()), cssPlugins, + enableCSSSelector, ); const encodeRawData: EncodeRawData = { compilerOptions: { diff --git a/packages/webpack/template-webpack-plugin/src/css/cssChunksToMap.ts b/packages/webpack/template-webpack-plugin/src/css/cssChunksToMap.ts index 84402c4a57..e2cbfe1cfb 100644 --- a/packages/webpack/template-webpack-plugin/src/css/cssChunksToMap.ts +++ b/packages/webpack/template-webpack-plugin/src/css/cssChunksToMap.ts @@ -6,14 +6,18 @@ import type * as CSS from '@lynx-js/css-serializer'; import { cssToAst } from './ast.js'; import { debundleCSS } from './debundle.js'; -export function cssChunksToMap(cssChunks: string[], plugins: CSS.Plugin[]): { +export function cssChunksToMap( + cssChunks: string[], + plugins: CSS.Plugin[], + enableCSSSelector: boolean, +): { cssMap: Record; cssSource: Record; contentMap: Map; } { const cssMap = cssChunks .reduce>((cssMap, css) => { - debundleCSS(css, cssMap); + debundleCSS(css, cssMap, enableCSSSelector); return cssMap; }, new Map()); diff --git a/packages/webpack/template-webpack-plugin/src/css/debundle.ts b/packages/webpack/template-webpack-plugin/src/css/debundle.ts index cbe12c7797..5995247686 100644 --- a/packages/webpack/template-webpack-plugin/src/css/debundle.ts +++ b/packages/webpack/template-webpack-plugin/src/css/debundle.ts @@ -9,7 +9,11 @@ import * as cssTree from 'css-tree'; const COMMON_CSS = '/common.css'; const COMMON_CSS_ID = 0; -export function debundleCSS(code: string, css: Map): void { +export function debundleCSS( + code: string, + css: Map, + enableCSSSelector: boolean, +): void { const ast = cssTree.parse(code); const fileKeyToCSSContent = new Map(); @@ -91,7 +95,13 @@ export function debundleCSS(code: string, css: Map): void { // For each scoped CSSStyleSheet, we should import the real CSSStyleSheet. // So that the styles can be resolved with the scoped cssId. - cssIdToFileKeys.forEach((fileKeys, cssId) => { + cssIdToFileKeys.forEach((rawFileKeys, cssId) => { + let fileKeys = Array.from(rawFileKeys); + if (enableCSSSelector === false) { + // When enableCSSSelector is false, style rule priority is inversely related to @import order, + // requiring reversed imports to maintain correct priority. + fileKeys = fileKeys.reverse(); + } emplaceCSSStyleSheet( css, cssId, diff --git a/packages/webpack/template-webpack-plugin/src/css/encode.ts b/packages/webpack/template-webpack-plugin/src/css/encode.ts index f9e3addfa7..ceb8796320 100644 --- a/packages/webpack/template-webpack-plugin/src/css/encode.ts +++ b/packages/webpack/template-webpack-plugin/src/css/encode.ts @@ -52,7 +52,7 @@ export async function encodeCSS( }); }, ): Promise { - const css = cssChunksToMap(cssChunks, plugins); + const css = cssChunksToMap(cssChunks, plugins, enableCSSSelector); const encodeOptions = { compilerOptions: { diff --git a/packages/webpack/template-webpack-plugin/test/css.test.ts b/packages/webpack/template-webpack-plugin/test/css.test.ts index cc079f74ea..8759b63242 100644 --- a/packages/webpack/template-webpack-plugin/test/css.test.ts +++ b/packages/webpack/template-webpack-plugin/test/css.test.ts @@ -51,11 +51,15 @@ describe('CSS', () => { describe('cssChunksToMap', () => { test('global styles only', () => { - const { cssMap, cssSource } = cssChunksToMap([ - `\ + const { cssMap, cssSource } = cssChunksToMap( + [ + `\ .foo { color: red; } .bar { color: blue; }`, - ], [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()]); + ], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + true, + ); expect(Object.keys(cssMap)).toMatchInlineSnapshot(` [ @@ -79,12 +83,16 @@ describe('CSS', () => { }); test('scoped styles only', () => { - const { cssSource, cssMap } = cssChunksToMap([ - `\ + const { cssSource, cssMap } = cssChunksToMap( + [ + `\ @cssId "1000" "foo.css" { .foo { color: red; } } @cssId "1001" "bar.css" { .bar { color: blue; } } @cssId "1000" "baz.css" { .baz { color: yellow; } }`, - ], [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()]); + ], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + true, + ); // 1000 has 3 ImportRules // 0 -> common.css(global styles) @@ -167,8 +175,9 @@ describe('CSS', () => { }); test('mixed global styles with scoped styles', () => { - const { cssSource, cssMap } = cssChunksToMap([ - `\ + const { cssSource, cssMap } = cssChunksToMap( + [ + `\ .root { background-color: black; } @cssId "1000" "foo.css" { .foo { color: red; } } @@ -177,7 +186,10 @@ describe('CSS', () => { .wrapper { display: flex; } `, - ], [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()]); + ], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + true, + ); expect(Object.keys(cssMap)).toMatchInlineSnapshot(` [ @@ -272,38 +284,160 @@ describe('CSS', () => { `); }); + test('mixed global styles with scoped styles when enableCSSSelector is false', () => { + const { cssSource, cssMap } = cssChunksToMap( + [ + `\ +.root { background-color: black; } + +@cssId "1000" "foo.css" { .foo { color: red; } } +@cssId "1001" "bar.css" { .bar { color: blue; } } +@cssId "1000" "baz.css" { .baz { color: yellow; } } + +.wrapper { display: flex; } +`, + ], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + false, + ); + + expect(Object.keys(cssMap)).toMatchInlineSnapshot(` + [ + "0", + "1", + "2", + "3", + "1000", + "1001", + ] + `); + + // 1000 has 3 ImportRules + // 0 -> common.css(global styles) + expect(cssMap[0]).not.toBeUndefined(); + expect(getAllSelectors(cssMap[0])).toMatchInlineSnapshot(` + [ + ".root", + ".wrapper", + ] + `); + // 1 -> foo.css + expect(getAllSelectors(cssMap[1])).toMatchInlineSnapshot(` + [ + ".foo", + ] + `); + // 3 -> baz.css + expect(getAllSelectors(cssMap[3])).toMatchInlineSnapshot(` + [ + ".baz", + ] + `); + expect(cssMap[1000]).toMatchInlineSnapshot(` + [ + { + "href": "3", + "origin": "3", + "type": "ImportRule", + }, + { + "href": "1", + "origin": "1", + "type": "ImportRule", + }, + { + "href": "0", + "origin": "0", + "type": "ImportRule", + }, + ] + `); + + // 1001 has 2 ImportRules + // 0 -> common.css(global styles) + expect(getAllSelectors(cssMap[0])).toMatchInlineSnapshot(` + [ + ".root", + ".wrapper", + ] + `); + // 2 -> bar.css + expect(getAllSelectors(cssMap[2])).toMatchInlineSnapshot(` + [ + ".bar", + ] + `); + expect(cssMap[1001]).toMatchInlineSnapshot(` + [ + { + "href": "2", + "origin": "2", + "type": "ImportRule", + }, + { + "href": "0", + "origin": "0", + "type": "ImportRule", + }, + ] + `); + + expect(cssSource).toMatchInlineSnapshot(` + { + "0": "/cssId/0.css", + "1": "/cssId/1.css", + "1000": "/cssId/1000.css", + "1001": "/cssId/1001.css", + "2": "/cssId/2.css", + "3": "/cssId/3.css", + } + `); + }); + test('remove css without data', () => { - const { cssMap } = cssChunksToMap([ - `\ + const { cssMap } = cssChunksToMap( + [ + `\ .root { -webkit-appearance: black; } `, - ], [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()]); + ], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + true, + ); expect(cssMap[0]).toHaveLength(1); expect(cssMap[0]?.[0]).toHaveProperty('type', 'StyleRule'); }); test('remove css with non-compatible', () => { - const { cssMap } = cssChunksToMap([ - `\ + const { cssMap } = cssChunksToMap( + [ + `\ .root { z-index: 10 } `, - ], [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()]); + ], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + true, + ); expect(cssMap[0]).toHaveLength(1); expect(cssMap[0]?.[0]).toHaveProperty('type', 'StyleRule'); }); test('not remove css variables', () => { - const { cssMap } = cssChunksToMap([ - `\ + const { cssMap } = cssChunksToMap( + [ + `\ :root { --foo: red; --bar: blue; color: var(--red); } `, - ], [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()]); + ], + [CSSPlugins.parserPlugins.removeFunctionWhiteSpace()], + true, + ); expect(cssMap[0]).toHaveLength(1); expect(cssMap[0]?.[0]).toHaveProperty('type', 'StyleRule'); @@ -334,6 +468,7 @@ describe('CSS', () => { } `, css, + true, ); expect(css).toMatchInlineSnapshot(` @@ -349,6 +484,36 @@ describe('CSS', () => { `); }); + test('debundle cssId when enableCSSSelector is false', () => { + const css = new Map(); + debundleCSS( + `\ +@cssId "400004" "foo.css" { + .split-line-wrapper .split-line__dark { + background-color: rgba(255, 255, 255, 0.12); + } + .split-line-wrapper .split-line__light { + background-color: rgba(22, 24, 35, 0.12); + } +} +`, + css, + false, + ); + + expect(css).toMatchInlineSnapshot(` + Map { + 1 => [ + ".split-line-wrapper .split-line__dark{background-color:rgba(255,255,255,0.12)}.split-line-wrapper .split-line__light{background-color:rgba(22,24,35,0.12)}", + ], + 400004 => [ + "@import "1"; + @import "0";", + ], + } + `); + }); + test('debundle multiple cssId', () => { const css = new Map(); debundleCSS( @@ -369,6 +534,7 @@ describe('CSS', () => { } `, css, + true, ); expect(css).toMatchInlineSnapshot(` @@ -391,6 +557,49 @@ describe('CSS', () => { `); }); + test('debundle multiple cssId when enableCSSSelector is false', () => { + const css = new Map(); + debundleCSS( + `\ +@cssId "400004" "foo.css" { + .split-line-wrapper .split-line__dark { + background-color: rgba(255, 255, 255, 0.12); + } + .split-line-wrapper .split-line__light { + background-color: rgba(22, 24, 35, 0.12); + } +} + +@cssId "1000" "bar.css" { + .foo { + color: red; + } +} +`, + css, + false, + ); + + expect(css).toMatchInlineSnapshot(` + Map { + 1 => [ + ".split-line-wrapper .split-line__dark{background-color:rgba(255,255,255,0.12)}.split-line-wrapper .split-line__light{background-color:rgba(22,24,35,0.12)}", + ], + 2 => [ + ".foo{color:red}", + ], + 400004 => [ + "@import "1"; + @import "0";", + ], + 1000 => [ + "@import "2"; + @import "0";", + ], + } + `); + }); + test('debundle multiple blocks with same cssId', () => { const css = new Map(); debundleCSS( @@ -411,6 +620,7 @@ describe('CSS', () => { } `, css, + true, ); expect(css).toMatchInlineSnapshot(` @@ -454,6 +664,7 @@ describe('CSS', () => { } `, css, + true, ); expect(css).toMatchInlineSnapshot(` @@ -477,6 +688,54 @@ describe('CSS', () => { `); }); + test('debundle with blocks without cssId and cssId: 0 when enableCSSSelector is false', () => { + const css = new Map(); + debundleCSS( + `\ +@cssId "400004" "foo.css" { + .split-line-wrapper .split-line__dark { + background-color: rgba(255, 255, 255, 0.12); + } + .split-line-wrapper .split-line__light { + background-color: rgba(22, 24, 35, 0.12); + } +} + +@cssId "0" "common.css" { + .bar { + color: blue; + } +} + +.foo { + color: red; +} +`, + css, + false, + ); + + expect(css).toMatchInlineSnapshot(` + Map { + 0 => [ + ".foo{color:red}", + "@import "2"; + @import "0";", + ], + 1 => [ + ".split-line-wrapper .split-line__dark{background-color:rgba(255,255,255,0.12)}.split-line-wrapper .split-line__light{background-color:rgba(22,24,35,0.12)}", + ], + 2 => [ + ".bar{color:blue}", + ], + 400004 => [ + "@import "1"; + @import "0";", + ], + } + `); + }); + test('debundle with blocks without cssId', () => { const css = new Map(); debundleCSS( @@ -495,6 +754,7 @@ describe('CSS', () => { } `, css, + true, ); expect(css).toMatchInlineSnapshot(` @@ -513,6 +773,43 @@ describe('CSS', () => { `); }); + test('debundle with blocks without cssId when enableCSSSelector is false', () => { + const css = new Map(); + debundleCSS( + `\ +@cssId "400004" "foo.css" { + .split-line-wrapper .split-line__dark { + background-color: rgba(255, 255, 255, 0.12); + } + .split-line-wrapper .split-line__light { + background-color: rgba(22, 24, 35, 0.12); + } +} + +.foo { + color: red; +} +`, + css, + false, + ); + + expect(css).toMatchInlineSnapshot(` + Map { + 0 => [ + ".foo{color:red}", + ], + 1 => [ + ".split-line-wrapper .split-line__dark{background-color:rgba(255,255,255,0.12)}.split-line-wrapper .split-line__light{background-color:rgba(22,24,35,0.12)}", + ], + 400004 => [ + "@import "1"; + @import "0";", + ], + } + `); + }); + test('minified(toLowerCase) cssId at-rule', () => { const css = new Map(); debundleCSS( @@ -533,6 +830,7 @@ describe('CSS', () => { } `, css, + true, ); expect(css).toMatchInlineSnapshot(` @@ -564,6 +862,7 @@ describe('CSS', () => { } `, css, + true, ); expect(css).toMatchInlineSnapshot(` @@ -599,6 +898,7 @@ describe('CSS', () => { } `, css, + true, ) ).toThrowErrorMatchingInlineSnapshot( `[Error: Invalid cssId: @cssId "foo" "bar.css"]`,